diff --git a/.dockerignore b/.dockerignore index 055ae7ed..a03616e5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,4 @@ -*.egg-info -.coverage .git -.tox build -coverage-html -docs/_site +dist venv -.tox diff --git a/.gitignore b/.gitignore index 4b318e23..da7fe7fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,8 @@ *.egg-info *.pyc -.coverage* -/.tox +.tox /build -/coverage-html /dist /docs/_site /venv -README.rst -compose/GITSHA +docker-compose.spec diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 0e7b9d5f..00000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,25 +0,0 @@ -- repo: git://github.com/pre-commit/pre-commit-hooks - sha: 'v0.4.2' - 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: v0.1.0 - hooks: - - id: reorder-python-imports - language_version: 'python2.7' - args: - - --add-import - - from __future__ import absolute_import - - --add-import - - from __future__ import unicode_literals diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index fbf26964..00000000 --- a/.travis.yml +++ /dev/null @@ -1,29 +0,0 @@ -sudo: required - -language: python - -matrix: - include: - - os: linux - services: - - docker - - os: osx - language: generic - -install: ./script/travis/install - -script: - - ./script/travis/ci - - ./script/travis/build-binary - -before_deploy: - - "./script/travis/render-bintray-config.py < ./script/travis/bintray.json.tmpl > ./bintray.json" - -deploy: - provider: bintray - user: docker-compose-roleuser - key: '$BINTRAY_API_KEY' - file: ./bintray.json - skip_cleanup: true - on: - all_branches: true diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 9c07e9ca..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,1174 +0,0 @@ -Change log -========== - -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 deleted file mode 120000 index 83b69470..00000000 --- a/CHANGES.md +++ /dev/null @@ -1 +0,0 @@ -CHANGELOG.md \ No newline at end of file diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 00000000..8ceb2680 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,343 @@ +Change log +========== + +1.3.0 RC3 (2015-06-15) +---------------------- + +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 Docker labels, rather than container names, to keep track of containers. This is both cleaner and more performant. + +- 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/reference/run/#security-configuration). +- `log_driver`, like `docker run --log-driver`, lets you specify a [log driver](https://docs.docker.com/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, @josephpage, @KyleJamesWalker, @lsowen, @mchasal, @sdake, @sherter, @stephenlawrence, @turtlemonvh, @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/CONTRIBUTING.md b/CONTRIBUTING.md index 16bccf98..6914e215 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,53 +22,66 @@ that should get you started. 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 +3. Enter the local directory `cd compose`. +4. 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. -## 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. - -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). - -To run the style checks at any time run `tox -e pre-commit`. - -## Submitting a pull request - -See Docker's [basic contribution workflow](https://docs.docker.com/opensource/workflow/make-a-contribution/#the-basic-contribution-workflow) for a guide on how to submit a pull request for code or documentation. - ## Running the test suite Use the test script to run linting checks and then the full test suite against different Python interpreters: - $ script/test/default + $ script/test 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: - $ DOCKER_VERSIONS=all script/test/default + $ DOCKER_VERSIONS=all script/test -Arguments to `script/test/default` are passed through to the `tox` executable, so +Arguments to `script/test` are passed through to the `nosetests` executable, so you can specify a test directory, file, module, class or method: - $ script/test/default tests/unit - $ script/test/default tests/unit/cli_test.py - $ script/test/default tests/unit/config_test.py::ConfigTest - $ script/test/default tests/unit/config_test.py::ConfigTest::test_load + $ script/test tests/unit + $ script/test tests/unit/cli_test.py + $ script/test tests.integration.service_test + $ script/test tests.integration.service_test:ServiceTest.test_containers -## Finding things to work on +## Building binaries -We use a [ZenHub board](https://www.zenhub.io/) to keep track of specific things we are working on and planning to work on. If you're looking for things to work on, stuff in the backlog is a great place to start. +`script/build-linux` will build the Linux binary inside a Docker container: -For more information about our project planning, take a look at our [GitHub wiki](https://github.com/docker/compose/wiki). + $ script/build-linux + +`script/build-osx` will build the Mac OS X binary inside a virtualenv: + + $ script/build-osx + +For official releases, you should build inside a Mountain Lion VM for proper +compatibility. Run the this script first to prepare the environment before +building - it will use Homebrew to make sure Python is installed and +up-to-date. + + $ script/prepare-osx + +## Release process + +1. Open pull request that: + - Updates the version in `compose/__init__.py` + - Updates the binary URL in `docs/install.md` + - Adds release notes to `CHANGES.md` +2. Create unpublished GitHub release with release notes +3. Build Linux version on any Docker host with `script/build-linux` and attach + to release +4. Build OS X version on Mountain Lion with `script/build-osx` and attach to + release as `docker-compose-Darwin-x86_64` and `docker-compose-Linux-x86_64`. +5. Publish GitHub release, creating tag +6. Update website with `script/deploy-docs` +7. Upload PyPi package + + $ git checkout $VERSION + $ python setup.py sdist upload diff --git a/Dockerfile b/Dockerfile index a03e1510..1ff2d382 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,69 +3,74 @@ FROM debian:wheezy RUN set -ex; \ apt-get update -qq; \ apt-get install -y \ - locales \ gcc \ make \ zlib1g \ zlib1g-dev \ libssl-dev \ git \ + apt-transport-https \ ca-certificates \ curl \ - libsqlite3-dev \ - libbz2-dev \ + lxc \ + iptables \ ; \ rm -rf /var/lib/apt/lists/* -RUN curl https://get.docker.com/builds/Linux/x86_64/docker-1.8.3 \ - -o /usr/local/bin/docker && \ - chmod +x /usr/local/bin/docker - -# Build Python 2.7.13 from source +# Build Python 2.7.9 from source RUN set -ex; \ - curl -L https://www.python.org/ftp/python/2.7.13/Python-2.7.13.tgz | tar -xz; \ - cd Python-2.7.13; \ + curl -LO https://www.python.org/ftp/python/2.7.9/Python-2.7.9.tgz; \ + tar -xzf Python-2.7.9.tgz; \ + cd Python-2.7.9; \ ./configure --enable-shared; \ make; \ make install; \ cd ..; \ - rm -rf /Python-2.7.13 - -# Build python 3.4 from source -RUN set -ex; \ - curl -L https://www.python.org/ftp/python/3.4.6/Python-3.4.6.tgz | tar -xz; \ - cd Python-3.4.6; \ - ./configure --enable-shared; \ - make; \ - make install; \ - cd ..; \ - rm -rf /Python-3.4.6 + rm -rf /Python-2.7.9; \ + rm Python-2.7.9.tgz # Make libpython findable ENV LD_LIBRARY_PATH /usr/local/lib +# Install setuptools +RUN set -ex; \ + curl -LO https://bootstrap.pypa.io/ez_setup.py; \ + python ez_setup.py; \ + rm ez_setup.py + # Install pip RUN set -ex; \ - curl -L https://bootstrap.pypa.io/get-pip.py | python + curl -LO https://pypi.python.org/packages/source/p/pip/pip-7.0.1.tar.gz; \ + tar -xzf pip-7.0.1.tar.gz; \ + cd pip-7.0.1; \ + python setup.py install; \ + cd ..; \ + rm -rf pip-7.0.1; \ + rm pip-7.0.1.tar.gz -# Python3 requires a valid locale -RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen -ENV LANG en_US.UTF-8 +ENV ALL_DOCKER_VERSIONS 1.6.0 1.7.0-rc2 + +RUN set -ex; \ + curl https://get.docker.com/builds/Linux/x86_64/docker-1.6.0 -o /usr/local/bin/docker-1.6.0; \ + chmod +x /usr/local/bin/docker-1.6.0; \ + curl https://test.docker.com/builds/Linux/x86_64/docker-1.7.0-rc2 -o /usr/local/bin/docker-1.7.0-rc2; \ + chmod +x /usr/local/bin/docker-1.7.0-rc2 + +# Set the default Docker to be run +RUN ln -s /usr/local/bin/docker-1.6.0 /usr/local/bin/docker RUN useradd -d /home/user -m -s /bin/bash user WORKDIR /code/ -RUN pip install tox==2.1.1 - ADD requirements.txt /code/ +RUN pip install -r requirements.txt + ADD requirements-dev.txt /code/ -ADD .pre-commit-config.yaml /code/ -ADD setup.py /code/ -ADD tox.ini /code/ -ADD compose /code/compose/ -RUN tox --notest +RUN pip install -r requirements-dev.txt ADD . /code/ +RUN python setup.py install + RUN chown -R user /code/ -ENTRYPOINT ["/code/.tox/py27/bin/docker-compose"] +ENTRYPOINT ["/usr/local/bin/docker-compose"] diff --git a/Dockerfile.run b/Dockerfile.run deleted file mode 100644 index de46e35e..00000000 --- a/Dockerfile.run +++ /dev/null @@ -1,14 +0,0 @@ - -FROM alpine:3.4 -ARG version -RUN apk -U add \ - python \ - py-pip - -COPY requirements.txt /code/requirements.txt -RUN pip install -r /code/requirements.txt - -COPY dist/docker_compose-${version}-py2.py3-none-any.whl /code/ -RUN pip install --no-deps /code/docker_compose-${version}-py2.py3-none-any.whl - -ENTRYPOINT ["/usr/bin/docker-compose"] diff --git a/Jenkinsfile b/Jenkinsfile deleted file mode 100644 index 51136b1f..00000000 --- a/Jenkinsfile +++ /dev/null @@ -1,64 +0,0 @@ -#!groovy - -def image - -def buildImage = { -> - wrappedNode(label: "ubuntu && !zfs", cleanWorkspace: true) { - stage("build image") { - checkout(scm) - def imageName = "dockerbuildbot/compose:${gitCommit()}" - image = docker.image(imageName) - try { - image.pull() - } catch (Exception exc) { - image = docker.build(imageName, ".") - image.push() - } - } - } -} - -def runTests = { Map settings -> - def dockerVersions = settings.get("dockerVersions", null) - def pythonVersions = settings.get("pythonVersions", null) - - if (!pythonVersions) { - throw new Exception("Need Python versions to test. e.g.: `runTests(pythonVersions: 'py27,py34')`") - } - if (!dockerVersions) { - throw new Exception("Need Docker versions to test. e.g.: `runTests(dockerVersions: 'all')`") - } - - { -> - wrappedNode(label: "ubuntu && !zfs", cleanWorkspace: true) { - stage("test python=${pythonVersions} / docker=${dockerVersions}") { - checkout(scm) - def storageDriver = sh(script: 'docker info | awk -F \': \' \'$1 == "Storage Driver" { print $2; exit }\'', returnStdout: true).trim() - echo "Using local system's storage driver: ${storageDriver}" - sh """docker run \\ - -t \\ - --rm \\ - --privileged \\ - --volume="\$(pwd)/.git:/code/.git" \\ - --volume="/var/run/docker.sock:/var/run/docker.sock" \\ - -e "TAG=${image.id}" \\ - -e "STORAGE_DRIVER=${storageDriver}" \\ - -e "DOCKER_VERSIONS=${dockerVersions}" \\ - -e "BUILD_NUMBER=\$BUILD_TAG" \\ - -e "PY_TEST_VERSIONS=${pythonVersions}" \\ - --entrypoint="script/ci" \\ - ${image.id} \\ - --verbose - """ - } - } - } -} - -buildImage() -// TODO: break this out into meaningful "DOCKER_VERSIONS" values instead of all -parallel( - failFast: true, - all_py27: runTests(pythonVersions: "py27", dockerVersions: "all"), - all_py34: runTests(pythonVersions: "py34", dockerVersions: "all"), -) diff --git a/MAINTAINERS b/MAINTAINERS index 820b2f82..8ac3985f 100644 --- a/MAINTAINERS +++ b/MAINTAINERS @@ -1,46 +1,3 @@ -# 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 = [ - "aanand", - "bfirsh", - "dnephin", - "mnowster", - ] - -[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.bfirsh] - Name = "Ben Firshman" - Email = "ben@firshman.co.uk" - GitHub = "bfirsh" - - [people.dnephin] - Name = "Daniel Nephin" - Email = "dnephin@gmail.com" - GitHub = "dnephin" - - [people.mnowster] - Name = "Mazz Mosley" - Email = "mazz@houseofmnowster.com" - GitHub = "mnowster" +Aanand Prasad (@aanand) +Ben Firshman (@bfirsh) +Daniel Nephin (@dnephin) diff --git a/MANIFEST.in b/MANIFEST.in index 8c6f932b..2acd5ab6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,11 +4,7 @@ include requirements.txt include requirements-dev.txt include tox.ini include *.md -exclude README.md -include README.rst -include compose/config/*.json -include compose/GITSHA -recursive-include contrib/completion * +include contrib/completion/bash/docker-compose recursive-include tests * global-exclude *.pyc global-exclude *.pyo diff --git a/README.md b/README.md index 35a10b90..4b18fc9d 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,14 @@ Docker Compose ============== -![Docker Compose](logo.png?raw=true "Docker Compose Logo") +*(Previously known as Fig)* -Compose is a tool for defining and running multi-container Docker applications. -With Compose, you use a Compose file to configure your application's services. -Then, using a single command, you create and start all the services -from your configuration. To learn more about all the features of Compose -see [the list of features](https://github.com/docker/docker.github.io/blob/master/compose/overview.md#features). +Compose is a tool for defining and running multi-container applications with +Docker. With Compose, you define a multi-container application in a single +file, then spin your application up in a single command which does everything +that needs to be done to get it running. -Compose is great for development, testing, and staging environments, as well as -CI workflows. You can learn more about each case in -[Common Use Cases](https://github.com/docker/docker.github.io/blob/master/compose/overview.md#common-use-cases). +Compose is great for development environments, staging servers, and CI. We don't +recommend that you use it in production yet. Using Compose is basically a three-step process. @@ -22,20 +20,16 @@ they can be run together in an isolated environment: A `docker-compose.yml` looks like this: - version: '2' - - services: - web: - build: . - ports: - - "5000:5000" - volumes: - - .:/code - redis: - image: redis - -For more information about the Compose file, see the -[Compose file reference](https://github.com/docker/docker.github.io/blob/master/compose/compose-file/compose-versioning.md) + web: + build: . + ports: + - "5000:5000" + volumes: + - .:/code + links: + - redis + redis: + image: redis Compose has commands for managing the whole lifecycle of your application: @@ -47,10 +41,8 @@ Compose has commands for managing the whole lifecycle of your application: Installation and documentation ------------------------------ -- Full documentation is available on [Docker's website](https://docs.docker.com/compose/). +- Full documentation is available on [Docker's website](http://docs.docker.com/compose/). - If you have any questions, you can talk in real-time with other developers in the #docker-compose IRC channel on Freenode. [Click here to join using IRCCloud.](https://www.irccloud.com/invite?hostname=irc.freenode.net&channel=%23docker-compose) -- Code repository for Compose is on [Github](https://github.com/docker/compose) -- If you find any problems please fill out an [issue](https://github.com/docker/compose/issues/new) Contributing ------------ @@ -58,8 +50,3 @@ Contributing [![Build Status](http://jenkins.dockerproject.org/buildStatus/icon?job=Compose%20Master)](http://jenkins.dockerproject.org/job/Compose%20Master/) Want to help build Compose? Check out our [contributing documentation](https://github.com/docker/compose/blob/master/CONTRIBUTING.md). - -Releasing ---------- - -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/ROADMAP.md b/ROADMAP.md index 287e5468..a74a781e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,23 +1,12 @@ # Roadmap -## An even better tool for development environments - -Compose is a great tool for development environments, but it could be even better. For example: - -- It should be possible to define hostnames for containers which work from the host machine, e.g. “mywebcontainer.local”. This is needed by apps comprising multiple web services which generate links to one another (e.g. a frontend website and a separate admin webapp) - ## More than just development environments -Compose currently works really well in development, but we want to make the Compose file format better for test, staging, and production environments. To support these use cases, there will need to be improvements to the file format, improvements to the command-line tool, integrations with other tools, and perhaps new tools altogether. +Over time we will extend Compose's remit to cover test, staging and production environments. This is not a simple task, and will take many incremental improvements such as: -Some specific things we are considering: - -- Compose currently will attempt to get your application into the correct state when running `up`, but it has a number of shortcomings: - - It should roll back to a known good state if it fails. - - It should allow a user to check the actions it is about to perform before running them. -- It should be possible to partially modify the config file for different environments (dev/test/staging/prod), passing in e.g. custom ports, volume mount paths, or volume drivers. ([#1377](https://github.com/docker/compose/issues/1377)) +- Compose’s brute-force “delete and recreate everything” approach is great for dev and testing, but it not sufficient for production environments. You should be able to define a "desired" state that Compose will intelligently converge to. +- It should be possible to partially modify the config file for different environments (dev/test/staging/prod), passing in e.g. custom ports or volume mount paths. ([#426](https://github.com/docker/fig/issues/426)) - Compose should recommend a technique for zero-downtime deploys. -- It should be possible to continuously attempt to keep an application in the correct state, instead of just performing `up` a single time. ## Integration with Swarm @@ -30,3 +19,10 @@ The current state of integration is documented in [SWARM.md](SWARM.md). Compose works well for applications that are in a single repository and depend on services that are hosted on Docker Hub. If your application depends on another application within your organisation, Compose doesn't work as well. There are several ideas about how this could work, such as [including external files](https://github.com/docker/fig/issues/318). + +## An even better tool for development environments + +Compose is a great tool for development environments, but it could be even better. For example: + +- [Compose could watch your code and automatically kick off builds when something changes.](https://github.com/docker/fig/issues/184) +- It should be possible to define hostnames for containers which work from the host machine, e.g. “mywebcontainer.local”. This is needed by apps comprising multiple web services which generate links to one another (e.g. a frontend website and a separate admin webapp) diff --git a/SWARM.md b/SWARM.md index c6f378a9..6cb24b60 100644 --- a/SWARM.md +++ b/SWARM.md @@ -1 +1,32 @@ -This file has moved to: https://docs.docker.com/compose/swarm/ +Docker Compose/Swarm integration +================================ + +Eventually, Compose and Swarm aim to have full integration, meaning you can point a Compose app at a Swarm cluster and have it all just work as if you were using a single Docker host. + +However, the current extent of integration is minimal: Compose can create containers on a Swarm cluster, but the majority of Compose apps won’t work out of the box unless all containers are scheduled on one host, defeating much of the purpose of using Swarm in the first place. + +Still, Compose and Swarm can be useful in a “batch processing” scenario (where a large number of containers need to be spun up and down to do independent computation) or a “shared cluster” scenario (where multiple teams want to deploy apps on a cluster without worrying about where to put them). + +A number of things need to happen before full integration is achieved, which are documented below. + +Links and networking +-------------------- + +The primary thing stopping multi-container apps from working seamlessly on Swarm is getting them to talk to one another: enabling private communication between containers on different hosts hasn’t been solved in a non-hacky way. + +Long-term, networking is [getting overhauled](https://github.com/docker/docker/issues/9983) in such a way that it’ll fit the multi-host model much better. For now, **linked containers are automatically scheduled on the same host**. + +Building +-------- + +`docker build` against a Swarm cluster is not implemented, so for now the `build` option will not work - you will need to manually build your service's image, push it somewhere and use `image` to instruct Compose to pull it. Here's an example using the Docker Hub: + + $ docker build -t myusername/web . + $ docker push myusername/web + $ cat docker-compose.yml + web: + image: myusername/web + links: ["db"] + db: + image: postgres + $ docker-compose up -d diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index e4f39544..00000000 --- a/appveyor.yml +++ /dev/null @@ -1,24 +0,0 @@ - -version: '{branch}-{build}' - -install: - - "SET PATH=C:\\Python27-x64;C:\\Python27-x64\\Scripts;%PATH%" - - "python --version" - - "pip install tox==2.1.1 virtualenv==13.1.2" - -# Build the binary after tests -build: false - -test_script: - - "tox -e py27,py34 -- tests/unit" - - ps: ".\\script\\build\\windows.ps1" - -artifacts: - - path: .\dist\docker-compose-Windows-x86_64.exe - name: "Compose Windows binary" - -deploy: - - provider: Environment - name: master-builds - on: - branch: master diff --git a/compose/__init__.py b/compose/__init__.py index b2ca86f8..1c7782c7 100644 --- a/compose/__init__.py +++ b/compose/__init__.py @@ -1,4 +1,3 @@ -from __future__ import absolute_import from __future__ import unicode_literals -__version__ = '1.12.0dev' +__version__ = '1.3.0rc3' diff --git a/compose/__main__.py b/compose/__main__.py deleted file mode 100644 index 27a7acbb..00000000 --- a/compose/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -from compose.cli.main import main - -main() diff --git a/compose/bundle.py b/compose/bundle.py deleted file mode 100644 index 505ce91f..00000000 --- a/compose/bundle.py +++ /dev/null @@ -1,258 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import json -import logging - -import six -from docker.utils import split_command -from docker.utils.ports import split_port - -from .cli.errors import UserError -from .config.serialize import denormalize_config -from .network import get_network_defs_for_service -from .service import format_environment -from .service import NoSuchImageError -from .service import parse_repository_tag - - -log = logging.getLogger(__name__) - - -SERVICE_KEYS = { - 'working_dir': 'WorkingDir', - 'user': 'User', - 'labels': 'Labels', -} - -IGNORED_KEYS = {'build'} - -SUPPORTED_KEYS = { - 'image', - 'ports', - 'expose', - 'networks', - 'command', - 'environment', - 'entrypoint', -} | set(SERVICE_KEYS) - -VERSION = '0.1' - - -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 MissingDigests(Exception): - def __init__(self, needs_push, needs_pull): - self.needs_push = needs_push - self.needs_pull = needs_pull - - -def serialize_bundle(config, image_digests): - return json.dumps(to_bundle(config, image_digests), indent=2, sort_keys=True) - - -def get_image_digests(project, allow_push=False): - digests = {} - needs_push = set() - needs_pull = set() - - for service in project.services: - try: - digests[service.name] = get_image_digest( - service, - allow_push=allow_push, - ) - 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, allow_push=False): - 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 for the bundle. 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'] - - try: - image = service.image() - except NoSuchImageError: - action = 'build' if 'build' in service.options else 'pull' - raise UserError( - "Image not found for service '{service}'. " - "You might need to run `docker-compose {action} {service}`." - .format(service=service.name, action=action)) - - if image['RepoDigests']: - # TODO: pick a digest based on the image tag if there are multiple - # digests - return image['RepoDigests'][0] - - if 'build' not in service.options: - raise NeedsPull(service.image_name, service.name) - - if not allow_push: - raise NeedsPush(service.image_name) - - return push_image(service) - - -def push_image(service): - try: - digest = service.push() - except: - log.error( - "Failed to push image for service '{s.name}'. Please use an " - "image tag that can be pushed to a Docker " - "registry.".format(s=service)) - raise - - if not digest: - raise ValueError("Failed to get digest for %s" % service.name) - - repo, _, _ = parse_repository_tag(service.options['image']) - identifier = '{repo}@{digest}'.format(repo=repo, digest=digest) - - # only do this if RepoDigests isn't already populated - image = service.image() - if not image['RepoDigests']: - # Pull by digest so that image['RepoDigests'] is populated for next time - # and we don't have to pull/push again - service.client.pull(identifier) - log.info("Stored digest for {}".format(service.image_name)) - - return identifier - - -def to_bundle(config, image_digests): - if config.networks: - log.warn("Unsupported top level key 'networks' - ignoring") - - if config.volumes: - log.warn("Unsupported top level key 'volumes' - ignoring") - - config = denormalize_config(config) - - return { - 'Version': VERSION, - 'Services': { - name: convert_service_to_bundle( - name, - service_dict, - image_digests[name], - ) - for name, service_dict in config['services'].items() - }, - } - - -def convert_service_to_bundle(name, service_dict, image_digest): - container_config = {'Image': image_digest} - - for key, value in service_dict.items(): - if key in IGNORED_KEYS: - continue - - if key not in SUPPORTED_KEYS: - log.warn("Unsupported key '{}' in services.{} - ignoring".format(key, name)) - continue - - if key == 'environment': - container_config['Env'] = format_environment({ - envkey: envvalue for envkey, envvalue in value.items() - if envvalue - }) - continue - - if key in SERVICE_KEYS: - container_config[SERVICE_KEYS[key]] = value - continue - - set_command_and_args( - container_config, - service_dict.get('entrypoint', []), - service_dict.get('command', [])) - container_config['Networks'] = make_service_networks(name, service_dict) - - ports = make_port_specs(service_dict) - if ports: - container_config['Ports'] = ports - - return container_config - - -# See https://github.com/docker/swarmkit/blob/agent/exec/container/container.go#L95 -def set_command_and_args(config, entrypoint, command): - if isinstance(entrypoint, six.string_types): - entrypoint = split_command(entrypoint) - if isinstance(command, six.string_types): - command = split_command(command) - - if entrypoint: - config['Command'] = entrypoint + command - return - - if command: - config['Args'] = command - - -def make_service_networks(name, service_dict): - networks = [] - - for network_name, network_def in get_network_defs_for_service(service_dict).items(): - for key in network_def.keys(): - log.warn( - "Unsupported key '{}' in services.{}.networks.{} - ignoring" - .format(key, name, network_name)) - - networks.append(network_name) - - return networks - - -def make_port_specs(service_dict): - ports = [] - - internal_ports = [ - internal_port - for port_def in service_dict.get('ports', []) - for internal_port in split_port(port_def)[0] - ] - - internal_ports += service_dict.get('expose', []) - - for internal_port in internal_ports: - spec = make_port_spec(internal_port) - if spec not in ports: - ports.append(spec) - - return ports - - -def make_port_spec(value): - components = six.text_type(value).partition('/') - return { - 'Protocol': components[2] or 'tcp', - 'Port': int(components[0]), - } diff --git a/compose/cli/__init__.py b/compose/cli/__init__.py index c5db4455..e69de29b 100644 --- a/compose/cli/__init__.py +++ b/compose/cli/__init__.py @@ -1,37 +0,0 @@ -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - -import subprocess -import sys - -# Attempt to detect https://github.com/docker/compose/issues/4344 -try: - # We don't try importing pip because it messes with package imports - # on some Linux distros (Ubuntu, Fedora) - # https://github.com/docker/compose/issues/4425 - # https://github.com/docker/compose/issues/4481 - # https://github.com/pypa/pip/blob/master/pip/_vendor/__init__.py - s_cmd = subprocess.Popen( - ['pip', 'freeze'], stderr=subprocess.PIPE, stdout=subprocess.PIPE - ) - packages = s_cmd.communicate()[0].splitlines() - dockerpy_installed = len( - list(filter(lambda p: p.startswith(b'docker-py=='), packages)) - ) > 0 - if dockerpy_installed: - from .colors import red - print( - red('ERROR:'), - "Dependency conflict: an older version of the 'docker-py' package " - "is polluting the namespace. " - "Run the following command to remedy the issue:\n" - "pip uninstall docker docker-py; pip install docker", - file=sys.stderr - ) - sys.exit(1) - -except OSError: - # pip command is not available, which indicates it's probably the binary - # distribution of Compose which is not affected - pass diff --git a/compose/cli/colors.py b/compose/cli/colors.py index f1251e43..af4a32ab 100644 --- a/compose/cli/colors.py +++ b/compose/cli/colors.py @@ -1,8 +1,4 @@ -from __future__ import absolute_import from __future__ import unicode_literals - -import colorama - NAMES = [ 'grey', 'red', @@ -33,7 +29,6 @@ def make_color_fn(code): return lambda s: ansi_color(code, s) -colorama.init(strip=False) for (name, code) in get_pairs(): globals()[name] = make_color_fn(code) diff --git a/compose/cli/command.py b/compose/cli/command.py index 02035428..bd6b2dc8 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -1,132 +1,130 @@ -from __future__ import absolute_import from __future__ import unicode_literals - +from __future__ import absolute_import +from requests.exceptions import ConnectionError, SSLError import logging import os import re -import ssl - import six -from . import errors -from . import verbose_proxy from .. import config -from ..config.environment import Environment -from ..const import API_VERSIONS from ..project import Project +from ..service import ConfigError +from .docopt_command import DocoptCommand +from .utils import call_silently, is_mac, is_ubuntu, find_candidates_in_parent_dirs from .docker_client import docker_client -from .docker_client import tls_config_from_options -from .utils import get_version_info +from . import verbose_proxy +from . import errors +from .. import __version__ log = logging.getLogger(__name__) - -def project_from_options(project_dir, options): - environment = Environment.from_env_file(project_dir) - host = options.get('--host') - if host is not None: - host = host.lstrip('=') - return get_project( - project_dir, - get_config_path_from_options(project_dir, options, environment), - project_name=options.get('--project-name'), - verbose=options.get('--verbose'), - host=host, - tls_config=tls_config_from_options(options), - environment=environment - ) +SUPPORTED_FILENAMES = [ + 'docker-compose.yml', + 'docker-compose.yaml', + 'fig.yml', + 'fig.yaml', +] -def get_config_from_options(base_dir, options): - environment = Environment.from_env_file(base_dir) - config_path = get_config_path_from_options( - base_dir, options, environment - ) - return config.load( - config.find(base_dir, config_path, environment) - ) +class Command(DocoptCommand): + base_dir = '.' + def dispatch(self, *args, **kwargs): + try: + super(Command, self).dispatch(*args, **kwargs) + except SSLError as e: + raise errors.UserError('SSL error: %s' % e) + except ConnectionError: + if call_silently(['which', 'docker']) != 0: + if is_mac(): + raise errors.DockerNotFoundMac() + elif is_ubuntu(): + raise errors.DockerNotFoundUbuntu() + else: + raise errors.DockerNotFoundGeneric() + elif call_silently(['which', 'boot2docker']) == 0: + raise errors.ConnectionErrorBoot2Docker() + else: + raise errors.ConnectionErrorGeneric(self.get_client().base_url) -def get_config_path_from_options(base_dir, options, environment): - file_option = options.get('--file') - if file_option: - return file_option + def perform_command(self, options, handler, command_options): + if options['COMMAND'] == 'help': + # Skip looking up the compose file. + handler(None, command_options) + return - config_files = environment.get('COMPOSE_FILE') - if config_files: - return config_files.split(os.pathsep) - return None + if 'FIG_FILE' in os.environ: + log.warn('The FIG_FILE environment variable is deprecated.') + log.warn('Please use COMPOSE_FILE instead.') + explicit_config_path = options.get('--file') or os.environ.get('COMPOSE_FILE') or os.environ.get('FIG_FILE') + project = self.get_project( + self.get_config_path(explicit_config_path), + project_name=options.get('--project-name'), + verbose=options.get('--verbose')) -def get_tls_version(environment): - compose_tls_version = environment.get('COMPOSE_TLS_VERSION', None) - if not compose_tls_version: - return None + handler(project, command_options) - tls_attr_name = "PROTOCOL_{}".format(compose_tls_version) - if not hasattr(ssl, tls_attr_name): - log.warn( - '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 + def get_client(self, verbose=False): + client = docker_client() + if verbose: + version_info = six.iteritems(client.version()) + log.info("Compose version %s", __version__) + 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 - return getattr(ssl, tls_attr_name) + def get_project(self, config_path, project_name=None, verbose=False): + try: + return Project.from_dicts( + self.get_project_name(config_path, project_name), + config.load(config_path), + self.get_client(verbose=verbose)) + except ConfigError as e: + raise errors.UserError(six.text_type(e)) + def get_project_name(self, config_path, project_name=None): + def normalize_name(name): + return re.sub(r'[^a-z0-9]', '', name.lower()) -def get_client(environment, verbose=False, version=None, tls_config=None, host=None, - tls_version=None): + if 'FIG_PROJECT_NAME' in os.environ: + log.warn('The FIG_PROJECT_NAME environment variable is deprecated.') + log.warn('Please use COMPOSE_PROJECT_NAME instead.') - client = docker_client( - version=version, tls_config=tls_config, host=host, - environment=environment, tls_version=get_tls_version(environment) - ) - if verbose: - version_info = six.iteritems(client.version()) - 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 + project_name = project_name or os.environ.get('COMPOSE_PROJECT_NAME') or os.environ.get('FIG_PROJECT_NAME') + if project_name is not None: + return normalize_name(project_name) + project = os.path.basename(os.path.dirname(os.path.abspath(config_path))) + if project: + return normalize_name(project) -def get_project(project_dir, config_path=None, project_name=None, verbose=False, - host=None, tls_config=None, environment=None): - if not environment: - environment = Environment.from_env_file(project_dir) - config_details = config.find(project_dir, config_path, environment) - project_name = get_project_name( - config_details.working_dir, project_name, environment - ) - config_data = config.load(config_details) + return 'default' - api_version = environment.get( - 'COMPOSE_API_VERSION', - API_VERSIONS[config_data.version]) + def get_config_path(self, file_path=None): + if file_path: + return os.path.join(self.base_dir, file_path) - client = get_client( - verbose=verbose, version=api_version, tls_config=tls_config, - host=host, environment=environment - ) + (candidates, path) = find_candidates_in_parent_dirs(SUPPORTED_FILENAMES, self.base_dir) - with errors.handle_connection_errors(client): - return Project.from_config(project_name, config_data, client) + if len(candidates) == 0: + raise errors.ComposeFileNotFound(SUPPORTED_FILENAMES) + winner = candidates[0] -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 len(candidates) > 1: + log.warning("Found multiple config files with supported names: %s", ", ".join(candidates)) + log.warning("Using %s\n", winner) - 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) + if winner == 'docker-compose.yaml': + log.warning("Please be aware that .yml is the expected extension " + "in most cases, and using .yaml can cause compatibility " + "issues in future.\n") - project = os.path.basename(os.path.abspath(working_dir)) - if project: - return normalize_name(project) + if winner.startswith("fig."): + log.warning("%s is deprecated and will not be supported in future. " + "Please rename your config file to docker-compose.yml\n" % winner) - return 'default' + return os.path.join(path, winner) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 018d2451..e513182f 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -1,74 +1,35 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import logging - -from docker import APIClient -from docker.errors import TLSParameterError -from docker.tls import TLSConfig -from docker.utils import kwargs_from_env - -from ..const import HTTP_TIMEOUT -from .errors import UserError -from .utils import generate_user_agent -from .utils import unquote_path - -log = logging.getLogger(__name__) +from docker import Client +from docker import tls +import ssl +import os -def tls_config_from_options(options): - 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 = options.get('--tlsverify') - skip_hostname_check = options.get('--skip-hostname-check', False) - - advanced_opts = any([ca_cert, cert, key, verify]) - - 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 - ) - - return None - - -def docker_client(environment, version=None, tls_config=None, host=None, - tls_version=None): +def docker_client(): """ 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)\"`") + cert_path = os.environ.get('DOCKER_CERT_PATH', '') + if cert_path == '': + cert_path = os.path.join(os.environ.get('HOME', ''), '.docker') - if host: - kwargs['base_url'] = host - if tls_config: - kwargs['tls'] = tls_config + base_url = os.environ.get('DOCKER_HOST') + tls_config = None - if version: - kwargs['version'] = version + if os.environ.get('DOCKER_TLS_VERIFY', '') != '': + parts = base_url.split('://', 1) + base_url = '%s://%s' % ('https', parts[1]) - timeout = environment.get('COMPOSE_HTTP_TIMEOUT') - if timeout: - kwargs['timeout'] = int(timeout) - else: - kwargs['timeout'] = HTTP_TIMEOUT + client_cert = (os.path.join(cert_path, 'cert.pem'), os.path.join(cert_path, 'key.pem')) + ca_cert = os.path.join(cert_path, 'ca.pem') - kwargs['user_agent'] = generate_user_agent() + tls_config = tls.TLSConfig( + ssl_version=ssl.PROTOCOL_TLSv1, + verify=True, + assert_hostname=False, + client_cert=client_cert, + ca_cert=ca_cert, + ) - return APIClient(**kwargs) + timeout = int(os.environ.get('DOCKER_CLIENT_TIMEOUT', 60)) + return Client(base_url=base_url, tls=tls_config, version='1.18', timeout=timeout) diff --git a/compose/cli/docopt_command.py b/compose/cli/docopt_command.py index 809a4b74..ee694701 100644 --- a/compose/cli/docopt_command.py +++ b/compose/cli/docopt_command.py @@ -1,10 +1,9 @@ -from __future__ import absolute_import from __future__ import unicode_literals +from __future__ import absolute_import +import sys from inspect import getdoc - -from docopt import docopt -from docopt import DocoptExit +from docopt import docopt, DocoptExit def docopt_full_help(docstring, *args, **kwargs): @@ -14,21 +13,32 @@ def docopt_full_help(docstring, *args, **kwargs): raise SystemExit(docstring) -class DocoptDispatcher(object): +class DocoptCommand(object): + def docopt_options(self): + return {'options_first': True} - def __init__(self, command_class, options): - self.command_class = command_class - self.options = options + def sys_dispatch(self): + self.dispatch(sys.argv[1:], None) - def parse(self, argv): - command_help = getdoc(self.command_class) - options = docopt_full_help(command_help, argv, **self.options) + def dispatch(self, argv, global_options): + self.perform_command(*self.parse(argv, global_options)) + + def perform_command(self, options, handler, command_options): + handler(command_options) + + def parse(self, argv, global_options): + options = docopt_full_help(getdoc(self), argv, **self.docopt_options()) command = options['COMMAND'] if command is None: - raise SystemExit(command_help) + raise SystemExit(getdoc(self)) - handler = get_handler(self.command_class, command) + command = command.replace('-', '_') + + if not hasattr(self, command): + raise NoSuchCommand(command, self) + + handler = getattr(self, command) docstring = getdoc(handler) if docstring is None: @@ -38,19 +48,6 @@ class DocoptDispatcher(object): 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(NoSuchCommand, self).__init__("No such command: %s" % command) diff --git a/compose/cli/errors.py b/compose/cli/errors.py index 5b977095..9a909e46 100644 --- a/compose/cli/errors.py +++ b/compose/cli/errors.py @@ -1,30 +1,8 @@ from __future__ import absolute_import -from __future__ import unicode_literals - -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 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() @@ -34,104 +12,53 @@ class UserError(Exception): __str__ = __unicode__ -class ConnectionError(Exception): - pass +class DockerNotFoundMac(UserError): + def __init__(self): + super(DockerNotFoundMac, self).__init__(""" + Couldn't connect to Docker daemon. You might need to install docker-osx: + + https://github.com/noplay/docker-osx + """) -@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) as e: - log_timeout_error(client.timeout) - raise ConnectionError() +class DockerNotFoundUbuntu(UserError): + def __init__(self): + super(DockerNotFoundUbuntu, self).__init__(""" + Couldn't connect to Docker daemon. You might need to install Docker: + + http://docs.docker.io/en/latest/installation/ubuntulinux/ + """) -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) +class DockerNotFoundGeneric(UserError): + def __init__(self): + super(DockerNotFoundGeneric, self).__init__(""" + Couldn't connect to Docker daemon. You might need to install Docker: + + http://docs.docker.io/en/latest/installation/ + """) -def log_api_error(e, client_version): - if b'client is newer than server' not in e.explanation: - log.error(e.explanation) - return - - version = API_VERSION_TO_ENGINE_VERSION.get(client_version) - if not version: - # They've set a custom API version - log.error(e.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)) +class ConnectionErrorBoot2Docker(UserError): + def __init__(self): + super(ConnectionErrorBoot2Docker, self).__init__(""" + Couldn't connect to Docker daemon - you might need to run `boot2docker up`. + """) -def exit_with_error(msg): - log.error(dedent(msg).strip()) - raise ConnectionError() +class ConnectionErrorGeneric(UserError): + def __init__(self, url): + super(ConnectionErrorGeneric, self).__init__(""" + Couldn't connect to Docker daemon at %s - is it running? + + If it's at a non-standard location, specify the URL with the DOCKER_HOST environment variable. + """ % url) -def get_conn_error_message(url): - 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 - return conn_error_generic.format(url=url) +class ComposeFileNotFound(UserError): + def __init__(self, supported_filenames): + super(ComposeFileNotFound, self).__init__(""" + Can't find a suitable configuration file in this directory or any parent. Are you in the right directory? - -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. -""" + Supported filenames: %s + """ % ", ".join(supported_filenames)) diff --git a/compose/cli/formatter.py b/compose/cli/formatter.py index 5f580645..df777d50 100644 --- a/compose/cli/formatter.py +++ b/compose/cli/formatter.py @@ -1,25 +1,18 @@ -from __future__ import absolute_import from __future__ import unicode_literals - -import logging +from __future__ import absolute_import import os - -import six import texttable -from compose.cli import colors - def get_tty_width(): tty_size = os.popen('stty size', 'r').read().split() if len(tty_size) != 2: - return 0 + return 80 _, width = tty_size return int(width) class Formatter(object): - """Format tabular data for printing.""" def table(self, headers, rows): table = texttable.Texttable(max_width=get_tty_width()) table.set_cols_dtype(['t' for h in headers]) @@ -28,24 +21,3 @@ class Formatter(object): 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.WARNING: - return colors.yellow(record.levelname) + separator - if record.levelno == logging.ERROR: - return colors.red(record.levelname) + separator - - return '' - - def format(self, record): - if isinstance(record.msg, six.binary_type): - record.msg = record.msg.decode('utf-8') - message = super(ConsoleWarningFormatter, self).format(record) - return '{0}{1}'.format(self.get_level_message(record), message) diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index 299ddea4..ce7e1065 100644 --- a/compose/cli/log_printer.py +++ b/compose/cli/log_printer.py @@ -1,237 +1,82 @@ -from __future__ import absolute_import from __future__ import unicode_literals - +from __future__ import absolute_import import sys -from collections import namedtuple + from itertools import cycle -from threading import Thread - -from docker.errors import APIError -from six.moves import _thread as thread -from six.moves.queue import Empty -from six.moves.queue import Queue +from .multiplexer import Multiplexer, STOP from . import colors -from compose import utils -from compose.cli.signals import ShutdownException -from compose.utils import split_buffer - - -class LogPresenter(object): - - def __init__(self, prefix_width, color_func): - self.prefix_width = prefix_width - self.color_func = color_func - - def present(self, container, line): - prefix = container.name_without_project.ljust(self.prefix_width) - return '{prefix} {line}'.format( - prefix=self.color_func(prefix + ' |'), - line=line) - - -def build_log_presenters(service_names, monochrome): - """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) - - -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 +from .utils import split_buffer class LogPrinter(object): - """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): + def __init__(self, containers, attach_params=None, output=sys.stdout, monochrome=False): self.containers = containers - self.presenters = presenters - self.event_stream = event_stream - self.output = utils.get_output_stream(output) - self.cascade_stop = cascade_stop - self.log_args = log_args or {} + self.attach_params = attach_params or {} + self.prefix_width = self._calculate_prefix_width(containers) + self.generators = self._make_log_generators(monochrome) + self.output = output 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 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 - + mux = Multiplexer(self.generators) + for line in mux.loop(): self.output.write(line) - self.output.flush() + def _calculate_prefix_width(self, containers): + """ + Calculate the maximum width of container names so we can make the log + prefixes line up like so: -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) + db_1 | Listening + web_1 | Listening + """ + prefix_width = 0 + for container in containers: + prefix_width = max(prefix_width, len(container.name_without_project)) + return prefix_width + def _make_log_generators(self, monochrome): + color_fns = cycle(colors.rainbow()) + generators = [] -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 no_color(text): + return text - -def build_thread_map(initial_containers, presenters, thread_args): - return { - container.id: build_thread(container, next(presenters), *thread_args) - for container in initial_containers - } - - -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): - return cls(None, True, None) - - -def tail_container_logs(container, presenter, queue, log_args): - generator = get_log_generator(container) - - try: - for item in 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()) - - -def get_log_generator(container): - if container.has_api_logs: - return build_log_generator - return build_no_log_generator - - -def build_no_log_generator(container, log_args): - """Return a generator that prints a warning about logs and waits for - container to exit. - """ - yield "WARNING: no logs are available with the '{}' log driver\n".format( - container.log_driver) - - -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 "%s exited with code %s\n" % (container.name, exit_code) - except APIError as e: - return "Unexpected API error for %s (HTTP code %s)\nResponse body:\n%s\n" % ( - 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): - for event in event_stream: - if event['action'] == 'stop': - thread_map.pop(event['id'], None) - - 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) - - 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: - if cascade_stop: - raise StopIteration + for container in self.containers: + if monochrome: + color_fn = no_color else: - continue + color_fn = next(color_fns) + generators.append(self._make_log_generator(container, color_fn)) - yield item.item + return generators + + def _make_log_generator(self, container, color_fn): + prefix = color_fn(self._generate_prefix(container)).encode('utf-8') + # Attach to container before log printer starts running + line_generator = split_buffer(self._attach(container), '\n') + + for line in line_generator: + yield prefix + line + + exit_code = container.wait() + yield color_fn("%s exited with code %s\n" % (container.name, exit_code)) + yield STOP + + def _generate_prefix(self, container): + """ + Generate the prefix for a log line without colour + """ + name = container.name_without_project + padding = ' ' * (self.prefix_width - len(name)) + return ''.join([name, padding, ' | ']) + + def _attach(self, container): + params = { + 'stdout': True, + 'stderr': True, + 'stream': True, + } + params.update(self.attach_params) + params = dict((name, 1 if value else 0) for (name, value) in list(params.items())) + return container.attach(**params) diff --git a/compose/cli/main.py b/compose/cli/main.py index 51ba36a0..4f3f11e4 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1,122 +1,60 @@ -from __future__ import absolute_import from __future__ import print_function from __future__ import unicode_literals - -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 logging +import re +import signal +import sys -from . import errors -from . import signals -from .. import __version__ -from ..bundle import get_image_digests -from ..bundle import MissingDigests -from ..bundle import serialize_bundle -from ..config import ConfigurationError +from docker.errors import APIError +import dockerpty + +from .. import legacy +from ..project import NoSuchService, ConfigurationError +from ..service import BuildError, NeedsBuildError from ..config import parse_environment -from ..config.environment import Environment -from ..config.serialize import serialize_config -from ..const import IS_WINDOWS_PLATFORM -from ..errors import StreamParseError -from ..progress_stream import StreamOutputError -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 .command import get_config_from_options -from .command import project_from_options -from .docopt_command import DocoptDispatcher -from .docopt_command import get_handler +from .command import Command 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 yesno - - -if not IS_WINDOWS_PLATFORM: - from dockerpty.pty import PseudoTerminal, RunOperation, ExecOperation +from .utils import get_version_info, yesno log = logging.getLogger(__name__) -console_handler = logging.StreamHandler(sys.stderr) def main(): - command = dispatch() - + setup_logging() try: - command() - except (KeyboardInterrupt, signals.ShutdownException): - log.error("Aborting.") + command = TopLevelCommand() + command.sys_dispatch() + except KeyboardInterrupt: + log.error("\nAborting.") sys.exit(1) - except (UserError, NoSuchService, ConfigurationError, - ProjectError, OperationFailedError) as e: + except (UserError, NoSuchService, ConfigurationError, legacy.LegacyContainersError) as e: log.error(e.msg) sys.exit(1) + except NoSuchCommand as e: + log.error("No such command: %s", e.command) + log.error("") + log.error("\n".join(parse_doc_section("commands:", getdoc(e.supercommand)))) + sys.exit(1) + except APIError as e: + log.error(e.explanation) + sys.exit(1) except BuildError as e: log.error("Service '%s' failed to build: %s" % (e.service.name, e.reason)) sys.exit(1) - except StreamOutputError as e: - log.error(e) - sys.exit(1) except NeedsBuildError as e: log.error("Service '%s' needs to be built, but --no-build was passed." % e.service.name) sys.exit(1) - except (errors.ConnectionError, StreamParseError): - sys.exit(1) - - -def dispatch(): - setup_logging() - dispatcher = DocoptDispatcher( - TopLevelCommand, - {'options_first': True, 'version': get_version_info('compose')}) - - try: - options, handler, command_options = dispatcher.parse(sys.argv[1:]) - except NoSuchCommand as e: - commands = "\n".join(parse_doc_section("commands:", getdoc(e.supercommand))) - log.error("No such command: %s\n\n%s", e.command, commands) - sys.exit(1) - - setup_console_handler(console_handler, options.get('--verbose')) - 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'] in ('config', 'bundle'): - command = TopLevelCommand(None) - handler(command, options, command_options) - return - - project = project_from_options('.', options) - command = TopLevelCommand(project) - with errors.handle_connection_errors(project.client): - handler(command, command_options) def setup_logging(): + console_handler = logging.StreamHandler(sys.stderr) + console_handler.setFormatter(logging.Formatter()) + console_handler.setLevel(logging.INFO) root_logger = logging.getLogger() root_logger.addHandler(console_handler) root_logger.setLevel(logging.DEBUG) @@ -125,20 +63,6 @@ def setup_logging(): logging.getLogger("requests").propagate = False -def setup_console_handler(handler, verbose): - if handler.stream.isatty(): - format_class = ConsoleWarningFormatter - else: - format_class = logging.Formatter - - if verbose: - handler.setFormatter(format_class('%(name)s.%(funcName)s: %(message)s')) - handler.setLevel(logging.DEBUG) - else: - handler.setFormatter(format_class()) - handler.setLevel(logging.INFO) - - # stolen from docopt master def parse_doc_section(name, source): pattern = re.compile('^([^\n]*' + name + '[^\n]*\n?(?:[ \t].*?(?:\n|$))*)', @@ -146,62 +70,43 @@ def parse_doc_section(name, source): return [s.strip() for s in pattern.findall(source)] -class TopLevelCommand(object): +class TopLevelCommand(Command): """Define and run multi-container applications with Docker. Usage: - docker-compose [-f ...] [options] [COMMAND] [ARGS...] + docker-compose [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) - --verbose Show more output - -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 (for example if your docker host - is an IP address) + -f, --file FILE Specify an alternate compose file (default: docker-compose.yml) + -p, --project-name NAME Specify an alternate project name (default: directory name) + --verbose Show more output + -v, --version Print version and exit Commands: build Build or rebuild services - bundle Generate a Docker bundle from the Compose file - config Validate and view the compose file - create Create services - down Stop and remove containers, networks, images, and volumes - events Receive real time events from containers - exec Execute a command in a running container help Get help on a command 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 + pull Pulls 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 the Docker-Compose version information + migrate-to-labels Recreate containers to add labels + """ + def docopt_options(self): + options = super(TopLevelCommand, self).docopt_options() + options['version'] = get_version_info() + return options - def __init__(self, project, project_dir='.'): - self.project = project - self.project_dir = '.' - - def build(self, options): + def build(self, project, options): """ Build or rebuild services. @@ -212,274 +117,23 @@ class TopLevelCommand(object): Usage: build [options] [SERVICE...] Options: - --force-rm Always remove intermediate containers. --no-cache Do not use cache when building the image. - --pull Always attempt to pull a newer version of the image. """ - 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))) + no_cache = bool(options.get('--no-cache', False)) + project.build(service_names=options['SERVICE'], no_cache=no_cache) - def bundle(self, config_options, options): - """ - Generate a Distributed Application Bundle (DAB) from the Compose file. - - Images must have digests stored, which requires interaction with a - Docker registry. If digests aren't stored for all images, you can fetch - them with `docker-compose pull` or `docker-compose push`. To push images - automatically when bundling, pass `--push-images`. Only services with - a `build` option specified will have their images pushed. - - Usage: bundle [options] - - Options: - --push-images Automatically push images for any services - which have a `build` option specified. - - -o, --output PATH Path to write the bundle file to. - Defaults to ".dab". - """ - self.project = project_from_options('.', config_options) - compose_config = get_config_from_options(self.project_dir, config_options) - - output = options["--output"] - if not output: - output = "{}.dab".format(self.project.name) - - with errors.handle_connection_errors(self.project.client): - try: - image_digests = get_image_digests( - self.project, - allow_push=options['--push-images'], - ) - 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-compose push {}` to push them. " - "You can do this automatically with `docker-compose bundle --push-images`." - .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-compose 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)) - - with open(output, 'w') as f: - f.write(serialize_bundle(compose_config, image_digests)) - - log.info("Wrote bundle to {}".format(output)) - - def config(self, config_options, options): - """ - Validate and view the compose file. - - Usage: config [options] - - Options: - -q, --quiet Only validate the configuration, don't print - anything. - --services Print the service names, one per line. - - """ - compose_config = get_config_from_options(self.project_dir, config_options) - - if options['--quiet']: - return - - if options['--services']: - print('\n'.join(service['name'] for service in compose_config.services)) - return - - print(serialize_config(compose_config)) - - def create(self, options): - """ - Creates containers for a service. - - 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'] - - self.project.create( - service_names=service_names, - strategy=convergence_strategy_from_opts(options), - do_build=build_action_from_opts(options), - ) - - 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 - """ - image_type = image_type_from_opt('--rmi', options['--rmi']) - self.project.down(image_type, options['--volumes'], options['--remove-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() - - def exec_command(self, options): - """ - Execute a command in a running container - - Usage: exec [options] SERVICE COMMAND [ARGS...] - - Options: - -d Detached mode: Run command in the background. - --privileged Give extended privileges to the process. - --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] - """ - index = int(options.get('--index')) - service = self.project.get_service(options['SERVICE']) - detach = options['-d'] - - 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 and not detach: - args = ["exec"] - - if options["-d"]: - args += ["--detach"] - else: - args += ["--interactive"] - - if not options["-T"]: - args += ["--tty"] - - if options["--privileged"]: - args += ["--privileged"] - - if options["--user"]: - args += ["--user", options["--user"]] - - args += [container.id] - args += command - - sys.exit(call_docker(args)) - - create_exec_options = { - "privileged": options["--privileged"], - "user": options["--user"], - "tty": tty, - "stdin": tty, - } - - exec_id = container.create_exec(command, **create_exec_options) - - if detach: - container.start_exec(exec_id, tty=tty) - 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 - def help(cls, options): + def help(self, project, options): """ Get help on a command. - Usage: help [COMMAND] + Usage: help COMMAND """ - if options['COMMAND']: - subject = get_handler(cls, options['COMMAND']) - else: - subject = cls + command = options['COMMAND'] + if not hasattr(self, command): + raise NoSuchCommand(command, self) + raise SystemExit(getdoc(getattr(self, command))) - print(getdoc(subject)) - - def kill(self, options): + def kill(self, project, options): """ Force stop service containers. @@ -491,52 +145,24 @@ class TopLevelCommand(object): """ signal = options.get('-s', 'SIGKILL') - self.project.kill(service_names=options['SERVICE'], signal=signal) + project.kill(service_names=options['SERVICE'], signal=signal) - def logs(self, options): + def logs(self, project, 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-color Produce monochrome output. """ - containers = self.project.containers(service_names=options['SERVICE'], stopped=True) + containers = 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'] - } + monochrome = options['--no-color'] 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'])).run() + LogPrinter(containers, attach_params={'logs': True}, monochrome=monochrome).run() - 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) - - def port(self, options): + def port(self, project, options): """ Print the public port for a port binding. @@ -548,7 +174,7 @@ class TopLevelCommand(object): instances of a service [default: 1] """ index = int(options.get('--index')) - service = self.project.get_service(options['SERVICE']) + service = project.get_service(options['SERVICE']) try: container = service.get_container(number=index) except ValueError as e: @@ -557,7 +183,7 @@ class TopLevelCommand(object): options['PRIVATE_PORT'], protocol=options.get('--protocol') or 'tcp') or '') - def ps(self, options): + def ps(self, project, options): """ List containers. @@ -567,8 +193,8 @@ class TopLevelCommand(object): -q 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), + project.containers(service_names=options['SERVICE'], stopped=True) + + project.containers(service_names=options['SERVICE'], one_off=True), key=attrgetter('name')) if options['-q']: @@ -594,75 +220,47 @@ class TopLevelCommand(object): ]) print(Formatter().table(headers, rows)) - def pull(self, options): + def pull(self, project, options): """ Pulls images for services. Usage: pull [options] [SERVICE...] Options: - --ignore-pull-failures Pull what it can and ignores images with pull failures. + --allow-insecure-ssl Allow insecure connections to the docker + registry """ - self.project.pull( + insecure_registry = options['--allow-insecure-ssl'] + project.pull( service_names=options['SERVICE'], - ignore_pull_failures=options.get('--ignore-pull-failures') + insecure_registry=insecure_registry ) - def push(self, options): + def rm(self, project, 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') - ) - - 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. + Remove stopped service containers. Usage: rm [options] [SERVICE...] Options: -f, --force Don't ask to confirm removal - -v Remove any anonymous volumes attached to containers - -a, --all Deprecated - no effect. + -v Remove volumes associated with containers """ - if options.get('--all'): - log.warn( - '--all flag is obsolete. This is now the default behavior ' - 'of `docker-compose rm`' - ) - one_off = OneOffFilter.include - - all_containers = self.project.containers( - service_names=options['SERVICE'], stopped=True, one_off=one_off - ) + all_containers = project.containers(service_names=options['SERVICE'], stopped=True) 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( + project.remove_stopped( service_names=options['SERVICE'], - v=options.get('-v', False), - one_off=one_off + v=options.get('-v', False) ) else: print("No stopped containers") - def run(self, options): + def run(self, project, options): """ Run a one-off command on a service. @@ -674,44 +272,87 @@ class TopLevelCommand(object): running. If you do not want to start linked services, use `docker-compose run --no-deps SERVICE COMMAND [ARGS...]`. - Usage: run [options] [-p PORT...] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] + Usage: run [options] [-e KEY=VAL...] SERVICE [COMMAND] [ARGS...] Options: + --allow-insecure-ssl Allow insecure connections to the docker + registry -d 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) -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. -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['-d'] + service = project.get_service(options['SERVICE']) - if options['--publish'] and options['--service-ports']: - raise UserError( - 'Service port mapping and manual port mapping ' - 'can not be used together' - ) + insecure_registry = options['--allow-insecure-ssl'] - if options['COMMAND'] is not None: + if not options['--no-deps']: + deps = service.get_linked_names() + + if len(deps) > 0: + project.up( + service_names=deps, + start_deps=True, + allow_recreate=False, + insecure_registry=insecure_registry, + ) + + tty = True + if options['-d'] or options['-T'] or not sys.stdin.isatty(): + tty = False + + if options['COMMAND']: command = [options['COMMAND']] + options['ARGS'] - elif options['--entrypoint'] is not None: - command = [] else: command = service.options.get('command') - container_options = build_container_options(options, detach, command) - run_one_off_container(container_options, self.project, service, options) + container_options = { + 'command': command, + 'tty': tty, + 'stdin_open': not options['-d'], + 'detach': options['-d'], + } - def scale(self, options): + if options['-e']: + container_options['environment'] = parse_environment(options['-e']) + + if options['--entrypoint']: + container_options['entrypoint'] = options.get('--entrypoint') + + if options['--rm']: + container_options['restart'] = None + + if options['--user']: + container_options['user'] = options.get('--user') + + if not options['--service-ports']: + container_options['ports'] = [] + + container = service.create_container( + quiet=True, + one_off=True, + insecure_registry=insecure_registry, + **container_options + ) + + if options['-d']: + service.start_container(container) + print(container.name) + else: + dockerpty.start(project.client, container.id, interactive=not options['-T']) + exit_code = container.wait() + if options['--rm']: + project.client.remove_container(container.id) + sys.exit(exit_code) + + def scale(self, project, options): """ Set number of containers to run for a service. @@ -720,14 +361,8 @@ class TopLevelCommand(object): $ docker-compose scale web=2 worker=3 - Usage: scale [options] [SERVICE=NUM...] - - Options: - -t, --timeout TIMEOUT Specify a shutdown timeout in seconds. - (default: 10) + Usage: scale [SERVICE=NUM...] """ - timeout = timeout_from_opts(options) - for s in options['SERVICE=NUM']: if '=' not in s: raise UserError('Arguments to scale should be in the form service=num') @@ -737,18 +372,17 @@ class TopLevelCommand(object): except ValueError: raise UserError('Number of containers for service "%s" is not a ' 'number' % service_name) - self.project.get_service(service_name).scale(num, timeout=timeout) + project.get_service(service_name).scale(num) - def start(self, options): + def start(self, project, options): """ Start existing containers. Usage: start [SERVICE...] """ - containers = self.project.start(service_names=options['SERVICE']) - exit_if(not containers, 'No containers to start', 1) + project.start(service_names=options['SERVICE']) - def stop(self, options): + def stop(self, project, options): """ Stop running containers without removing them. @@ -760,10 +394,11 @@ class TopLevelCommand(object): -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) + timeout = options.get('--timeout') + params = {} if timeout is None else {'timeout': int(timeout)} + project.stop(service_names=options['SERVICE'], **params) - def restart(self, options): + def restart(self, project, options): """ Restart running containers. @@ -773,335 +408,87 @@ class TopLevelCommand(object): -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) + timeout = options.get('--timeout') + params = {} if timeout is None else {'timeout': int(timeout)} + project.restart(service_names=options['SERVICE'], **params) - def top(self, options): + def up(self, project, options): """ - Display the running processes + Build, (re)create, start and attach to containers for a service. - Usage: top [SERVICE...] + By default, `docker-compose up` will aggregate the output of each container, and + when it exits, all containers will be stopped. If you run `docker-compose up -d`, + it'll start the containers in the background and leave them running. - """ - 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)) - - 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) - - 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. + If there are existing containers for a service, `docker-compose up` will stop + and recreate them (preserving mounted volumes with volumes-from), + so that changes in `docker-compose.yml` are picked up. If you do not want existing + containers to be recreated, `docker-compose up --no-recreate` will re-use existing + containers. Usage: up [options] [SERVICE...] Options: - -d Detached mode: Run containers in the background, - print new container names. - Incompatible with --abort-on-container-exit. - --no-color Produce monochrome output. - --no-deps Don't start linked services. - --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 starting containers. - --abort-on-container-exit Stops all containers if any container was stopped. - Incompatible with -d. - -t, --timeout TIMEOUT Use this timeout in seconds for container shutdown - when attached or when containers are already - running. (default: 10) - --remove-orphans Remove containers for services not - defined in the Compose file + --allow-insecure-ssl Allow insecure connections to the docker + registry + -d Detached mode: Run containers in the background, + print new container names. + --no-color Produce monochrome output. + --no-deps Don't start linked services. + --x-smart-recreate Only recreate containers whose configuration or + image needs to be updated. (EXPERIMENTAL) + --no-recreate If containers already exist, don't recreate them. + --no-build Don't build an image, even if it's missing + -t, --timeout TIMEOUT When attached, use this timeout in seconds + for the shutdown. (default: 10) + """ + insecure_registry = options['--allow-insecure-ssl'] + detached = options['-d'] + + monochrome = options['--no-color'] + start_deps = not options['--no-deps'] - cascade_stop = options['--abort-on-container-exit'] + allow_recreate = not options['--no-recreate'] + smart_recreate = options['--x-smart-recreate'] service_names = options['SERVICE'] - timeout = timeout_from_opts(options) - remove_orphans = options['--remove-orphans'] - detached = options.get('-d') - if detached and cascade_stop: - raise UserError("--abort-on-container-exit and -d cannot be combined.") - - with up_shutdown_context(self.project, service_names, timeout, detached): - to_attach = 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) - - if detached: - return - - log_printer = log_printer_from_project( - self.project, - filter_containers_to_service_names(to_attach, service_names), - options['--no-color'], - {'follow': True}, - cascade_stop, - event_stream=self.project.events(service_names=service_names)) - print("Attaching to", list_containers(log_printer.containers)) - log_printer.run() - - if cascade_stop: - print("Aborting on container exit...") - self.project.stop(service_names=service_names, timeout=timeout) - - @classmethod - def version(cls, options): - """ - Show version informations - - Usage: version [--short] - - Options: - --short Shows only Compose's version number. - """ - if options['--short']: - print(__version__) - else: - print(get_version_info('full')) - - -def convergence_strategy_from_opts(options): - no_recreate = options['--no-recreate'] - force_recreate = options['--force-recreate'] - if force_recreate and no_recreate: - raise UserError("--force-recreate and --no-recreate cannot be combined.") - - if force_recreate: - 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_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_container_options(options, detach, command): - container_options = { - 'command': command, - 'tty': not (detach or options['-T'] or not sys.stdin.isatty()), - 'stdin_open': not detach, - 'detach': detach, - } - - if options['-e']: - container_options['environment'] = Environment.from_command_line( - parse_environment(options['-e']) + project.up( + service_names=service_names, + start_deps=start_deps, + allow_recreate=allow_recreate, + smart_recreate=smart_recreate, + insecure_registry=insecure_registry, + do_build=not options['--no-build'], ) - if options['--entrypoint']: - container_options['entrypoint'] = options.get('--entrypoint') + to_attach = [c for s in project.get_services(service_names) for c in s.containers()] - if options['--rm']: - container_options['restart'] = None + if not detached: + print("Attaching to", list_containers(to_attach)) + log_printer = LogPrinter(to_attach, attach_params={"logs": True}, monochrome=monochrome) - if options['--user']: - container_options['user'] = options.get('--user') + try: + log_printer.run() + finally: + def handler(signal, frame): + project.kill(service_names=service_names) + sys.exit(0) + signal.signal(signal.SIGINT, handler) - if not options['--service-ports']: - container_options['ports'] = [] + print("Gracefully stopping... (press Ctrl+C again to force)") + timeout = options.get('--timeout') + params = {} if timeout is None else {'timeout': int(timeout)} + project.stop(service_names=service_names, **params) - if options['--publish']: - container_options['ports'] = options.get('--publish') + def migrate_to_labels(self, project, _options): + """ + Recreate containers to add labels - if options['--name']: - container_options['name'] = options['--name'] - - if options['--workdir']: - container_options['working_dir'] = options['--workdir'] - - return container_options - - -def run_one_off_container(container_options, project, service, options): - if not options['--no-deps']: - deps = service.get_dependency_names() - if deps: - project.up( - service_names=deps, - start_deps=True, - strategy=ConvergenceStrategy.never) - - project.initialize() - - container = service.create_container( - quiet=True, - one_off=True, - **container_options) - - if options['-d']: - service.start_container(container) - print(container.name) - return - - def remove_container(force=False): - if options['--rm']: - project.client.remove_container(container.id, force=True) - - signals.set_signal_handler_to_shutdown() - try: - try: - if IS_WINDOWS_PLATFORM: - service.connect_container_to_networks(container) - exit_code = call_docker(["start", "--attach", "--interactive", container.id]) - 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) - pty.start(sockets) - exit_code = container.wait() - except signals.ShutdownException: - project.client.stop(container.id) - exit_code = 1 - except signals.ShutdownException: - project.client.kill(container.id) - remove_container(force=True) - sys.exit(2) - - remove_container() - sys.exit(exit_code) - - -def log_printer_from_project( - project, - containers, - monochrome, - log_args, - cascade_stop=False, - event_stream=None, -): - return LogPrinter( - containers, - build_log_presenters(project.service_names, monochrome), - event_stream or project.events(), - cascade_stop=cascade_stop, - log_args=log_args) - - -def filter_containers_to_service_names(containers, service_names): - if not service_names: - return containers - - return [ - container - for container in containers if container.service in service_names - ] - - -@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) + Usage: migrate-to-labels + """ + legacy.migrate_project_to_labels(project) 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): - executable_path = find_executable('docker') - if not executable_path: - raise UserError(errors.docker_not_found_msg("Couldn't find `docker` binary.")) - - args = [executable_path] + args - log.debug(" ".join(map(pipes.quote, args))) - - return subprocess.call(args) diff --git a/compose/cli/multiplexer.py b/compose/cli/multiplexer.py new file mode 100644 index 00000000..849dbd66 --- /dev/null +++ b/compose/cli/multiplexer.py @@ -0,0 +1,42 @@ +from __future__ import absolute_import +from threading import Thread + +try: + from Queue import Queue, Empty +except ImportError: + from queue import Queue, Empty # Python 3.x + + +# Yield STOP from an input generator to stop the +# top-level loop without processing any more input. +STOP = object() + + +class Multiplexer(object): + def __init__(self, generators): + self.generators = generators + self.queue = Queue() + + def loop(self): + self._init_readers() + + while True: + try: + item = self.queue.get(timeout=0.1) + if item is STOP: + break + else: + yield item + except Empty: + pass + + def _init_readers(self): + for generator in self.generators: + t = Thread(target=_enqueue_output, args=(generator, self.queue)) + t.daemon = True + t.start() + + +def _enqueue_output(generator, queue): + for item in generator: + queue.put(item) diff --git a/compose/cli/signals.py b/compose/cli/signals.py deleted file mode 100644 index 68a0598e..00000000 --- a/compose/cli/signals.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import signal - - -class ShutdownException(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) diff --git a/compose/cli/utils.py b/compose/cli/utils.py index 580bd1b0..93b99103 100644 --- a/compose/cli/utils.py +++ b/compose/cli/utils.py @@ -1,24 +1,13 @@ +from __future__ import unicode_literals from __future__ import absolute_import from __future__ import division -from __future__ import unicode_literals - +import datetime import os +import subprocess import platform import ssl -import subprocess -import sys -import docker - -import compose -from ..const import IS_WINDOWS_PLATFORM - -# WindowsError is not defined on non-win32 platforms. Avoid runtime errors by -# defining it as OSError (its parent class) if missing. -try: - WindowsError -except NameError: - WindowsError = OSError +from .. import __version__ def yesno(prompt, default=None): @@ -31,7 +20,7 @@ def yesno(prompt, default=None): Unrecognised input (anything other than "y", "n", "yes", "no" or "") will return None. """ - answer = input(prompt).strip().lower() + answer = raw_input(prompt).strip().lower() if answer == "y" or answer == "yes": return True @@ -43,14 +32,81 @@ def yesno(prompt, default=None): return None -def input(prompt): +# http://stackoverflow.com/a/5164027 +def prettydate(d): + diff = datetime.datetime.utcnow() - d + s = diff.seconds + if diff.days > 7 or diff.days < 0: + return d.strftime('%d %b %y') + elif diff.days == 1: + return '1 day ago' + elif diff.days > 1: + return '{0} days ago'.format(diff.days) + elif s <= 1: + return 'just now' + elif s < 60: + return '{0} seconds ago'.format(s) + elif s < 120: + return '1 minute ago' + elif s < 3600: + return '{0} minutes ago'.format(s / 60) + elif s < 7200: + return '1 hour ago' + else: + return '{0} hours ago'.format(s / 3600) + + +def mkdir(path, permissions=0o700): + if not os.path.exists(path): + os.mkdir(path) + + os.chmod(path, permissions) + + return path + + +def find_candidates_in_parent_dirs(filenames, path): """ - 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 + Given a directory path to start, looks for filenames in the + directory, and then each parent directory successively, + until found. + + Returns tuple (candidates, path). """ - sys.stdout.write(prompt) - sys.stdout.flush() - return sys.stdin.readline().rstrip('\n') + candidates = [filename for filename in filenames + if os.path.exists(os.path.join(path, filename))] + + if len(candidates) == 0: + 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 split_buffer(reader, separator): + """ + Given a generator which yields strings and a separator string, + 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. + """ + buffered = str('') + separator = str(separator) + + for data in reader: + buffered += data + while True: + index = buffered.find(separator) + if index == -1: + break + yield buffered[:index + 1] + buffered = buffered[index + 1:] + + if len(buffered) > 0: + yield buffered def call_silently(*args, **kwargs): @@ -58,12 +114,7 @@ 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 WindowsError: - # On Windows, subprocess.call() can still raise exceptions. Normalize - # to POSIXy behaviour by returning a nonzero exit code. - return 1 + return subprocess.call(*args, stdout=shutup, stderr=shutup, **kwargs) def is_mac(): @@ -74,64 +125,9 @@ def is_ubuntu(): return platform.system() == 'Linux' and platform.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 IOError: - pass - else: - parts.append("{}/{}".format(p_system, p_release)) - return " ".join(parts) - - -def unquote_path(s): - if not s: - return s - if s[0] == '"' and s[-1] == '"': - return s[1:-1] - return s +def get_version_info(): + return '\n'.join([ + 'docker-compose version: %s' % __version__, + "%s version: %s" % (platform.python_implementation(), platform.python_version()), + "OpenSSL version: %s" % ssl.OPENSSL_VERSION, + ]) diff --git a/compose/cli/verbose_proxy.py b/compose/cli/verbose_proxy.py index b1592eab..a548983e 100644 --- a/compose/cli/verbose_proxy.py +++ b/compose/cli/verbose_proxy.py @@ -1,10 +1,8 @@ -from __future__ import absolute_import -from __future__ import unicode_literals import functools +from itertools import chain import logging import pprint -from itertools import chain import six diff --git a/compose/config.py b/compose/config.py new file mode 100644 index 00000000..efc50075 --- /dev/null +++ b/compose/config.py @@ -0,0 +1,489 @@ +import os +import yaml +import six + + +DOCKER_CONFIG_KEYS = [ + 'cap_add', + 'cap_drop', + 'cpu_shares', + 'cpuset', + 'command', + 'detach', + 'devices', + 'dns', + 'dns_search', + 'domainname', + 'entrypoint', + 'env_file', + 'environment', + 'extra_hosts', + 'read_only', + 'hostname', + 'image', + 'labels', + 'links', + 'mem_limit', + 'net', + 'log_driver', + 'pid', + 'ports', + 'privileged', + 'restart', + 'security_opt', + 'stdin_open', + 'tty', + 'user', + 'volumes', + 'volumes_from', + 'working_dir', +] + +ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ + 'build', + 'dockerfile', + 'expose', + 'external_links', + 'name', +] + +DOCKER_CONFIG_HINTS = { + 'cpu_share': 'cpu_shares', + 'add_host': 'extra_hosts', + 'hosts': 'extra_hosts', + 'extra_host': 'extra_hosts', + 'device': 'devices', + 'link': 'links', + 'port': 'ports', + 'privilege': 'privileged', + 'priviliged': 'privileged', + 'privilige': 'privileged', + 'volume': 'volumes', + 'workdir': 'working_dir', +} + + +def load(filename): + working_dir = os.path.dirname(filename) + return from_dictionary(load_yaml(filename), working_dir=working_dir, filename=filename) + + +def from_dictionary(dictionary, working_dir=None, filename=None): + service_dicts = [] + + for service_name, service_dict in list(dictionary.items()): + if not isinstance(service_dict, dict): + raise ConfigurationError('Service "%s" doesn\'t have any configuration options. All top level keys in your docker-compose.yml must map to a dictionary of configuration options.' % service_name) + loader = ServiceLoader(working_dir=working_dir, filename=filename) + service_dict = loader.make_service_dict(service_name, service_dict) + validate_paths(service_dict) + service_dicts.append(service_dict) + + return service_dicts + + +def make_service_dict(name, service_dict, working_dir=None): + return ServiceLoader(working_dir=working_dir).make_service_dict(name, service_dict) + + +class ServiceLoader(object): + def __init__(self, working_dir, filename=None, already_seen=None): + self.working_dir = working_dir + self.filename = filename + self.already_seen = already_seen or [] + + def make_service_dict(self, name, service_dict): + if self.signature(name) in self.already_seen: + raise CircularReference(self.already_seen) + + service_dict = service_dict.copy() + service_dict['name'] = name + service_dict = resolve_environment(service_dict, working_dir=self.working_dir) + service_dict = self.resolve_extends(service_dict) + return process_container_options(service_dict, working_dir=self.working_dir) + + def resolve_extends(self, service_dict): + if 'extends' not in service_dict: + return service_dict + + extends_options = process_extends_options(service_dict['name'], service_dict['extends']) + + if self.working_dir is None: + raise Exception("No working_dir passed to ServiceLoader()") + + other_config_path = expand_path(self.working_dir, extends_options['file']) + other_working_dir = os.path.dirname(other_config_path) + other_already_seen = self.already_seen + [self.signature(service_dict['name'])] + other_loader = ServiceLoader( + working_dir=other_working_dir, + filename=other_config_path, + already_seen=other_already_seen, + ) + + other_config = load_yaml(other_config_path) + other_service_dict = other_config[extends_options['service']] + other_service_dict = other_loader.make_service_dict( + service_dict['name'], + other_service_dict, + ) + validate_extended_service_dict( + other_service_dict, + filename=other_config_path, + service=extends_options['service'], + ) + + return merge_service_dicts(other_service_dict, service_dict) + + def signature(self, name): + return (self.filename, name) + + +def process_extends_options(service_name, extends_options): + error_prefix = "Invalid 'extends' configuration for %s:" % service_name + + if not isinstance(extends_options, dict): + raise ConfigurationError("%s must be a dictionary" % error_prefix) + + if 'service' not in extends_options: + raise ConfigurationError( + "%s you need to specify a service, e.g. 'service: web'" % error_prefix + ) + + for k, _ in extends_options.items(): + if k not in ['file', 'service']: + raise ConfigurationError( + "%s unsupported configuration option '%s'" % (error_prefix, k) + ) + + return extends_options + + +def validate_extended_service_dict(service_dict, filename, service): + error_prefix = "Cannot extend service '%s' in %s:" % (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_service_name_from_net(service_dict['net']) is not None: + raise ConfigurationError("%s services with 'net: container' cannot be extended" % error_prefix) + + +def process_container_options(service_dict, working_dir=None): + for k in service_dict: + if k not in ALLOWED_KEYS: + msg = "Unsupported config option for %s service: '%s'" % (service_dict['name'], k) + if k in DOCKER_CONFIG_HINTS: + msg += " (did you mean '%s'?)" % DOCKER_CONFIG_HINTS[k] + raise ConfigurationError(msg) + + service_dict = service_dict.copy() + + if 'volumes' in service_dict: + service_dict['volumes'] = resolve_host_paths(service_dict['volumes'], working_dir=working_dir) + + if 'build' in service_dict: + service_dict['build'] = resolve_build_path(service_dict['build'], working_dir=working_dir) + + if 'labels' in service_dict: + service_dict['labels'] = parse_labels(service_dict['labels']) + + return service_dict + + +def merge_service_dicts(base, override): + d = base.copy() + + if 'environment' in base or 'environment' in override: + d['environment'] = merge_environment( + base.get('environment'), + override.get('environment'), + ) + + path_mapping_keys = ['volumes', 'devices'] + + for key in path_mapping_keys: + if key in base or key in override: + d[key] = merge_path_mappings( + base.get(key), + override.get(key), + ) + + if 'labels' in base or 'labels' in override: + d['labels'] = merge_labels( + base.get('labels'), + override.get('labels'), + ) + + if 'image' in override and 'build' in d: + del d['build'] + + if 'build' in override and 'image' in d: + del d['image'] + + list_keys = ['ports', 'expose', 'external_links'] + + for key in list_keys: + if key in base or key in override: + d[key] = base.get(key, []) + override.get(key, []) + + list_or_string_keys = ['dns', 'dns_search'] + + for key in list_or_string_keys: + if key in base or key in override: + d[key] = to_list(base.get(key)) + to_list(override.get(key)) + + already_merged_keys = ['environment', 'labels'] + path_mapping_keys + list_keys + list_or_string_keys + + for k in set(ALLOWED_KEYS) - set(already_merged_keys): + if k in override: + d[k] = override[k] + + return d + + +def merge_environment(base, override): + env = parse_environment(base) + env.update(parse_environment(override)) + return env + + +def parse_links(links): + return dict(parse_link(l) for l in links) + + +def parse_link(link): + if ':' in link: + source, alias = link.split(':', 1) + return (alias, source) + else: + return (link, link) + + +def get_env_files(options, working_dir=None): + if 'env_file' not in options: + return {} + + if working_dir is None: + raise Exception("No working_dir passed to get_env_files()") + + env_files = options.get('env_file', []) + if not isinstance(env_files, list): + env_files = [env_files] + + return [expand_path(working_dir, path) for path in env_files] + + +def resolve_environment(service_dict, working_dir=None): + service_dict = service_dict.copy() + + if 'environment' not in service_dict and 'env_file' not in service_dict: + return service_dict + + env = {} + + if 'env_file' in service_dict: + for f in get_env_files(service_dict, working_dir=working_dir): + env.update(env_vars_from_file(f)) + del service_dict['env_file'] + + env.update(parse_environment(service_dict.get('environment'))) + env = dict(resolve_env_var(k, v) for k, v in six.iteritems(env)) + + service_dict['environment'] = env + return service_dict + + +def parse_environment(environment): + if not environment: + return {} + + if isinstance(environment, list): + return dict(split_env(e) for e in environment) + + if isinstance(environment, dict): + return environment + + raise ConfigurationError( + "environment \"%s\" must be a list or mapping," % + environment + ) + + +def split_env(env): + if '=' in env: + return env.split('=', 1) + else: + return env, None + + +def resolve_env_var(key, val): + if val is not None: + return key, val + elif key in os.environ: + return key, os.environ[key] + else: + return key, '' + + +def env_vars_from_file(filename): + """ + Read in a line delimited file of environment variables. + """ + if not os.path.exists(filename): + raise ConfigurationError("Couldn't find env file: %s" % filename) + env = {} + for line in open(filename, 'r'): + line = line.strip() + if line and not line.startswith('#'): + k, v = split_env(line) + env[k] = v + return env + + +def resolve_host_paths(volumes, working_dir=None): + if working_dir is None: + raise Exception("No working_dir passed to resolve_host_paths()") + + return [resolve_host_path(v, working_dir) for v in volumes] + + +def resolve_host_path(volume, working_dir): + container_path, host_path = split_path_mapping(volume) + if host_path is not None: + host_path = os.path.expanduser(host_path) + host_path = os.path.expandvars(host_path) + return "%s:%s" % (expand_path(working_dir, host_path), container_path) + else: + return container_path + + +def resolve_build_path(build_path, working_dir=None): + if working_dir is None: + raise Exception("No working_dir passed to resolve_build_path") + return expand_path(working_dir, build_path) + + +def validate_paths(service_dict): + if 'build' in service_dict: + build_path = service_dict['build'] + if not os.path.exists(build_path) or not os.access(build_path, os.R_OK): + raise ConfigurationError("build path %s either does not exist or is not accessible." % 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 d.items()] + + +def split_path_mapping(string): + if ':' in string: + (host, container) = string.split(':', 1) + return (container, host) + else: + return (string, None) + + +def join_path_mapping(pair): + (container, host) = pair + if host is None: + return container + else: + return ":".join((host, container)) + + +def merge_labels(base, override): + labels = parse_labels(base) + labels.update(parse_labels(override)) + return labels + + +def parse_labels(labels): + if not labels: + return {} + + if isinstance(labels, list): + return dict(split_label(e) for e in labels) + + if isinstance(labels, dict): + return labels + + raise ConfigurationError( + "labels \"%s\" must be a list or mapping" % + labels + ) + + +def split_label(label): + if '=' in label: + return label.split('=', 1) + else: + return label, '' + + +def expand_path(working_dir, path): + return os.path.abspath(os.path.join(working_dir, path)) + + +def to_list(value): + if value is None: + return [] + elif isinstance(value, six.string_types): + return [value] + else: + return value + + +def get_service_name_from_net(net_config): + if not net_config: + return + + if not net_config.startswith('container:'): + return + + _, net_name = net_config.split(':', 1) + return net_name + + +def load_yaml(filename): + try: + with open(filename, 'r') as fh: + return yaml.safe_load(fh) + except IOError as e: + raise ConfigurationError(six.text_type(e)) + + +class ConfigurationError(Exception): + def __init__(self, msg): + self.msg = msg + + def __str__(self): + return self.msg + + +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)) diff --git a/compose/config/__init__.py b/compose/config/__init__.py deleted file mode 100644 index 7cf71eb9..00000000 --- a/compose/config/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# flake8: noqa -from __future__ import absolute_import -from __future__ import unicode_literals - -from . import environment -from .config import ConfigurationError -from .config import DOCKER_CONFIG_KEYS -from .config import find -from .config import load -from .config import merge_environment -from .config import parse_environment diff --git a/compose/config/config.py b/compose/config/config.py deleted file mode 100644 index 4c9cf423..00000000 --- a/compose/config/config.py +++ /dev/null @@ -1,1123 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import functools -import logging -import os -import string -import sys -from collections import namedtuple - -import six -import yaml -from cached_property import cached_property - -from . import types -from ..const import COMPOSEFILE_V1 as V1 -from ..const import COMPOSEFILE_V2_0 as V2_0 -from ..const import COMPOSEFILE_V2_1 as V2_1 -from ..const import COMPOSEFILE_V3_0 as V3_0 -from ..const import COMPOSEFILE_V3_1 as V3_1 -from ..utils import build_string_dict -from ..utils import parse_nanoseconds_int -from ..utils import splitdrive -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 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 parse_extra_hosts -from .types import parse_restart_spec -from .types import ServiceLink -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_depends_on -from .validation import validate_extends_file_path -from .validation import validate_links -from .validation import validate_network_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_quota', - 'cpu_shares', - 'cpuset', - 'detach', - 'devices', - 'dns', - 'dns_search', - 'domainname', - 'entrypoint', - 'env_file', - 'environment', - 'extra_hosts', - 'group_add', - 'hostname', - 'healthcheck', - 'image', - 'ipc', - 'labels', - 'links', - 'mac_address', - 'mem_limit', - 'memswap_limit', - 'mem_swappiness', - 'net', - 'oom_score_adj', - 'pid', - 'ports', - 'privileged', - 'read_only', - 'restart', - 'secrets', - 'security_opt', - 'shm_size', - 'stdin_open', - 'stop_signal', - 'sysctls', - 'tty', - 'user', - 'userns_mode', - 'volume_driver', - 'volumes', - 'volumes_from', - 'working_dir', -] - -ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [ - 'build', - 'container_name', - 'dockerfile', - 'log_driver', - 'log_opt', - 'logging', - 'network_mode', -] - -DOCKER_VALID_URL_PREFIXES = ( - 'http://', - 'https://', - 'git://', - 'github.com/', - 'git@', -) - -SUPPORTED_FILENAMES = [ - 'docker-compose.yml', - 'docker-compose.yaml', -] - -DEFAULT_OVERRIDE_FILENAME = 'docker-compose.override.yml' - - -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(ConfigDetails, cls).__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 version(self): - if 'version' not in self.config: - return V1 - - version = self.config['version'] - - if isinstance(version, dict): - log.warn('Unexpected type for "version" key in "{}". Assuming ' - '"version" is the name of a service, and defaulting to ' - 'Compose file version 1.'.format(self.filename)) - return V1 - - if not isinstance(version, six.string_types): - raise ConfigurationError( - 'Version in "{}" is invalid - it should be a string.' - .format(self.filename)) - - if version == '1': - raise ConfigurationError( - 'Version in "{}" is invalid. {}' - .format(self.filename, VERSION_EXPLANATION)) - - if version == '2': - version = V2_0 - - if version == '3': - version = V3_0 - - return version - - def get_service(self, name): - return self.get_service_dicts()[name] - - def get_service_dicts(self): - return self.config if self.version == V1 else 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 < V3_1 else self.config.get('secrets', {}) - - -class Config(namedtuple('_Config', 'version services volumes networks secrets')): - """ - :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` - """ - - -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): - if filenames == ['-']: - return ConfigDetails( - os.getcwd(), - [ConfigFile(None, yaml.safe_load(sys.stdin))], - environment - ) - - if filenames: - filenames = [os.path.join(base_dir, f) for f in filenames] - else: - filenames = get_default_config_files(base_dir) - - log.debug("Using configuration files: {}".format(",".join(filenames))) - return ConfigDetails( - 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 {0} specifies version {1} but " - "extension file {2} uses version {3}".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: - raise ComposeFileNotFound(SUPPORTED_FILENAMES) - - winner = candidates[0] - - if len(candidates) > 1: - log.warn("Found multiple config files with supported names: %s", ", ".join(candidates)) - log.warn("Using %s\n", winner) - - return [os.path.join(path, winner)] + get_default_override_file(path) - - -def get_default_override_file(path): - override_filename = os.path.join(path, DEFAULT_OVERRIDE_FILENAME) - return [override_filename] if os.path.exists(override_filename) else [] - - -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 load(config_details): - """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_config_version(config_details.config_files) - - processed_files = [ - process_config_file(config_file, config_details.environment) - 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_secrets(config_details.config_files, config_details.working_dir) - service_dicts = load_services(config_details, main_file) - - if main_file.version != V1: - for service_dict in service_dicts: - match_named_volumes(service_dict, volumes) - - services_using_deploy = [s for s in service_dicts if s.get('deploy')] - if services_using_deploy: - log.warn( - "Some services ({}) use the 'deploy' key, which will be ignored. " - "Compose does not support deploy configuration - use " - "`docker stack deploy` to deploy to a swarm." - .format(", ".join(sorted(s['name'] for s in services_using_deploy)))) - - return Config(main_file.version, service_dicts, volumes, networks, secrets) - - -def load_mapping(config_files, get_func, entity_type): - 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) - if isinstance(external, dict): - config['external_name'] = external.get('name') - else: - config['external_name'] = name - - if 'driver_opts' in config: - config['driver_opts'] = build_string_dict( - config['driver_opts'] - ) - - if 'labels' in config: - config['labels'] = parse_labels(config['labels']) - - return mapping - - -def validate_external(entity_type, name, config): - if len(config.keys()) <= 1: - return - - raise ConfigurationError( - "{} {} declared as external but specifies additional attributes " - "({}).".format( - entity_type, name, ', '.join(k for k in config if k != 'external'))) - - -def load_secrets(config_files, working_dir): - mapping = {} - - for config_file in config_files: - for name, config in config_file.get_secrets().items(): - mapping[name] = config or {} - if not config: - continue - - external = config.get('external') - if external: - validate_external('Secret', name, config) - if isinstance(external, dict): - config['external_name'] = external.get('name') - else: - config['external_name'] = name - - if 'file' in config: - config['file'] = expand_path(working_dir, config['file']) - - return mapping - - -def load_services(config_details, config_file): - 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.version) - service_dict = finalize_service( - service_config, - service_names, - config_file.version, - config_details.environment) - 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 = service_configs[0] - for next_config in service_configs[1:]: - service_config = merge_services(service_config, next_config) - - return build_services(service_config) - - -def interpolate_config_section(config_file, config, section, environment): - validate_config_section(config_file.filename, config, section) - return interpolate_environment_variables( - config_file.version, - config, - section, - environment - ) - - -def process_config_file(config_file, environment, service_name=None): - services = interpolate_config_section( - config_file, - config_file.get_service_dicts(), - 'service', - environment) - - if config_file.version in (V2_0, V2_1, V3_0, V3_1): - processed_config = dict(config_file.config) - processed_config['services'] = services - processed_config['volumes'] = interpolate_config_section( - config_file, - config_file.get_volumes(), - 'volume', - environment) - processed_config['networks'] = interpolate_config_section( - config_file, - config_file.get_networks(), - 'network', - environment) - elif config_file.version == V1: - processed_config = services - else: - raise ConfigurationError( - 'Version in "{}" is unsupported. {}' - .format(config_file.filename, VERSION_EXPLANATION)) - - config_file = config_file._replace(config=processed_config) - validate_against_config_schema(config_file) - - 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(object): - 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'] - - 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): - """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)) - - env.update(parse_environment(service_dict.get('environment'))) - return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(env)) - - -def resolve_build_args(build, environment): - args = parse_build_arguments(build.get('args')) - return dict(resolve_env_var(k, v, environment) for k, v in six.iteritems(args)) - - -def validate_extended_service_dict(service_dict, filename, service): - error_prefix = "Cannot extend service '%s' in %s:" % (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, version): - service_dict, service_name = service_config.config, service_config.name - validate_service_constraints(service_dict, service_name, version) - validate_paths(service_dict) - - validate_ulimits(service_config) - validate_network_mode(service_config, service_names) - validate_depends_on(service_config, service_names) - validate_links(service_config, service_names) - - 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: - if isinstance(service_dict['build'], six.string_types): - service_dict['build'] = resolve_build_path(working_dir, service_dict['build']) - elif isinstance(service_dict['build'], dict) and 'context' in service_dict['build']: - path = service_dict['build']['context'] - service_dict['build']['context'] = resolve_build_path(working_dir, path) - - if 'volumes' in service_dict and service_dict.get('volume_driver') is None: - service_dict['volumes'] = resolve_volume_paths(working_dir, service_dict) - - if 'labels' in service_dict: - service_dict['labels'] = parse_labels(service_dict['labels']) - - if 'extra_hosts' in service_dict: - service_dict['extra_hosts'] = parse_extra_hosts(service_dict['extra_hosts']) - - if 'sysctls' in service_dict: - service_dict['sysctls'] = build_string_dict(parse_sysctls(service_dict['sysctls'])) - - 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_healthcheck(service_dict, service_config.name) - - 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'] = dict([ - (svc, {'condition': 'service_started'}) for svc in service_dict['depends_on'] - ]) - return service_dict - - -def process_healthcheck(service_dict, service_name): - if 'healthcheck' not in service_dict: - return service_dict - - hc = {} - raw = service_dict['healthcheck'] - - if raw.get('disable'): - if len(raw) > 1: - raise ConfigurationError( - 'Service "{}" defines an invalid healthcheck: ' - '"disable: true" cannot be combined with other options' - .format(service_name)) - hc['test'] = ['NONE'] - elif 'test' in raw: - hc['test'] = raw['test'] - - if 'interval' in raw: - if not isinstance(raw['interval'], six.integer_types): - hc['interval'] = parse_nanoseconds_int(raw['interval']) - else: # Conversion has been done previously - hc['interval'] = raw['interval'] - if 'timeout' in raw: - if not isinstance(raw['timeout'], six.integer_types): - hc['timeout'] = parse_nanoseconds_int(raw['timeout']) - else: # Conversion has been done previously - hc['timeout'] = raw['timeout'] - if 'retries' in raw: - hc['retries'] = raw['retries'] - - service_dict['healthcheck'] = hc - return service_dict - - -def finalize_service(service_config, service_names, version, environment): - 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) - 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'] - ] - - if 'volumes' in service_dict: - service_dict['volumes'] = [ - VolumeSpec.parse( - v, environment.get_boolean('COMPOSE_CONVERT_WINDOWS_PATHS') - ) for v in service_dict['volumes'] - ] - - 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'] - ] - - 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): - if not self.needs_merge(field): - return - - 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_ulimits) - md.merge_mapping('networks', parse_networks) - md.merge_mapping('sysctls', parse_sysctls) - md.merge_mapping('depends_on', parse_depends_on) - md.merge_sequence('links', ServiceLink.parse) - md.merge_sequence('secrets', types.ServiceSecret.parse) - - for field in ['volumes', 'devices']: - md.merge_field(field, merge_path_mappings) - - for field in [ - 'ports', 'cap_add', 'cap_drop', 'expose', 'external_links', - 'security_opt', 'volumes_from', - ]: - 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={}) - - 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): - return sorted(set().union(base, override)) - - -def merge_build(output, base, override): - def to_dict(service): - build_config = service.get('build', {}) - if isinstance(build_config, six.string_types): - 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_mapping('args', parse_build_arguments) - 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 {}) - else: - 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 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_ulimits(ulimits): - if not ulimits: - return {} - - if isinstance(ulimits, dict): - return dict(ulimits) - - -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): - container_path, host_path = split_path_mapping(volume) - - if host_path is not None: - if host_path.startswith('.'): - host_path = expand_path(working_dir, host_path) - host_path = os.path.expanduser(host_path) - return u"{}:{}".format(host_path, container_path) - 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'], six.string_types): - 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, 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, six.string_types): - 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 ':'. - """ - drive, volume_config = splitdrive(volume_path) - - if ':' in volume_config: - (host, container) = volume_config.split(':', 1) - return (container, drive + host) - else: - return (volume_path, None) - - -def join_path_mapping(pair): - (container, host) = pair - if host is None: - return container - else: - return ":".join((host, container)) - - -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, six.string_types): - 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): - try: - with open(filename, 'r') as fh: - return yaml.safe_load(fh) - except (IOError, yaml.YAMLError) as e: - error_name = getattr(e, '__module__', '') + '.' + e.__class__.__name__ - raise ConfigurationError(u"{}: {}".format(error_name, e)) diff --git a/compose/config/config_schema_v1.json b/compose/config/config_schema_v1.json deleted file mode 100644 index 94354cda..00000000 --- a/compose/config/config_schema_v1.json +++ /dev/null @@ -1,188 +0,0 @@ -{ - "$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/list_or_dict"}, - "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} - ] - }, - - "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/config_schema_v2.0.json b/compose/config/config_schema_v2.0.json deleted file mode 100644 index 59c7b30c..00000000 --- a/compose/config/config_schema_v2.0.json +++ /dev/null @@ -1,329 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v2.0.json", - "type": "object", - - "properties": { - "version": { - "type": "string" - }, - - "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 - } - }, - - "additionalProperties": false, - - "definitions": { - - "service": { - "id": "#/definitions/service", - "type": "object", - - "properties": { - "build": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "context": {"type": "string"}, - "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"} - }, - "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"}} - ] - }, - "container_name": {"type": "string"}, - "cpu_shares": {"type": ["number", "string"]}, - "cpu_quota": {"type": ["number", "string"]}, - "cpuset": {"type": "string"}, - "depends_on": {"$ref": "#/definitions/list_of_strings"}, - "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "dns": {"$ref": "#/definitions/string_or_list"}, - "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"}, - "hostname": {"type": "string"}, - "image": {"type": "string"}, - "ipc": {"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"} - }, - "additionalProperties": false - }, - - "mac_address": {"type": "string"}, - "mem_limit": {"type": ["number", "string"]}, - "memswap_limit": {"type": ["number", "string"]}, - "mem_swappiness": {"type": "integer"}, - "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"} - }, - "additionalProperties": false - }, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - ] - }, - "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, - "group_add": { - "type": "array", - "items": { - "type": ["string", "number"] - }, - "uniqueItems": true - }, - "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_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "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 - } - ] - } - } - }, - "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 - }, - - "network": { - "id": "#/definitions/network", - "type": "object", - "properties": { - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "ipam": { - "type": "object", - "properties": { - "driver": {"type": "string"}, - "config": { - "type": "array" - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"} - }, - "additionalProperties": false - }, - - "volume": { - "id": "#/definitions/volume", - "type": ["object", "null"], - "properties": { - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - } - }, - "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} - ] - }, - - "constraints": { - "service": { - "id": "#/definitions/constraints/service", - "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] - } - } - } - } - } -} diff --git a/compose/config/config_schema_v2.1.json b/compose/config/config_schema_v2.1.json deleted file mode 100644 index d1ffff89..00000000 --- a/compose/config/config_schema_v2.1.json +++ /dev/null @@ -1,376 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v2.1.json", - "type": "object", - - "properties": { - "version": { - "type": "string" - }, - - "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 - } - }, - - "additionalProperties": false, - - "definitions": { - - "service": { - "id": "#/definitions/service", - "type": "object", - - "properties": { - "build": { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "context": {"type": "string"}, - "dockerfile": {"type": "string"}, - "args": {"$ref": "#/definitions/list_or_dict"} - }, - "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"}} - ] - }, - "container_name": {"type": "string"}, - "cpu_shares": {"type": ["number", "string"]}, - "cpu_quota": {"type": ["number", "string"]}, - "cpuset": {"type": "string"}, - "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"] - } - }, - "required": ["condition"] - } - } - } - ] - }, - "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "dns": {"$ref": "#/definitions/string_or_list"}, - "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"}, - "healthcheck": {"$ref": "#/definitions/healthcheck"}, - "hostname": {"type": "string"}, - "image": {"type": "string"}, - "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"} - }, - "additionalProperties": false - }, - - "mac_address": {"type": "string"}, - "mem_limit": {"type": ["number", "string"]}, - "memswap_limit": {"type": ["number", "string"]}, - "mem_swappiness": {"type": "integer"}, - "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"} - }, - "additionalProperties": false - }, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - ] - }, - "oom_score_adj": {"type": "integer", "minimum": -1000, "maximum": 1000}, - "group_add": { - "type": "array", - "items": { - "type": ["string", "number"] - }, - "uniqueItems": true - }, - "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"]}, - "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "stdin_open": {"type": "boolean"}, - "stop_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "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 - } - ] - } - } - }, - "user": {"type": "string"}, - "userns_mode": {"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 - }, - - "healthcheck": { - "id": "#/definitions/healthcheck", - "type": "object", - "additionalProperties": false, - "properties": { - "disable": {"type": "boolean"}, - "interval": {"type": "string"}, - "retries": {"type": "number"}, - "test": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "timeout": {"type": "string"} - } - }, - - "network": { - "id": "#/definitions/network", - "type": "object", - "properties": { - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "ipam": { - "type": "object", - "properties": { - "driver": {"type": "string"}, - "config": { - "type": "array" - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"}, - "enable_ipv6": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} - }, - "additionalProperties": false - }, - - "volume": { - "id": "#/definitions/volume", - "type": ["object", "null"], - "properties": { - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "labels": {"$ref": "#/definitions/list_or_dict"} - }, - "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} - ] - }, - - "constraints": { - "service": { - "id": "#/definitions/constraints/service", - "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] - } - } - } - } - } -} diff --git a/compose/config/config_schema_v3.0.json b/compose/config/config_schema_v3.0.json deleted file mode 100644 index fbcd8bb8..00000000 --- a/compose/config/config_schema_v3.0.json +++ /dev/null @@ -1,383 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.0.json", - "type": "object", - "required": ["version"], - - "properties": { - "version": { - "type": "string" - }, - - "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 - } - }, - - "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"} - }, - "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"}} - ] - }, - "container_name": {"type": "string"}, - "depends_on": {"$ref": "#/definitions/list_of_strings"}, - "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "dns": {"$ref": "#/definitions/string_or_list"}, - "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 - }, - - "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, - "healthcheck": {"$ref": "#/definitions/healthcheck"}, - "hostname": {"type": "string"}, - "image": {"type": "string"}, - "ipc": {"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 - }, - - "mac_address": {"type": "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"} - }, - "additionalProperties": false - }, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - ] - }, - "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"]}, - "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "stdin_open": {"type": "boolean"}, - "stop_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "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 - } - ] - } - } - }, - "user": {"type": "string"}, - "userns_mode": {"type": "string"}, - "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "working_dir": {"type": "string"} - }, - "additionalProperties": false - }, - - "healthcheck": { - "id": "#/definitions/healthcheck", - "type": "object", - "additionalProperties": false, - "properties": { - "disable": {"type": "boolean"}, - "interval": {"type": "string"}, - "retries": {"type": "number"}, - "test": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "timeout": {"type": "string"} - } - }, - "deployment": { - "id": "#/definitions/deployment", - "type": ["object", "null"], - "properties": { - "mode": {"type": "string"}, - "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, - "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"} - }, - "additionalProperties": false - }, - "resources": { - "type": "object", - "properties": { - "limits": {"$ref": "#/definitions/resource"}, - "reservations": {"$ref": "#/definitions/resource"} - } - }, - "restart_policy": { - "type": "object", - "properties": { - "condition": {"type": "string"}, - "delay": {"type": "string", "format": "duration"}, - "max_attempts": {"type": "integer"}, - "window": {"type": "string", "format": "duration"} - }, - "additionalProperties": false - }, - "placement": { - "type": "object", - "properties": { - "constraints": {"type": "array", "items": {"type": "string"}} - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - - "resource": { - "id": "#/definitions/resource", - "type": "object", - "properties": { - "cpus": {"type": "string"}, - "memory": {"type": "string"} - }, - "additionalProperties": false - }, - - "network": { - "id": "#/definitions/network", - "type": ["object", "null"], - "properties": { - "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"} - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} - }, - "additionalProperties": false - }, - - "volume": { - "id": "#/definitions/volume", - "type": ["object", "null"], - "properties": { - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "labels": {"$ref": "#/definitions/list_or_dict"} - }, - "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} - ] - }, - - "constraints": { - "service": { - "id": "#/definitions/constraints/service", - "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] - } - } - } - } - } -} diff --git a/compose/config/config_schema_v3.1.json b/compose/config/config_schema_v3.1.json deleted file mode 100644 index b7037485..00000000 --- a/compose/config/config_schema_v3.1.json +++ /dev/null @@ -1,428 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "id": "config_schema_v3.1.json", - "type": "object", - "required": ["version"], - - "properties": { - "version": { - "type": "string" - }, - - "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 - } - }, - - "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"} - }, - "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"}} - ] - }, - "container_name": {"type": "string"}, - "depends_on": {"$ref": "#/definitions/list_of_strings"}, - "devices": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "dns": {"$ref": "#/definitions/string_or_list"}, - "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 - }, - - "external_links": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "extra_hosts": {"$ref": "#/definitions/list_or_dict"}, - "healthcheck": {"$ref": "#/definitions/healthcheck"}, - "hostname": {"type": "string"}, - "image": {"type": "string"}, - "ipc": {"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 - }, - - "mac_address": {"type": "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"} - }, - "additionalProperties": false - }, - {"type": "null"} - ] - } - }, - "additionalProperties": false - } - ] - }, - "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"]}, - "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"} - } - } - ] - } - }, - "sysctls": {"$ref": "#/definitions/list_or_dict"}, - "stdin_open": {"type": "boolean"}, - "stop_grace_period": {"type": "string", "format": "duration"}, - "stop_signal": {"type": "string"}, - "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 - } - ] - } - } - }, - "user": {"type": "string"}, - "userns_mode": {"type": "string"}, - "volumes": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, - "working_dir": {"type": "string"} - }, - "additionalProperties": false - }, - - "healthcheck": { - "id": "#/definitions/healthcheck", - "type": "object", - "additionalProperties": false, - "properties": { - "disable": {"type": "boolean"}, - "interval": {"type": "string"}, - "retries": {"type": "number"}, - "test": { - "oneOf": [ - {"type": "string"}, - {"type": "array", "items": {"type": "string"}} - ] - }, - "timeout": {"type": "string"} - } - }, - "deployment": { - "id": "#/definitions/deployment", - "type": ["object", "null"], - "properties": { - "mode": {"type": "string"}, - "replicas": {"type": "integer"}, - "labels": {"$ref": "#/definitions/list_or_dict"}, - "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"} - }, - "additionalProperties": false - }, - "resources": { - "type": "object", - "properties": { - "limits": {"$ref": "#/definitions/resource"}, - "reservations": {"$ref": "#/definitions/resource"} - } - }, - "restart_policy": { - "type": "object", - "properties": { - "condition": {"type": "string"}, - "delay": {"type": "string", "format": "duration"}, - "max_attempts": {"type": "integer"}, - "window": {"type": "string", "format": "duration"} - }, - "additionalProperties": false - }, - "placement": { - "type": "object", - "properties": { - "constraints": {"type": "array", "items": {"type": "string"}} - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - - "resource": { - "id": "#/definitions/resource", - "type": "object", - "properties": { - "cpus": {"type": "string"}, - "memory": {"type": "string"} - }, - "additionalProperties": false - }, - - "network": { - "id": "#/definitions/network", - "type": ["object", "null"], - "properties": { - "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"} - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "internal": {"type": "boolean"}, - "labels": {"$ref": "#/definitions/list_or_dict"} - }, - "additionalProperties": false - }, - - "volume": { - "id": "#/definitions/volume", - "type": ["object", "null"], - "properties": { - "driver": {"type": "string"}, - "driver_opts": { - "type": "object", - "patternProperties": { - "^.+$": {"type": ["string", "number"]} - } - }, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - }, - "additionalProperties": false - }, - "labels": {"$ref": "#/definitions/list_or_dict"} - }, - "additionalProperties": false - }, - - "secret": { - "id": "#/definitions/secret", - "type": "object", - "properties": { - "file": {"type": "string"}, - "external": { - "type": ["boolean", "object"], - "properties": { - "name": {"type": "string"} - } - }, - "labels": {"$ref": "#/definitions/list_or_dict"} - }, - "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} - ] - }, - - "constraints": { - "service": { - "id": "#/definitions/constraints/service", - "anyOf": [ - {"required": ["build"]}, - {"required": ["image"]} - ], - "properties": { - "build": { - "required": ["context"] - } - } - } - } - } -} diff --git a/compose/config/environment.py b/compose/config/environment.py deleted file mode 100644 index 4ba228c8..00000000 --- a/compose/config/environment.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import codecs -import contextlib -import logging -import os - -import six - -from ..const import IS_WINDOWS_PLATFORM -from .errors import ConfigurationError - -log = logging.getLogger(__name__) - - -def split_env(env): - if isinstance(env, six.binary_type): - env = env.decode('utf-8', 'replace') - if '=' in env: - return env.split('=', 1) - else: - return env, None - - -def env_vars_from_file(filename): - """ - Read in a line delimited file of environment variables. - """ - if not os.path.exists(filename): - raise ConfigurationError("Couldn't find env file: %s" % filename) - elif not os.path.isfile(filename): - raise ConfigurationError("%s is not a file." % (filename)) - env = {} - with contextlib.closing(codecs.open(filename, 'r', 'utf-8')) as fileobj: - for line in fileobj: - line = line.strip() - if line and not line.startswith('#'): - k, v = split_env(line) - env[k] = v - return env - - -class Environment(dict): - def __init__(self, *args, **kwargs): - super(Environment, self).__init__(*args, **kwargs) - self.missing_keys = [] - - @classmethod - def from_env_file(cls, base_dir): - def _initialize(): - result = cls() - if base_dir is None: - return result - env_file_path = os.path.join(base_dir, '.env') - try: - return cls(env_vars_from_file(env_file_path)) - except ConfigurationError: - 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(Environment, self).__getitem__(key) - except KeyError: - if IS_WINDOWS_PLATFORM: - try: - return super(Environment, self).__getitem__(key.upper()) - except KeyError: - pass - if key not in self.missing_keys: - log.warn( - "The {} variable is not set. Defaulting to a blank string." - .format(key) - ) - self.missing_keys.append(key) - - return "" - - def __contains__(self, key): - result = super(Environment, self).__contains__(key) - if IS_WINDOWS_PLATFORM: - return ( - result or super(Environment, self).__contains__(key.upper()) - ) - return result - - def get(self, key, *args, **kwargs): - if IS_WINDOWS_PLATFORM: - return super(Environment, self).get( - key, - super(Environment, self).get(key.upper(), *args, **kwargs) - ) - return super(Environment, self).get(key, *args, **kwargs) - - def get_boolean(self, key): - # 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 False - if value.lower() in ['0', 'false']: - return False - return True diff --git a/compose/config/errors.py b/compose/config/errors.py deleted file mode 100644 index 16ed01b8..00000000 --- a/compose/config/errors.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - - -VERSION_EXPLANATION = ( - 'You might be seeing this error because you\'re using the wrong Compose file version. ' - 'Either specify a supported version ("2.0", "2.1", "3.0") 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 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(ComposeFileNotFound, self).__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)) diff --git a/compose/config/interpolation.py b/compose/config/interpolation.py deleted file mode 100644 index 1b270b9e..00000000 --- a/compose/config/interpolation.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import logging -from string import Template - -import six - -from .errors import ConfigurationError -from compose.const import COMPOSEFILE_V1 as V1 -from compose.const import COMPOSEFILE_V2_0 as V2_0 - - -log = logging.getLogger(__name__) - - -class Interpolator(object): - - 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 in (V2_0, V1): - interpolator = Interpolator(Template, environment) - else: - interpolator = Interpolator(TemplateWithDefaults, environment) - - def process_item(name, config_dict): - return dict( - (key, interpolate_value(name, key, val, section, interpolator)) - for key, val in (config_dict or {}).items() - ) - - return dict( - (name, process_item(name, config_dict or {})) - for name, config_dict in config.items() - ) - - -def interpolate_value(name, config_key, value, section, interpolator): - try: - return recursive_interpolate(value, interpolator) - 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)) - - -def recursive_interpolate(obj, interpolator): - if isinstance(obj, six.string_types): - return interpolator.interpolate(obj) - if isinstance(obj, dict): - return dict( - (key, recursive_interpolate(val, interpolator)) - for (key, val) in obj.items() - ) - if isinstance(obj, list): - return [recursive_interpolate(val, interpolator) for val in obj] - return obj - - -class TemplateWithDefaults(Template): - idpattern = r'[_a-z][_a-z0-9]*(?::?-[^}]+)?' - - # Modified from python2.7/string.py - def substitute(self, mapping): - # Helper function for .sub() - def convert(mo): - # Check the most common path first. - named = mo.group('named') or mo.group('braced') - if named is not None: - if ':-' in named: - var, _, default = named.partition(':-') - return mapping.get(var) or default - if '-' in named: - var, _, default = named.partition('-') - return mapping.get(var, default) - val = mapping[named] - return '%s' % (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 diff --git a/compose/config/serialize.py b/compose/config/serialize.py deleted file mode 100644 index 46d283f0..00000000 --- a/compose/config/serialize.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import six -import yaml - -from compose.config import types -from compose.config.config import V1 -from compose.config.config import V2_1 - - -def serialize_config_type(dumper, data): - representer = dumper.represent_str if six.PY3 else dumper.represent_unicode - return representer(data.repr()) - - -yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type) -yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) - - -def denormalize_config(config): - denormalized_services = [ - denormalize_service_dict(service_dict, config.version) - for service_dict in config.services - ] - services = { - service_dict.pop('name'): service_dict - for service_dict in denormalized_services - } - networks = config.networks.copy() - for net_name, net_conf in networks.items(): - if 'external_name' in net_conf: - del net_conf['external_name'] - - volumes = config.volumes.copy() - for vol_name, vol_conf in volumes.items(): - if 'external_name' in vol_conf: - del vol_conf['external_name'] - - version = config.version - if version == V1: - version = V2_1 - - return { - 'version': version, - 'services': services, - 'networks': networks, - 'volumes': volumes, - } - - -def serialize_config(config): - return yaml.safe_dump( - denormalize_config(config), - default_flow_style=False, - indent=2, - width=80) - - -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 '{0}{1}'.format(*result) - - -def denormalize_service_dict(service_dict, version): - service_dict = service_dict.copy() - - 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 'depends_on' in service_dict and version != V2_1: - service_dict['depends_on'] = sorted([ - svc for svc in service_dict['depends_on'].keys() - ]) - - 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 'secrets' in service_dict: - service_dict['secrets'] = map(lambda s: s.repr(), service_dict['secrets']) - - return service_dict diff --git a/compose/config/sort_services.py b/compose/config/sort_services.py deleted file mode 100644 index 20ac4461..00000000 --- a/compose/config/sort_services.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -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(':')[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 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 deleted file mode 100644 index 811e6c1f..00000000 --- a/compose/config/types.py +++ /dev/null @@ -1,260 +0,0 @@ -""" -Types for objects parsed from the configuration. -""" -from __future__ import absolute_import -from __future__ import unicode_literals - -import os -import re -from collections import namedtuple - -import six - -from ..const import COMPOSEFILE_V1 as V1 -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(six.text_type(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('\\', '/') - - -class VolumeSpec(namedtuple('_VolumeSpec', 'external internal mode')): - - @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 = os.path.normpath(external) - 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 - - return cls(external, internal, mode) - - @classmethod - def parse(cls, volume_config, normalize=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: - return cls._parse_win32(volume_config, normalize) - else: - return cls._parse_unix(volume_config) - - def repr(self): - external = self.external + ':' if self.external else '' - return '{ext}{v.internal}:{v.mode}'.format(ext=external, v=self) - - @property - def is_named_volume(self): - res = self.external and not self.external.startswith(('.', '/', '~')) - if not IS_WINDOWS_PLATFORM: - 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 ServiceSecret(namedtuple('_ServiceSecret', 'source target uid gid mode')): - - @classmethod - def parse(cls, spec): - if isinstance(spec, six.string_types): - return cls(spec, None, None, None, None) - return cls( - spec.get('source'), - spec.get('target'), - spec.get('uid'), - spec.get('gid'), - spec.get('mode'), - ) - - @property - def merge_field(self): - return self.source - - def repr(self): - return dict( - [(k, v) for k, v in self._asdict().items() if v is not None] - ) diff --git a/compose/config/validation.py b/compose/config/validation.py deleted file mode 100644 index 3f23f0a7..00000000 --- a/compose/config/validation.py +++ /dev/null @@ -1,423 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import json -import logging -import os -import re -import sys - -import six -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 .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 = '[a-zA-Z0-9\._\-]' -VALID_EXPOSE_FORMAT = r'^\d+(\-\d+)?(\/[a-zA-Z]+)?$' - - -@FormatChecker.cls_checks(format="ports", raises=ValidationError) -def format_ports(instance): - try: - split_port(instance) - except ValueError as e: - raise ValidationError(six.text_type(e)) - return True - - -@FormatChecker.cls_checks(format="expose", raises=ValidationError) -def format_expose(instance): - if isinstance(instance, six.string_types): - if not re.match(VALID_EXPOSE_FORMAT, instance): - raise ValidationError( - "should be of the format 'PORT[/PROTOCOL]'") - - 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 "{0}" is used in service "{1}" 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, six.string_types): - 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 six.iteritems(ulimit_config): - 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_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 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 the key to the json object - list(error.instance)[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 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 = six.text_type(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): - return error.message.split("'")[1] - - -def path_string(path): - return ".".join(c for c in path if isinstance(c, six.string_types)) - - -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.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 == 'uniqueItems': - return ( - None, - "contains non unique items, please remove duplicates from {}".format( - context.instance), - ) - - 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 validate_against_config_schema(config_file): - schema = load_jsonschema(config_file.version) - format_checker = FormatChecker(["ports", "expose"]) - validator = Draft4Validator( - schema, - resolver=RefResolver(get_resolver_path(), schema), - format_checker=format_checker) - handle_errors( - validator.iter_errors(config_file.config), - process_config_schema_errors, - config_file.filename) - - -def validate_service_constraints(config, service_name, version): - def handler(errors): - return process_service_constraint_errors(errors, service_name, version) - - schema = load_jsonschema(version) - validator = Draft4Validator(schema['definitions']['constraints']['service']) - handle_errors(validator.iter_errors(config), handler, None) - - -def get_schema_path(): - return os.path.dirname(os.path.abspath(__file__)) - - -def load_jsonschema(version): - filename = os.path.join( - get_schema_path(), - "config_schema_v{0}.json".format(version)) - - with open(filename, "r") 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 = list(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)) diff --git a/compose/const.py b/compose/const.py index e694dbda..f76fb572 100644 --- a/compose/const.py +++ b/compose/const.py @@ -1,41 +1,7 @@ -from __future__ import absolute_import -from __future__ import unicode_literals -import sys - -DEFAULT_TIMEOUT = 10 -HTTP_TIMEOUT = 60 -IMAGE_EVENTS = ['delete', 'import', 'load', 'pull', 'push', 'save', 'tag', 'untag'] -IS_WINDOWS_PLATFORM = (sys.platform == "win32") LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number' LABEL_ONE_OFF = 'com.docker.compose.oneoff' LABEL_PROJECT = 'com.docker.compose.project' LABEL_SERVICE = 'com.docker.compose.service' -LABEL_NETWORK = 'com.docker.compose.network' LABEL_VERSION = 'com.docker.compose.version' -LABEL_VOLUME = 'com.docker.compose.volume' LABEL_CONFIG_HASH = 'com.docker.compose.config-hash' - -SECRETS_PATH = '/run/secrets' - -COMPOSEFILE_V1 = '1' -COMPOSEFILE_V2_0 = '2.0' -COMPOSEFILE_V2_1 = '2.1' -COMPOSEFILE_V3_0 = '3.0' -COMPOSEFILE_V3_1 = '3.1' - -API_VERSIONS = { - COMPOSEFILE_V1: '1.21', - COMPOSEFILE_V2_0: '1.22', - COMPOSEFILE_V2_1: '1.24', - COMPOSEFILE_V3_0: '1.25', - COMPOSEFILE_V3_1: '1.25', -} - -API_VERSION_TO_ENGINE_VERSION = { - API_VERSIONS[COMPOSEFILE_V1]: '1.9.0', - API_VERSIONS[COMPOSEFILE_V2_0]: '1.10.0', - API_VERSIONS[COMPOSEFILE_V2_1]: '1.12.0', - API_VERSIONS[COMPOSEFILE_V3_0]: '1.13.0', - API_VERSIONS[COMPOSEFILE_V3_1]: '1.13.0', -} diff --git a/compose/container.py b/compose/container.py index bda4e659..71951497 100644 --- a/compose/container.py +++ b/compose/container.py @@ -1,13 +1,10 @@ -from __future__ import absolute_import from __future__ import unicode_literals - -from functools import reduce +from __future__ import absolute_import import six +from functools import reduce -from .const import LABEL_CONTAINER_NUMBER -from .const import LABEL_PROJECT -from .const import LABEL_SERVICE +from .const import LABEL_CONTAINER_NUMBER, LABEL_SERVICE class Container(object): @@ -19,27 +16,22 @@ class Container(object): 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, + 'Name': '/' + get_container_name(dictionary), } return cls(client, new_dictionary, **kwargs) @classmethod def from_id(cls, client, id): - return cls(client, client.inspect_container(id), has_been_inspected=True) + return cls(client, client.inspect_container(id)) @classmethod def create(cls, client, **options): @@ -60,24 +52,15 @@ class Container(object): @property def short_id(self): - return self.id[:12] + return self.id[:10] @property def name(self): return self.dictionary['Name'][1:] - @property - def service(self): - return self.labels.get(LABEL_SERVICE) - @property def name_without_project(self): - project = self.labels.get(LABEL_PROJECT) - - if self.name.startswith('{0}_{1}'.format(project, self.service)): - return '{0}_{1}'.format(self.service, self.number) - else: - return self.name + return '{0}_{1}'.format(self.labels.get(LABEL_SERVICE), self.number) @property def number(self): @@ -107,20 +90,12 @@ class Container(object): 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 'Up' else: @@ -134,44 +109,12 @@ class Container(object): @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') + return dict(var.split("=", 1) for var in self.get('Config.Env') or []) @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 has_api_logs(self): - log_type = self.log_driver - return not log_type or log_type in ('json-file', 'journald') - - def attach_log_stream(self): - """A log stream can only be attached if the container uses a json-file - log driver. - """ - if self.has_api_logs: - 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. @@ -189,24 +132,12 @@ class Container(object): port = self.ports.get("%s/%s" % (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) @@ -216,21 +147,6 @@ class Container(object): 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. - """ - self.client.rename( - self.id, - '%s_%s' % (self.short_id, self.name) - ) - def inspect_if_not_inspected(self): if not self.has_been_inspected: self.inspect() @@ -246,9 +162,22 @@ class Container(object): self.has_been_inspected = True return self.dictionary + # TODO: only used by tests, move to test module + def links(self): + links = [] + for container in self.client.containers(): + for name in container['Names']: + bits = name.split('/') + if len(bits) > 2 and bits[1] == self.name: + links.append(bits[2]) + return links + def attach(self, *args, **kwargs): return self.client.attach(self.id, *args, **kwargs) + def attach_socket(self, **kwargs): + return self.client.attach_socket(self.id, **kwargs) + def __repr__(self): return '' % (self.name, self.id[:6]) diff --git a/compose/errors.py b/compose/errors.py deleted file mode 100644 index 415b41e7..00000000 --- a/compose/errors.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - - -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(HealthCheckFailed, self).__init__( - 'Container "{}" is unhealthy.'.format(container_id) - ) - - -class NoHealthCheckConfigured(HealthCheckException): - def __init__(self, service_name): - super(NoHealthCheckConfigured, self).__init__( - 'Service "{}" is missing a healthcheck configuration'.format( - service_name - ) - ) diff --git a/compose/legacy.py b/compose/legacy.py new file mode 100644 index 00000000..340511a7 --- /dev/null +++ b/compose/legacy.py @@ -0,0 +1,122 @@ +import logging +import re + +from .container import get_container_name, Container + + +log = logging.getLogger(__name__) + + +# TODO: remove this section when migrate_project_to_labels is removed +NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$') + +ERROR_MESSAGE_FORMAT = """ +Compose found the following containers without labels: + +{names_list} + +As of Compose 1.3.0, containers are identified with labels instead of naming convention. If you want to continue using these containers, run: + + $ docker-compose migrate-to-labels + +Alternatively, remove them: + + $ docker rm -f {rm_args} +""" + + +def check_for_legacy_containers( + client, + project, + services, + stopped=False, + one_off=False): + """Check if there are containers named using the old naming convention + and warn the user that those containers may need to be migrated to + using labels, so that compose can find them. + """ + containers = list(get_legacy_containers( + client, + project, + services, + stopped=stopped, + one_off=one_off)) + + if containers: + raise LegacyContainersError([c.name for c in containers]) + + +class LegacyContainersError(Exception): + def __init__(self, names): + self.names = names + + self.msg = ERROR_MESSAGE_FORMAT.format( + names_list="\n".join(" {}".format(name) for name in names), + rm_args=" ".join(names), + ) + + def __unicode__(self): + return self.msg + + __str__ = __unicode__ + + +def add_labels(project, container): + project_name, service_name, one_off, number = NAME_RE.match(container.name).groups() + if project_name != project.name or service_name not in project.service_names: + return + service = project.get_service(service_name) + service.recreate_container(container) + + +def migrate_project_to_labels(project): + log.info("Running migration to labels for project %s", project.name) + + containers = get_legacy_containers( + project.client, + project.name, + project.service_names, + stopped=True, + one_off=False) + + for container in containers: + add_labels(project, container) + + +def get_legacy_containers( + client, + project, + services, + stopped=False, + one_off=False): + + containers = client.containers(all=stopped) + + for service in services: + for container in containers: + name = get_container_name(container) + if has_container(project, service, name, one_off=one_off): + yield Container.from_ps(client, container) + + +def has_container(project, service, name, one_off=False): + if not name or not is_valid_name(name, one_off): + return False + container_project, container_service, _container_number = parse_name(name) + return container_project == project and container_service == service + + +def is_valid_name(name, one_off=False): + match = NAME_RE.match(name) + if match is None: + return False + if one_off: + return match.group(3) == 'run_' + else: + return match.group(3) is None + + +def parse_name(name): + match = NAME_RE.match(name) + (project, service_name, _, suffix) = match.groups() + return (project, service_name, int(suffix)) diff --git a/compose/network.py b/compose/network.py deleted file mode 100644 index d98f68d2..00000000 --- a/compose/network.py +++ /dev/null @@ -1,231 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import logging - -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 .config import ConfigurationError -from .const import LABEL_NETWORK -from .const import LABEL_PROJECT - - -log = logging.getLogger(__name__) - -OPTS_EXCEPTIONS = [ - 'com.docker.network.driver.overlay.vxlanid_list', -] - - -class Network(object): - def __init__(self, client, project, name, driver=None, driver_opts=None, - ipam=None, external_name=None, internal=False, enable_ipv6=False, - labels=None): - 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_name = external_name - self.internal = internal - self.enable_ipv6 = enable_ipv6 - self.labels = labels - - def ensure(self): - if self.external_name: - try: - self.inspect() - log.debug( - 'Network {0} 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.external_name, - command='docker network create' - ) - ) - return - - try: - data = self.inspect() - 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, - ) - - def remove(self): - if self.external_name: - log.info("Network %s is external, skipping", self.full_name) - return - - log.info("Removing network {}".format(self.full_name)) - self.client.remove_network(self.full_name) - - def inspect(self): - return self.client.inspect_network(self.full_name) - - @property - def full_name(self): - if self.external_name: - return self.external_name - return '{0}_{1}'.format(self.project, self.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, - }) - return labels - - -def create_ipam_config_from_dict(ipam_dict): - if not ipam_dict: - return None - - return IPAMConfig( - driver=ipam_dict.get('driver'), - 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', []) - ], - ) - - -def check_remote_network_config(remote, local): - if local.driver and remote.get('Driver') != local.driver: - raise ConfigurationError( - 'Network "{}" needs to be recreated - driver has changed' - .format(local.full_name) - ) - 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 ConfigurationError( - 'Network "{}" needs to be recreated - options have changed' - .format(local.full_name) - ) - - -def build_networks(name, config_data, client): - network_config = config_data.networks or {} - networks = { - network_name: Network( - client=client, project=name, name=network_name, - driver=data.get('driver'), - driver_opts=data.get('driver_opts'), - ipam=data.get('ipam'), - external_name=data.get('external_name'), - internal=data.get('internal'), - enable_ipv6=data.get('enable_ipv6'), - labels=data.get('labels'), - ) - for network_name, data in network_config.items() - } - - if 'default' not in networks: - networks['default'] = Network(client, name, 'default') - - return networks - - -class ProjectNetworks(object): - - 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.warn( - "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.warn("Network %s not found.", network.full_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 dict( - (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.full_name] = netdef - else: - raise ConfigurationError( - 'Service "{}" uses an undefined network "{}"' - .format(service_dict['name'], name)) - - return networks diff --git a/compose/parallel.py b/compose/parallel.py deleted file mode 100644 index e495410c..00000000 --- a/compose/parallel.py +++ /dev/null @@ -1,259 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import logging -import operator -import sys -from threading import Thread - -from docker.errors import APIError -from six.moves import _thread as thread -from six.moves.queue import Empty -from six.moves.queue import Queue - -from compose.cli.signals import ShutdownException -from compose.errors import HealthCheckFailed -from compose.errors import NoHealthCheckConfigured -from compose.errors import OperationFailedError -from compose.utils import get_output_stream - - -log = logging.getLogger(__name__) - -STOP = object() - - -def parallel_execute(objects, func, get_name, msg, get_deps=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. - """ - objects = list(objects) - stream = get_output_stream(sys.stderr) - - writer = ParallelStreamWriter(stream, msg) - for obj in objects: - writer.initialize(get_name(obj)) - - events = parallel_execute_iter(objects, func, get_deps) - - errors = {} - results = [] - error_to_reraise = None - - for obj, result, exception in events: - if exception is None: - writer.write(get_name(obj), 'done') - results.append(result) - elif isinstance(exception, APIError): - errors[get_name(obj)] = exception.explanation - writer.write(get_name(obj), 'error') - elif isinstance(exception, (OperationFailedError, HealthCheckFailed, NoHealthCheckConfigured)): - errors[get_name(obj)] = exception.msg - writer.write(get_name(obj), 'error') - elif isinstance(exception, UpstreamError): - writer.write(get_name(obj), 'error') - else: - errors[get_name(obj)] = exception - error_to_reraise = exception - - 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(object): - """ - 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 - - -def parallel_execute_iter(objects, func, get_deps): - """ - 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 - - results = Queue() - state = State(objects) - - while True: - feed_queue(objects, func, get_deps, results, state) - - 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): - """ - 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. - """ - 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): - """ - 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)) - 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)) - - if state.is_done(): - results.put(STOP) - - -class UpstreamError(Exception): - pass - - -class ParallelStreamWriter(object): - """Write out messages for operations happening in parallel. - - Each operation has it's own line, and ANSI code characters are used - to jump to the correct line, and write over the line. - """ - - def __init__(self, stream, msg): - self.stream = stream - self.msg = msg - self.lines = [] - - def initialize(self, obj_index): - if self.msg is None: - return - self.lines.append(obj_index) - self.stream.write("{} {} ... \r\n".format(self.msg, obj_index)) - self.stream.flush() - - def write(self, obj_index, status): - if self.msg is None: - return - position = self.lines.index(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("{} {} ... {}\r".format(self.msg, obj_index, status)) - # move back down - self.stream.write("%c[%dB" % (27, diff)) - self.stream.flush() - - -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_start(containers, options): - parallel_operation(containers, 'start', options, 'Starting') - - -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 index 5314f89f..317c6e81 100644 --- a/compose/progress_stream.py +++ b/compose/progress_stream.py @@ -1,7 +1,6 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -from compose import utils +import json +import os +import codecs class StreamOutputError(Exception): @@ -9,41 +8,35 @@ class StreamOutputError(Exception): def stream_output(output, stream): - is_terminal = hasattr(stream, 'isatty') and stream.isatty() - stream = utils.get_output_stream(stream) + is_terminal = hasattr(stream, 'fileno') and os.isatty(stream.fileno()) + stream = codecs.getwriter('utf-8')(stream) all_events = [] lines = {} diff = 0 - for event in utils.json_stream(output): + for chunk in output: + event = json.loads(chunk) all_events.append(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 'progress' in event or 'progressDetail' in event: + image_id = event.get('id') + if not image_id: + continue - if not is_terminal: - continue + if image_id in lines: + diff = len(lines) - lines[image_id] + else: + lines[image_id] = len(lines) + stream.write("\n") + diff = 0 - # 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) - stream.write("\n") - - diff = len(lines) - lines[image_id] - - # move cursor up `diff` rows - stream.write("%c[%dA" % (27, diff)) + if is_terminal: + # move cursor up `diff` rows + stream.write("%c[%dA" % (27, diff)) print_output_event(event, stream, is_terminal) - if 'id' in event: + if 'id' in event and is_terminal: # move cursor back down stream.write("%c[%dB" % (27, diff)) @@ -62,6 +55,7 @@ def print_output_event(event, stream, is_terminal): # erase current line stream.write("%c[2K\r" % 27) terminator = "\r" + pass elif 'progressDetail' in event: return @@ -90,22 +84,3 @@ def print_output_event(event, stream, is_terminal): stream.write("%s%s" % (event['stream'], terminator)) else: stream.write("%s%s\n" % (status, terminator)) - - -def get_digest_from_pull(events): - for event in events: - status = event.get('status') - if not status or 'Digest' not in status: - continue - - _, digest = status.split(':', 1) - return digest.strip() - return None - - -def get_digest_from_push(events): - for event in events: - digest = event.get('aux', {}).get('Digest') - if digest: - return digest - return None diff --git a/compose/project.py b/compose/project.py index 133071e7..bc093628 100644 --- a/compose/project.py +++ b/compose/project.py @@ -1,128 +1,87 @@ -from __future__ import absolute_import from __future__ import unicode_literals - -import datetime +from __future__ import absolute_import import logging -import operator from functools import reduce -import enum from docker.errors import APIError -from . import parallel -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 IMAGE_EVENTS -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 .service import BuildAction -from .service import ContainerNetworkMode -from .service import ConvergenceStrategy -from .service import NetworkMode +from .config import get_service_name_from_net, ConfigurationError +from .const import LABEL_PROJECT, LABEL_SERVICE, LABEL_ONE_OFF from .service import Service -from .service import ServiceNetworkMode -from .utils import microseconds_from_time_nano -from .volume import ProjectVolumes - +from .container import Container +from .legacy import check_for_legacy_containers log = logging.getLogger(__name__) -@enum.unique -class OneOffFilter(enum.Enum): - include = 0 - exclude = 1 - only = 2 +def sort_service_dicts(services): + # Topological sort (Cormen/Tarjan algorithm). + unmarked = services[:] + temporary_marked = set() + sorted_services = [] - @classmethod - def update_labels(cls, value, labels): - if value == cls.only: - labels.append('{0}={1}'.format(LABEL_ONE_OFF, "True")) - elif value == cls.exclude: - labels.append('{0}={1}'.format(LABEL_ONE_OFF, "False")) - elif value == cls.include: - pass - else: - raise ValueError("Invalid value for one_off: {}".format(repr(value))) + def get_service_names(links): + return [link.split(':')[0] for link in links] + + 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 service.get('volumes_from', []) or + name == get_service_name_from_net(service.get('net'))) + ] + + 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']) + else: + raise DependencyError('Circular import 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 class Project(object): """ A collection of services. """ - def __init__(self, name, services, client, networks=None, volumes=None): + def __init__(self, name, services, client): self.name = name self.services = services self.client = client - self.volumes = volumes or ProjectVolumes({}) - self.networks = networks or ProjectNetworks({}, False) - def labels(self, one_off=OneOffFilter.exclude): - labels = ['{0}={1}'.format(LABEL_PROJECT, self.name)] - - OneOffFilter.update_labels(one_off, labels) - return labels + def labels(self, one_off=False): + return [ + '{0}={1}'.format(LABEL_PROJECT, self.name), + '{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False"), + ] @classmethod - def from_config(cls, name, config_data, client): + def from_dicts(cls, name, service_dicts, client): """ - Construct a Project from a config.Config object. + Construct a ServiceCollection from a list of dicts representing services. """ - 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) - - 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) + project = cls(name, [], client) + for service_dict in sort_service_dicts(service_dicts): links = project.get_links(service_dict) - network_mode = project.get_network_mode( - service_dict, list(service_networks.keys()) - ) - 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) - - 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, - **service_dict) - ) + volumes_from = project.get_volumes_from(service_dict) + net = project.get_net(service_dict) + project.services.append(Service(client=client, project=name, links=links, net=net, + volumes_from=volumes_from, **service_dict)) return project @property @@ -140,16 +99,6 @@ class Project(object): 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): """ Returns a list of this project's services filtered @@ -165,24 +114,20 @@ class Project(object): Raises NoSuchService if any of the named services do not exist. """ if service_names is None or len(service_names) == 0: - service_names = self.service_names + return self.get_services( + service_names=self.service_names, + include_deps=include_deps + ) + else: + unsorted = [self.get_service(name) for name in service_names] + services = [s for s in self.services if s in unsorted] - unsorted = [self.get_service(name) for name in service_names] - services = [s for s in self.services if s in unsorted] + if include_deps: + services = reduce(self._inject_deps, services, []) - if include_deps: - services = reduce(self._inject_deps, 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 + uniques = [] + [uniques.append(s) for s in services if s not in uniques] + return uniques def get_links(self, service_dict): links = [] @@ -195,318 +140,160 @@ class Project(object): 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)) + 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) + def get_volumes_from(self, service_dict): + volumes_from = [] + if 'volumes_from' in service_dict: + for volume_name in service_dict.get('volumes_from', []): + try: + service = self.get_service(volume_name) + volumes_from.append(service) + except NoSuchService: + try: + container = Container.from_id(self.client, volume_name) + volumes_from.append(container) + except APIError: + raise ConfigurationError('Service "%s" mounts volumes from "%s", which is not the name of a service or container.' % (service_dict['name'], volume_name)) + del service_dict['volumes_from'] + return volumes_from - service_name = get_service_name_from_network_mode(network_mode) - if service_name: - return ServiceNetworkMode(self.get_service(service_name)) + def get_net(self, service_dict): + if 'net' in service_dict: + net_name = get_service_name_from_net(service_dict.get('net')) - 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)) + if net_name: + try: + net = self.get_service(net_name) + except NoSuchService: + try: + net = Container.from_id(self.client, net_name) + except APIError: + raise ConfigurationError('Service "%s" is trying to use the network of "%s", which is not the name of a service or container.' % (service_dict['name'], net_name)) + else: + net = service_dict['net'] - return NetworkMode(network_mode) + del service_dict['net'] + + else: + net = 'bridge' + + return net def start(self, service_names=None, **options): - containers = [] + for service in self.get_services(service_names): + service.start(**options) - 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) - - 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 stop(self, service_names=None, **options): + for service in reversed(self.get_services(service_names)): + service.stop(**options) 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): - self.stop(one_off=OneOffFilter.include) - 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.get_services(): - service.remove_image(remove_image_type) + for service in reversed(self.get_services(service_names)): + service.kill(**options) def restart(self, service_names=None, **options): - containers = self.containers(service_names, stopped=True) + for service in self.get_services(service_names): + service.restart(**options) - 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): + def build(self, service_names=None, no_cache=False): for service in self.get_services(service_names): if service.can_be_built(): - service.build(no_cache, pull, force_rm) + service.build(no_cache) else: log.info('%s uses an image, skipping' % service.name) - 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 events(self, service_names=None): - 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 - ): - # The first part of this condition 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 or event['status'] in IMAGE_EVENTS: - # We don't receive any image events because labels aren't applied - # to images - continue - - # TODO: get labels from the API v1.22 , see github issue 2618 - try: - # this can fail if the container has been removed - 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 up(self, service_names=None, start_deps=True, - strategy=ConvergenceStrategy.changed, - do_build=BuildAction.none, - timeout=None, - detached=False, - remove_orphans=False): + allow_recreate=True, + smart_recreate=False, + insecure_registry=False, + do_build=True): - warn_for_swarm_mode(self.client) + services = self.get_services(service_names, include_deps=start_deps) - self.initialize() - self.find_orphan_containers(remove_orphans) - - services = self.get_services_without_duplicate( - service_names, - include_deps=start_deps) - - for svc in services: - svc.ensure_image_exists(do_build=do_build) - plans = self._get_convergence_plans(services, strategy) - - def do(service): - return service.execute_convergence_plan( - plans[service.name], - timeout=timeout, - detached=detached - ) - - def get_deps(service): - return { - (self.get_service(dep), config) - for dep, config in service.get_dependency_configs().items() - } - - results, errors = parallel.parallel_execute( + plans = self._get_convergence_plans( services, - do, - operator.attrgetter('name'), - None, - get_deps + allow_recreate=allow_recreate, + smart_recreate=smart_recreate, ) - 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 + for service in services + for container in service.execute_convergence_plan( + plans[service.name], + insecure_registry=insecure_registry, + do_build=do_build, + ) ] - def initialize(self): - self.networks.initialize() - self.volumes.initialize() + def _get_convergence_plans(self, + services, + allow_recreate=True, + smart_recreate=False): - def _get_convergence_plans(self, services, strategy): 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') + if name in plans + and plans[name].action == 'recreate' ] - if updated_dependencies and strategy.allows_recreate: - log.debug('%s has upstream changes (%s)', - service.name, - ", ".join(updated_dependencies)) - plan = service.convergence_plan(ConvergenceStrategy.always) + if updated_dependencies: + log.debug( + '%s has upstream changes (%s)', + service.name, ", ".join(updated_dependencies), + ) + plan = service.convergence_plan( + allow_recreate=allow_recreate, + smart_recreate=False, + ) else: - plan = service.convergence_plan(strategy) + plan = service.convergence_plan( + allow_recreate=allow_recreate, + smart_recreate=smart_recreate, + ) plans[service.name] = plan return plans - def pull(self, service_names=None, ignore_pull_failures=False): - for service in self.get_services(service_names, include_deps=False): - service.pull(ignore_pull_failures) + def pull(self, service_names=None, insecure_registry=False): + for service in self.get_services(service_names, include_deps=True): + service.pull(insecure_registry=insecure_registry) - def push(self, service_names=None, ignore_push_failures=False): - for service in self.get_services(service_names, include_deps=False): - service.push(ignore_push_failures) + def remove_stopped(self, service_names=None, **options): + for service in self.get_services(service_names): + service.remove_stopped(**options) - def _labeled_containers(self, stopped=False, one_off=OneOffFilter.exclude): - return list(filter(None, [ + def containers(self, service_names=None, stopped=False, one_off=False): + containers = [ Container.from_ps(self.client, container) for container in self.client.containers( all=stopped, - filters={'label': self.labels(one_off=one_off)})]) - ) - - 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) + filters={'label': self.labels(one_off=one_off)})] def matches_service_names(container): + if not service_names: + return True return container.labels.get(LABEL_SERVICE) in service_names - return [c for c in containers if matches_service_names(c)] + if not containers: + check_for_legacy_containers( + self.client, + self.name, + self.service_names, + stopped=stopped, + one_off=one_off) - def find_orphan_containers(self, remove_orphans): - def _find(): - containers = self._labeled_containers() - 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 "{0}"'.format(ctnr.name)) - ctnr.kill() - ctnr.remove(force=True) - else: - log.warning( - 'Found orphan containers ({0}) 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]) - ) - ) + return filter(matches_service_names, containers) def _inject_deps(self, acc, service): dep_names = service.get_dependency_names() @@ -522,85 +309,6 @@ class Project(object): dep_services.append(service) return acc + dep_services - def build_container_operation_with_timeout_func(self, operation, options): - def container_operation_with_timeout(container): - 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 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_name'): - log.warn("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.warn("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)) - - secrets.append({'secret': secret, 'file': secret_def.get('file')}) - - return secrets - - -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.warn( - "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" - ) - class NoSuchService(Exception): def __init__(self, name): @@ -611,6 +319,5 @@ class NoSuchService(Exception): return self.msg -class ProjectError(Exception): - def __init__(self, msg): - self.msg = msg +class DependencyError(ConfigurationError): + pass diff --git a/compose/service.py b/compose/service.py index 9f2fc68b..1e91a9f2 100644 --- a/compose/service.py +++ b/compose/service.py @@ -1,46 +1,29 @@ -from __future__ import absolute_import from __future__ import unicode_literals - +from __future__ import absolute_import +from collections import namedtuple import logging import re import sys -from collections import namedtuple from operator import attrgetter -import enum import six from docker.errors import APIError -from docker.errors import ImageNotFound -from docker.errors import NotFound -from docker.types import LogConfig -from docker.utils.ports import build_port_bindings -from docker.utils.ports import split_port +from docker.utils import create_host_config, LogConfig from . import __version__ -from . import const -from . import progress_stream -from .config import DOCKER_CONFIG_KEYS -from .config import merge_environment -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_VERSION +from .config import DOCKER_CONFIG_KEYS, merge_environment +from .const import ( + LABEL_CONTAINER_NUMBER, + LABEL_ONE_OFF, + LABEL_PROJECT, + LABEL_SERVICE, + LABEL_VERSION, + LABEL_CONFIG_HASH, +) from .container import Container -from .errors import HealthCheckFailed -from .errors import NoHealthCheckConfigured -from .errors import OperationFailedError -from .parallel import parallel_execute -from .parallel import parallel_start -from .progress_stream import stream_output -from .progress_stream import StreamOutputError +from .legacy import check_for_legacy_containers +from .progress_stream import stream_output, StreamOutputError from .utils import json_hash -from .utils import parse_seconds_float - log = logging.getLogger(__name__) @@ -48,34 +31,22 @@ log = logging.getLogger(__name__) DOCKER_START_KEYS = [ 'cap_add', 'cap_drop', - 'cgroup_parent', - 'cpu_quota', 'devices', 'dns', 'dns_search', 'env_file', 'extra_hosts', - 'group_add', - 'ipc', 'read_only', + 'net', 'log_driver', - 'log_opt', - 'mem_limit', - 'memswap_limit', - 'oom_score_adj', - 'mem_swappiness', 'pid', 'privileged', 'restart', - 'security_opt', - 'shm_size', - 'sysctls', - 'userns_mode', 'volumes_from', + 'security_opt', ] -CONDITION_STARTED = 'service_started' -CONDITION_HEALTHY = 'service_healthy' +VALID_NAME_CHARS = '[a-zA-Z0-9]' class BuildError(Exception): @@ -84,13 +55,16 @@ class BuildError(Exception): self.reason = reason +class ConfigError(ValueError): + pass + + class NeedsBuildError(Exception): def __init__(self, service): self.service = service -class NoSuchImageError(Exception): - pass +VolumeSpec = namedtuple('VolumeSpec', 'external internal mode') ServiceName = namedtuple('ServiceName', 'project service number') @@ -99,72 +73,42 @@ 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(object): - def __init__( - self, - name, - client=None, - project='default', - use_networking=False, - links=None, - volumes_from=None, - network_mode=None, - networks=None, - secrets=None, - **options - ): + def __init__(self, name, client=None, project='default', links=None, external_links=None, volumes_from=None, net=None, **options): + if not re.match('^%s+$' % VALID_NAME_CHARS, name): + raise ConfigError('Invalid service name "%s" - only %s are allowed' % (name, VALID_NAME_CHARS)) + if not re.match('^%s+$' % VALID_NAME_CHARS, project): + raise ConfigError('Invalid project name "%s" - only %s are allowed' % (project, VALID_NAME_CHARS)) + if 'image' in options and 'build' in options: + raise ConfigError('Service %s has both an image and build path specified. A service can either be built to image or use an existing image, not both.' % name) + if 'image' not in options and 'build' not in options: + raise ConfigError('Service %s has neither an image nor a build path specified. Exactly one must be provided.' % name) + self.name = name self.client = client self.project = project - self.use_networking = use_networking self.links = links or [] + self.external_links = external_links or [] self.volumes_from = volumes_from or [] - self.network_mode = network_mode or NetworkMode(None) - self.networks = networks or {} - self.secrets = secrets or [] + self.net = net or None self.options = options - def __repr__(self): - return ''.format(self.name) - - def containers(self, stopped=False, one_off=False, filters={}): - filters.update({'label': self.labels(one_off=one_off)}) - - return list(filter(None, [ + def containers(self, stopped=False, one_off=False): + containers = [ Container.from_ps(self.client, container) for container in self.client.containers( all=stopped, - filters=filters)])) + filters={'label': self.labels(one_off=one_off)})] + + if not containers: + check_for_legacy_containers( + self.client, + self.project, + [self.name], + stopped=stopped, + one_off=one_off) + + return containers def get_container(self, number=1): """Return a :class:`compose.container.Container` for this service. The @@ -177,12 +121,25 @@ class Service(object): raise ValueError("No container found for %s_%s" % (self.name, number)) def start(self, **options): - containers = self.containers(stopped=True) - for c in containers: + for c in self.containers(stopped=True): self.start_container_if_stopped(c, **options) - return containers - def scale(self, desired_num, timeout=None): + def stop(self, **options): + for c in self.containers(): + log.info("Stopping %s..." % c.name) + c.stop(**options) + + def kill(self, **options): + for c in self.containers(): + log.info("Killing %s..." % c.name) + c.kill(**options) + + def restart(self, **options): + for c in self.containers(): + log.info("Restarting %s..." % c.name) + c.restart(**options) + + def scale(self, desired_num): """ Adjusts the number of containers to the specified number and ensures they are running. @@ -192,87 +149,52 @@ class Service(object): - starts containers until there are at least `desired_num` running - removes all stopped containers """ - if self.custom_container_name and desired_num > 1: - log.warn('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.warn('The "%s" service specifies a port on the host. If multiple containers ' + if not self.can_be_scaled(): + log.warn('Service %s 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 create_and_start(service, number): - container = service.create_container(number=number, quiet=True) - service.start_container(container) - return container + # Create enough containers + containers = self.containers(stopped=True) + while len(containers) < desired_num: + containers.append(self.create_container()) - def stop_and_remove(container): - container.stop(timeout=self.stop_timeout(timeout)) - container.remove() + running_containers = [] + stopped_containers = [] + for c in containers: + if c.is_running: + running_containers.append(c) + else: + stopped_containers.append(c) + running_containers.sort(key=lambda c: c.number) + stopped_containers.sort(key=lambda c: c.number) - running_containers = self.containers(stopped=False) - num_running = len(running_containers) + # Stop containers + while len(running_containers) > desired_num: + c = running_containers.pop() + log.info("Stopping %s..." % c.name) + c.stop(timeout=1) + stopped_containers.append(c) - if desired_num == num_running: - # do nothing as we already have the desired number - log.info('Desired container number already achieved') - return + # Start containers + while len(running_containers) < desired_num: + c = stopped_containers.pop(0) + log.info("Starting %s..." % c.name) + self.start_container(c) + running_containers.append(c) - if desired_num > num_running: - # we need to start/create until we have desired_num - all_containers = self.containers(stopped=True) + self.remove_stopped() - if num_running != len(all_containers): - # we have some stopped containers, let's start them up again - stopped_containers = sorted( - (c for c in all_containers if not c.is_running), - key=attrgetter('number')) - - num_stopped = len(stopped_containers) - - if num_stopped + num_running > desired_num: - num_to_start = desired_num - num_running - containers_to_start = stopped_containers[:num_to_start] - else: - containers_to_start = stopped_containers - - parallel_start(containers_to_start, {}) - - num_running += len(containers_to_start) - - num_to_create = desired_num - num_running - next_number = self._next_container_number() - container_numbers = [ - number for number in range( - next_number, next_number + num_to_create - ) - ] - - parallel_execute( - container_numbers, - lambda n: create_and_start(service=self, number=n), - lambda n: self.get_container_name(n), - "Creating and starting" - ) - - if desired_num < num_running: - num_to_stop = num_running - desired_num - - sorted_running_containers = sorted( - running_containers, - key=attrgetter('number')) - - parallel_execute( - sorted_running_containers[-num_to_stop:], - stop_and_remove, - lambda c: c.name, - "Stopping and removing", - ) + def remove_stopped(self, **options): + for c in self.containers(stopped=True): + if not c.is_running: + log.info("Removing %s..." % c.name) + c.remove(**options) def create_container(self, one_off=False, + insecure_registry=False, + do_build=True, previous_container=None, number=None, quiet=False, @@ -281,9 +203,10 @@ class Service(object): 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() + self.ensure_image_exists( + do_build=do_build, + insecure_registry=insecure_registry, + ) container_options = self._get_container_create_options( override_options, @@ -293,82 +216,85 @@ class Service(object): ) if 'name' in container_options and not quiet: - log.info("Creating %s" % container_options['name']) + 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, ex.explanation)) + return Container.create(self.client, **container_options) - def ensure_image_exists(self, do_build=BuildAction.none): - if self.can_be_built() and do_build == BuildAction.force: - self.build() + def ensure_image_exists(self, + do_build=True, + insecure_registry=False): + + if self.image(): return - try: - self.image() - return - except NoSuchImageError: - pass - - if not self.can_be_built(): - self.pull() - return - - if do_build == BuildAction.skip: - raise NeedsBuildError(self) - - self.build() - log.warn( - "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)) + if self.can_be_built(): + if do_build: + self.build() + else: + raise NeedsBuildError(self) + else: + self.pull(insecure_registry=insecure_registry) def image(self): try: return self.client.inspect_image(self.image_name) - except ImageNotFound: - raise NoSuchImageError("Image '{}' not found".format(self.image_name)) + except APIError as e: + if e.response.status_code == 404 and e.explanation and 'No such image' in str(e.explanation): + return None + else: + raise @property def image_name(self): - return self.options.get('image', '{s.project}_{s.name}'.format(s=self)) + if self.can_be_built(): + return self.full_name + else: + return self.options['image'] + + def converge(self, + allow_recreate=True, + smart_recreate=False, + insecure_registry=False, + do_build=True): + """ + If a container for this service doesn't exist, create and start one. If there are + any, stop them, create+start new ones, and remove the old containers. + """ + plan = self.convergence_plan( + allow_recreate=allow_recreate, + smart_recreate=smart_recreate, + ) + + return self.execute_convergence_plan( + plan, + insecure_registry=insecure_registry, + do_build=do_build, + ) + + def convergence_plan(self, + allow_recreate=True, + smart_recreate=False): - def convergence_plan(self, strategy=ConvergenceStrategy.changed): containers = self.containers(stopped=True) if not containers: return ConvergencePlan('create', []) - if strategy is ConvergenceStrategy.never: + if smart_recreate and not self._containers_have_diverged(containers): + stopped = [c for c in containers if not c.is_running] + + if stopped: + return ConvergencePlan('start', stopped) + + return ConvergencePlan('noop', containers) + + if not allow_recreate: 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', stopped) - - return ConvergencePlan('noop', containers) + return ConvergencePlan('recreate', 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, six.text_type(e), - ) - return True - + config_hash = self.config_hash() has_diverged = False for c in containers: @@ -384,38 +310,31 @@ class Service(object): def execute_convergence_plan(self, plan, - timeout=None, - detached=False, - start=True): + insecure_registry=False, + do_build=True): (action, containers) = plan - should_attach_logs = not detached if action == 'create': - container = self.create_container() - - if should_attach_logs: - container.attach_log_stream() - - if start: - self.start_container(container) + container = self.create_container( + insecure_registry=insecure_registry, + do_build=do_build, + ) + self.start_container(container) return [container] elif action == 'recreate': return [ self.recreate_container( - container, - timeout=timeout, - attach_logs=should_attach_logs, - start_new_container=start + c, + insecure_registry=insecure_registry, ) - for container in containers + for c in containers ] elif action == 'start': - if start: - for container in containers: - self.start_container_if_stopped(container, attach_logs=should_attach_logs) + for c in containers: + self.start_container_if_stopped(c) return containers @@ -428,100 +347,53 @@ class Service(object): else: raise Exception("Invalid action: {}".format(action)) - def recreate_container( - self, - container, - timeout=None, - attach_logs=False, - start_new_container=True): + def recreate_container(self, + container, + insecure_registry=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. """ - log.info("Recreating %s" % container.name) + log.info("Recreating %s..." % container.name) + try: + container.stop() + except APIError as e: + if (e.response.status_code == 500 + and e.explanation + and 'no such process' in str(e.explanation)): + pass + else: + raise + + # Use a hopefully unique container name by prepending the short id + self.client.rename( + container.id, + '%s_%s' % (container.short_id, container.name)) - container.stop(timeout=self.stop_timeout(timeout)) - container.rename_to_tmp_name() new_container = self.create_container( + insecure_registry=insecure_registry, + do_build=False, previous_container=container, number=container.labels.get(LABEL_CONTAINER_NUMBER), quiet=True, ) - if attach_logs: - new_container.attach_log_stream() - if start_new_container: - self.start_container(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() + def start_container_if_stopped(self, container): + if container.is_running: + return container + else: + log.info("Starting %s..." % container.name) return self.start_container(container) def start_container(self, container): - self.connect_container_to_networks(container) - try: - container.start() - except APIError as ex: - raise OperationFailedError("Cannot start service %s: %s" % (self.name, ex.explanation)) + container.start() return container - def connect_container_to_networks(self, container): - connected_networks = container.get('NetworkSettings.Networks') - - for network, netdefs in self.networks.items(): - if network in connected_networks: - if short_id_alias_exists(container, network): - continue - - self.client.disconnect_container_from_network( - container.id, - network) - - self.client.connect_container_to_network( - container.id, network, - aliases=self._get_aliases(netdefs, container), - 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()) @@ -529,127 +401,94 @@ class Service(object): return { 'options': self.options, 'image_id': self.image()['Id'], - 'links': self.get_link_names(), - 'net': self.network_mode.id, - 'networks': self.networks, - '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 - return ( - self.get_linked_service_names() + - self.get_volumes_from_names() + - ([net_name] if net_name else []) + - list(self.options.get('depends_on', {}).keys()) - ) + net_name = self.get_net_name() + return (self.get_linked_names() + + self.get_volumes_from_names() + + ([net_name] if net_name else [])) - def get_dependency_configs(self): - net_name = self.network_mode.service_name - configs = dict( - [(name, None) for name in self.get_linked_service_names()] - ) - configs.update(dict( - [(name, None) for name in self.get_volumes_from_names()] - )) - configs.update({net_name: None} if net_name 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() - 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_linked_names(self): + return [s.name for (s, _) in self.links] def get_volumes_from_names(self): - return [s.source.name for s in self.volumes_from if isinstance(s.source, Service)] + return [s.name for s in self.volumes_from if isinstance(s, Service)] - # TODO: this would benefit from github.com/docker/docker/pull/14699 + def get_net_name(self): + if isinstance(self.net, Service): + return self.net.name + else: + return + + def get_container_name(self, number, one_off=False): + # TODO: Implement issue #652 here + return build_container_name(self.project, self.name, number, one_off) + + # TODO: this would benefit from github.com/docker/docker/pull/11943 # to remove the need to inspect every container def _next_container_number(self, one_off=False): - containers = filter(None, [ - Container.from_ps(self.client, container) + numbers = [ + Container.from_ps(self.client, container).number for container in self.client.containers( all=True, filters={'label': self.labels(one_off=one_off)}) - ]) - numbers = [c.number for c in containers] + ] return 1 if not numbers else max(numbers) + 1 - def _get_aliases(self, network, container=None): - if container and container.labels.get(LABEL_ONE_OFF) == "True": - return [] - - 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 = {} - + 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 - + links.append((container.name, link_name or service.name)) + links.append((container.name, container.name)) + links.append((container.name, container.name_without_project)) 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 []: + links.append((container.name, self.name)) + links.append((container.name, container.name)) + links.append((container.name, container.name_without_project)) + for external_link in self.external_links: 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() - ] + links.append((external_link, link_name)) + return links def _get_volumes_from(self): - return [build_volume_from(spec) for spec in self.volumes_from] + volumes_from = [] + for volume_source in self.volumes_from: + if isinstance(volume_source, Service): + containers = volume_source.containers(stopped=True) + if not containers: + volumes_from.append(volume_source.create_container().id) + else: + volumes_from.extend(map(attrgetter('id'), containers)) + + elif isinstance(volume_source, Container): + volumes_from.append(volume_source.id) + + return volumes_from + + def _get_net(self): + if not self.net: + return "bridge" + + if isinstance(self.net, Service): + containers = self.net.containers() + if len(containers) > 0: + net = 'container:' + containers[0].id + else: + log.warning("Warning: Service %s is trying to use reuse the network stack " + "of another service that is not running." % (self.net.name)) + net = None + elif isinstance(self.net, Container): + net = 'container:' + self.net.id + else: + net = self.net + + return net def _get_container_create_options( self, @@ -657,6 +496,7 @@ class Service(object): number, one_off=False, previous_container=None): + add_config_hash = (not one_off and not override_options) container_options = dict( @@ -664,53 +504,63 @@ class Service(object): for k in DOCKER_CONFIG_KEYS if k in self.options) container_options.update(override_options) - if not container_options.get('name'): - container_options['name'] = self.get_container_name(number, one_off) + container_options['name'] = self.get_container_name(number, one_off) - container_options.setdefault('detach', True) + if add_config_hash: + config_hash = self.config_hash() + if 'labels' not in container_options: + container_options['labels'] = {} + container_options['labels'][LABEL_CONFIG_HASH] = config_hash + log.debug("Added config hash: %s" % config_hash) + + if 'detach' not in container_options: + container_options['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 the behavior of # the official Docker CLI in that scenario. - if ('hostname' in container_options and - 'domainname' not in container_options and - '.' in container_options['hostname']): + if ('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 'ports' in container_options or 'expose' in self.options: - container_options['ports'] = build_container_ports( - container_options, - self.options) + ports = [] + all_ports = container_options.get('ports', []) + self.options.get('expose', []) + for port in all_ports: + port = str(port) + if ':' in port: + port = port.split(':')[-1] + if '/' in port: + port = tuple(port.split('/')) + ports.append(port) + container_options['ports'] = ports + + override_options['binds'] = merge_volume_bindings( + container_options.get('volumes') or [], + previous_container) + + if 'volumes' in container_options: + container_options['volumes'] = dict( + (parse_volume_spec(v).internal, {}) + for v in container_options['volumes']) container_options['environment'] = merge_environment( self.options.get('environment'), override_options.get('environment')) - binds, affinity = merge_volume_bindings( - container_options.get('volumes') or [], - previous_container) - override_options['binds'] = binds - container_options['environment'].update(affinity) - - container_options['volumes'] = dict( - (v.internal, {}) for v in container_options.get('volumes') or {}) - - secret_volumes = self.get_secret_volumes() - if secret_volumes: - override_options['binds'].extend(v.repr() for v in secret_volumes) - container_options['volumes'].update( - (v.internal, {}) for v in secret_volumes) + if previous_container: + container_options['environment']['affinity:container'] = ('=' + previous_container.id) container_options['image'] = self.image_name container_options['labels'] = build_container_labels( container_options.get('labels', {}), self.labels(one_off=one_off), - number, - self.config_hash if add_config_hash else None) + number) # Delete options which are only used when starting for key in DOCKER_START_KEYS: @@ -720,94 +570,72 @@ class Service(object): 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 _get_container_host_config(self, override_options, one_off=False): options = dict(self.options, **override_options) + port_bindings = build_port_bindings(options.get('ports') or []) - logging_dict = options.get('logging', None) - log_config = get_log_config(logging_dict) + privileged = options.get('privileged', False) + cap_add = options.get('cap_add', None) + cap_drop = options.get('cap_drop', None) + log_config = LogConfig(type=options.get('log_driver', 'json-file')) + pid = options.get('pid', None) + security_opt = options.get('security_opt', None) - host_config = self.client.create_host_config( + dns = options.get('dns', None) + if isinstance(dns, six.string_types): + dns = [dns] + + dns_search = options.get('dns_search', None) + if isinstance(dns_search, six.string_types): + dns_search = [dns_search] + + restart = parse_restart_spec(options.get('restart', None)) + + extra_hosts = build_extra_hosts(options.get('extra_hosts', None)) + read_only = options.get('read_only', None) + + devices = options.get('devices', None) + + return create_host_config( links=self._get_links(link_to_self=one_off), - port_bindings=build_port_bindings(options.get('ports') or []), + port_bindings=port_bindings, 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'), - dns=options.get('dns'), - dns_search=options.get('dns_search'), - restart_policy=options.get('restart'), - cap_add=options.get('cap_add'), - cap_drop=options.get('cap_drop'), - mem_limit=options.get('mem_limit'), - memswap_limit=options.get('memswap_limit'), - ulimits=build_ulimits(options.get('ulimits')), + privileged=privileged, + network_mode=self._get_net(), + devices=devices, + dns=dns, + dns_search=dns_search, + restart_policy=restart, + cap_add=cap_add, + cap_drop=cap_drop, log_config=log_config, - extra_hosts=options.get('extra_hosts'), - read_only=options.get('read_only'), - pid_mode=options.get('pid'), - security_opt=options.get('security_opt'), - ipc_mode=options.get('ipc'), - cgroup_parent=options.get('cgroup_parent'), - cpu_quota=options.get('cpu_quota'), - shm_size=options.get('shm_size'), - sysctls=options.get('sysctls'), - tmpfs=options.get('tmpfs'), - 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') + extra_hosts=extra_hosts, + read_only=read_only, + pid_mode=pid, + security_opt=security_opt ) - # TODO: Add as an argument to create_host_config once it's supported - # in docker-py - host_config['Isolation'] = options.get('isolation') + def build(self, no_cache=False): + log.info('Building %s...' % self.name) - return host_config - - def get_secret_volumes(self): - def build_spec(secret): - target = '{}/{}'.format( - const.SECRETS_PATH, - secret['secret'].target or secret['secret'].source) - return VolumeSpec(secret['file'], target, 'ro') - - return [build_spec(secret) for secret in self.secrets] - - def build(self, no_cache=False, pull=False, force_rm=False): - log.info('Building %s' % self.name) - - build_opts = self.options.get('build', {}) - path = build_opts.get('context') - # python2 os.stat() doesn't support unicode on some UNIX, so we - # encode it to a bytestring to be safe - if not six.PY3 and not IS_WINDOWS_PLATFORM: - path = path.encode('utf8') + path = six.binary_type(self.options['build']) build_output = self.client.build( path=path, tag=self.image_name, stream=True, rm=True, - forcerm=force_rm, - pull=pull, nocache=no_cache, - dockerfile=build_opts.get('dockerfile', None), - buildargs=build_opts.get('args', None), + dockerfile=self.options.get('dockerfile', None), ) try: all_events = stream_output(build_output, sys.stdout) except StreamOutputError as e: - raise BuildError(self, six.text_type(e)) + raise BuildError(self, unicode(e)) # Ensure the HTTP connection is not reused for another # streaming command, as the Docker daemon can sometimes @@ -830,6 +658,13 @@ class Service(object): def can_be_built(self): return 'build' in self.options + @property + def full_name(self): + """ + The tag to give to images built for this service. + """ + return '%s_%s' % (self.project, self.name) + def labels(self, one_off=False): return [ '{0}={1}'.format(LABEL_PROJECT, self.project), @@ -837,167 +672,25 @@ class Service(object): '{0}={1}'.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, number, one_off=False): - if self.custom_container_name and not one_off: - return self.custom_container_name - - return build_container_name(self.project, self.name, number, one_off) - - 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 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): - _, external_bindings = split_port(binding) - - # there are no external bindings - if external_bindings is None: + def can_be_scaled(self): + for port in self.options.get('ports', []): + if ':' in str(port): return False + return True - # 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 pull(self, ignore_pull_failures=False): + def pull(self, insecure_registry=False): if 'image' not in self.options: return - repo, tag, separator = parse_repository_tag(self.options['image']) + repo, tag = parse_repository_tag(self.options['image']) tag = tag or 'latest' - log.info('Pulling %s (%s%s%s)...' % (self.name, repo, separator, tag)) - try: - output = self.client.pull(repo, tag=tag, stream=True) - return progress_stream.get_digest_from_pull( - stream_output(output, sys.stdout)) - except (StreamOutputError, NotFound) as e: - if not ignore_pull_failures: - raise - else: - log.error(six.text_type(e)) - - 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 %s (%s%s%s)...' % (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(six.text_type(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 short_id_alias_exists(container, network): - aliases = container.get( - 'NetworkSettings.Networks.{net}.Aliases'.format(net=network)) or () - return container.short_id in aliases - - -class NetworkMode(object): - """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(object): - """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(object): - """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.warn("Service %s is trying to use reuse the network stack " - "of another service that is not running." % (self.id)) - return None + log.info('Pulling %s (%s:%s)...' % (self.name, repo, tag)) + output = self.client.pull( + repo, + tag=tag, + stream=True, + insecure_registry=insecure_registry) + stream_output(output, sys.stdout) # Names @@ -1012,57 +705,33 @@ def build_container_name(project, service, number, one_off=False): # 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 +def parse_repository_tag(s): + if ":" not in s: + return s, "" + repo, tag = s.rsplit(":", 1) + if "/" in tag: + return s, "" + return repo, tag # Volumes -def merge_volume_bindings(volumes, previous_container): +def merge_volume_bindings(volumes_option, previous_container): """Return a list of volume bindings for a container. Container data volumes are replaced by those from the previous container. """ - affinity = {} - volume_bindings = dict( - build_volume_binding(volume) - for volume in volumes - if volume.external) + build_volume_binding(parse_volume_spec(volume)) + for volume in volumes_option or [] + if ':' in volume) if previous_container: - old_volumes = get_container_data_volumes(previous_container, volumes) - warn_on_masked_volume(volumes, old_volumes, previous_container.service) volume_bindings.update( - build_volume_binding(volume) for volume in old_volumes) + get_container_data_volumes(previous_container, volumes_option)) - if old_volumes: - affinity = {'affinity:container': '=' + previous_container.id} - - return list(volume_bindings.values()), affinity + return volume_bindings.values() def get_container_data_volumes(container, volumes_option): @@ -1070,149 +739,137 @@ def get_container_data_volumes(container, volumes_option): a mapping of volume bindings for those volumes. """ volumes = [] + volumes_option = volumes_option or [] + container_volumes = container.get('Volumes') or {} + image_volumes = container.image_config['ContainerConfig'].get('Volumes') or {} - container_mounts = dict( - (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): + for volume in set(volumes_option + image_volumes.keys()): + volume = parse_volume_spec(volume) # No need to preserve host volumes if volume.external: continue - mount = container_mounts.get(volume.internal) - + volume_path = container_volumes.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'): + if not volume_path: continue # Copy existing volume from old container - volume = volume._replace(external=mount['Name']) - volumes.append(volume) + volume = volume._replace(external=volume_path) + volumes.append(build_volume_binding(volume)) - return volumes - - -def warn_on_masked_volume(volumes_option, container_volumes, service): - container_volumes = dict( - (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.warn(( - "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)) + return dict(volumes) def build_volume_binding(volume_spec): - return volume_spec.internal, volume_spec.repr() + return volume_spec.internal, "{}:{}:{}".format(*volume_spec) -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) +def parse_volume_spec(volume_config): + parts = volume_config.split(':') + if len(parts) > 3: + raise ConfigError("Volume %s has incorrect format, should be " + "external:internal[:mode]" % volume_config) - 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) + if len(parts) == 1: + return VolumeSpec(None, parts[0], 'rw') + + if len(parts) == 2: + parts.append('rw') + + external, internal, mode = parts + if mode not in ('rw', 'ro'): + raise ConfigError("Volume %s has invalid mode (%s), should be " + "one of: rw, ro." % (volume_config, mode)) + + return VolumeSpec(external, internal, mode) + + +# Ports + + +def build_port_bindings(ports): + port_bindings = {} + for port in ports: + internal_port, external = split_port(port) + if internal_port in port_bindings: + port_bindings[internal_port].append(external) + else: + port_bindings[internal_port] = [external] + return port_bindings + + +def split_port(port): + parts = str(port).split(':') + if not 1 <= len(parts) <= 3: + raise ConfigError('Invalid port "%s", should be ' + '[[remote_ip:]remote_port:]port[/protocol]' % port) + + if len(parts) == 1: + internal_port, = parts + return internal_port, None + if len(parts) == 2: + external_port, internal_port = parts + return internal_port, external_port + + external_ip, external_port, internal_port = parts + return internal_port, (external_ip, external_port or None) # Labels -def build_container_labels(label_options, service_labels, number, config_hash): - labels = dict(label_options or {}) +def build_container_labels(label_options, service_labels, number, one_off=False): + labels = label_options or {} labels.update(label.split('=', 1) for label in service_labels) labels[LABEL_CONTAINER_NUMBER] = str(number) labels[LABEL_VERSION] = __version__ - - if config_hash: - log.debug("Added config hash: %s" % config_hash) - labels[LABEL_CONFIG_HASH] = config_hash - return labels -# Ulimits +# Restart policy -def build_ulimits(ulimit_config): - if not ulimit_config: +def parse_restart_spec(restart_config): + if not restart_config: return None - ulimits = [] - for limit_name, soft_hard_values in six.iteritems(ulimit_config): - if isinstance(soft_hard_values, six.integer_types): - 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) + parts = restart_config.split(':') + if len(parts) > 2: + raise ConfigError("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 ulimits + return {'Name': name, 'MaximumRetryCount': int(max_retry_count)} -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 +# Extra hosts + + +def build_extra_hosts(extra_hosts_config): + if not extra_hosts_config: + return {} + + if isinstance(extra_hosts_config, list): + extra_hosts_dict = {} + for extra_hosts_line in extra_hosts_config: + if not isinstance(extra_hosts_line, six.string_types): + raise ConfigError( + "extra_hosts_config \"%s\" must be either a list of strings or a string->string mapping," % + extra_hosts_config + ) + host, ip = extra_hosts_line.split(':') + extra_hosts_dict.update({host.strip(): ip.strip()}) + extra_hosts_config = extra_hosts_dict + + if isinstance(extra_hosts_config, dict): + return extra_hosts_config + + raise ConfigError( + "extra_hosts_config \"%s\" must be either a list of strings or a string->string mapping," % + extra_hosts_config ) - - -# 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, six.binary_type): - value = value.decode('utf-8') - return '{key}={value}'.format(key=key, value=value) - return [format_env(*item) for item in environment.items()] - -# Ports - - -def build_container_ports(container_options, options): - ports = [] - all_ports = container_options.get('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 diff --git a/compose/timeparse.py b/compose/timeparse.py deleted file mode 100644 index 16ef8a6d..00000000 --- a/compose/timeparse.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -''' -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. -from __future__ import absolute_import -from __future__ import unicode_literals - -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 = dict([ - ('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, 10) if value.isdigit() else float(value) diff --git a/compose/utils.py b/compose/utils.py index b8bdf732..76a4c6b9 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -1,135 +1,9 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import codecs -import hashlib import json -import json.decoder -import logging -import ntpath - -import six - -from .errors import StreamParseError -from .timeparse import timeparse - - -json_decoder = json.JSONDecoder() -log = logging.getLogger(__name__) - - -def get_output_stream(stream): - if six.PY3: - return stream - return codecs.getwriter('utf-8')(stream) - - -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, six.text_type): - data = data.decode('utf-8', 'replace') - yield data - - -def line_splitter(buffer, separator=u'\n'): - index = buffer.find(six.text_type(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 = six.text_type('') - - 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) +import hashlib def json_hash(obj): dump = json.dumps(obj, sort_keys=True, separators=(',', ':')) h = hashlib.sha256() - h.update(dump.encode('utf8')) + h.update(dump) return h.hexdigest() - - -def microseconds_from_time_nano(time_nano): - return int(time_nano % 1000000000 / 1000) - - -def nanoseconds_from_time_seconds(time_seconds): - return time_seconds * 1000000000 - - -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 int(parsed * 1000000000) - - -def build_string_dict(source_dict): - return dict((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) diff --git a/compose/volume.py b/compose/volume.py deleted file mode 100644 index ab6a88fa..00000000 --- a/compose/volume.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import logging - -from docker.errors import NotFound -from docker.utils import version_lt - -from .config import ConfigurationError -from .const import LABEL_PROJECT -from .const import LABEL_VOLUME - -log = logging.getLogger(__name__) - - -class Volume(object): - def __init__(self, client, project, name, driver=None, driver_opts=None, - external_name=None, labels=None): - self.client = client - self.project = project - self.name = name - self.driver = driver - self.driver_opts = driver_opts - self.external_name = external_name - self.labels = labels - - 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.full_name) - return - log.info("Removing volume %s", self.full_name) - return self.client.remove_volume(self.full_name) - - def inspect(self): - return self.client.inspect_volume(self.full_name) - - def exists(self): - try: - self.inspect() - except NotFound: - return False - return True - - @property - def external(self): - return bool(self.external_name) - - @property - def full_name(self): - if self.external_name: - return self.external_name - return '{0}_{1}'.format(self.project, self.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, - }) - return labels - - -class ProjectVolumes(object): - - 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=vol_name, - driver=data.get('driver'), - driver_opts=data.get('driver_opts'), - external_name=data.get('external_name'), - labels=data.get('labels') - ) - 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.warn("Volume %s not found.", volume.full_name) - - def initialize(self): - try: - for volume in self.volumes.values(): - volume_exists = volume.exists() - if volume.external: - log.debug( - 'Volume {0} 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 "{0}" with {1} driver'.format( - volume.full_name, volume.driver or 'default' - ) - ) - volume.create() - else: - driver = volume.inspect()['Driver'] - if volume.driver is not None and driver != volume.driver: - raise ConfigurationError( - 'Configuration for volume {0} specifies driver ' - '{1}, but a volume with the same name uses a ' - 'different driver ({3}). If you wish to use the ' - 'new configuration, please remove the existing ' - 'volume "{2}" first:\n' - '$ docker volume rm {2}'.format( - volume.name, volume.driver, volume.full_name, - volume.inspect()['Driver'] - ) - ) - except NotFound: - raise ConfigurationError( - 'Volume %s specifies nonexistent driver %s' % (volume.name, volume.driver) - ) - - def namespace_spec(self, volume_spec): - if not volume_spec.is_named_volume: - return volume_spec - - volume = self.volumes[volume_spec.external] - return volume_spec._replace(external=volume.full_name) diff --git a/contrib/completion/bash/docker-compose b/contrib/completion/bash/docker-compose index 77d02b42..ba3dff35 100644 --- a/contrib/completion/bash/docker-compose +++ b/contrib/completion/bash/docker-compose @@ -17,145 +17,94 @@ # . ~/.docker-compose-completion.sh -__docker_compose_q() { - docker-compose 2>/dev/null $daemon_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)" -} - -# suppress trailing whitespace -__docker_compose_nospace() { - # compopt is not available in ancient bash versions - type compopt &>/dev/null && compopt -o nospace +# For compatibility reasons, Compose and therefore its completion supports several +# stack compositon files as listed here, in descending priority. +# Support for these filenames might be dropped in some future version. +__docker-compose_compose_file() { + local file + for file in docker-compose.y{,a}ml fig.y{,a}ml ; do + [ -e $file ] && { + echo $file + return + } + done + echo docker-compose.yml } # Extracts all service names from the compose file. -___docker_compose_all_services_in_compose_file() { - __docker_compose_q config --services +___docker-compose_all_services_in_compose_file() { + awk -F: '/^[a-zA-Z0-9]/{print $1}' "${compose_file:-$(__docker-compose_compose_file)}" 2>/dev/null } # All services, even those without an existing container -__docker_compose_services_all() { - COMPREPLY=( $(compgen -W "$(___docker_compose_all_services_in_compose_file)" -- "$cur") ) +__docker-compose_services_all() { + COMPREPLY=( $(compgen -W "$(___docker-compose_all_services_in_compose_file)" -- "$cur") ) } # All services that have an entry with the given key in their compose_file section -___docker_compose_services_with_key() { - # flatten sections under "services" to one line, then filter lines containing the key and return section name - __docker_compose_q config \ - | sed -n -e '/^services:/,/^[^ ]/p' \ - | sed -n 's/^ //p' \ - | awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' \ - | awk -F: -v key=": +$1:" '$0 ~ key {print $1}' +___docker-compose_services_with_key() { + # flatten sections to one line, then filter lines containing the key and return section name. + awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' "${compose_file:-$(__docker-compose_compose_file)}" | awk -F: -v key=": +$1:" '$0 ~ key {print $1}' } # All services that are defined by a Dockerfile reference -__docker_compose_services_from_build() { - COMPREPLY=( $(compgen -W "$(___docker_compose_services_with_key build)" -- "$cur") ) +__docker-compose_services_from_build() { + COMPREPLY=( $(compgen -W "$(___docker-compose_services_with_key build)" -- "$cur") ) } # All services that are defined by an image -__docker_compose_services_from_image() { - COMPREPLY=( $(compgen -W "$(___docker_compose_services_with_key image)" -- "$cur") ) +__docker-compose_services_from_image() { + COMPREPLY=( $(compgen -W "$(___docker-compose_services_with_key image)" -- "$cur") ) } # The services for which containers have been created, optionally filtered # by a boolean expression passed in as argument. -__docker_compose_services_with() { +__docker-compose_services_with() { local containers names - containers="$(__docker_compose_q ps -q)" - names=$(docker 2>/dev/null inspect -f "{{if ${1:-true}}}{{range \$k, \$v := .Config.Labels}}{{if eq \$k \"com.docker.compose.service\"}}{{\$v}}{{end}}{{end}}{{end}}" $containers) - COMPREPLY=( $(compgen -W "$names" -- "$cur") ) -} - -# The services for which at least one paused container exists -__docker_compose_services_paused() { - __docker_compose_services_with '.State.Paused' + containers="$(docker-compose 2>/dev/null ${compose_file:+-f $compose_file} ${compose_project:+-p $compose_project} ps -q)" + names=( $(docker 2>/dev/null inspect --format "{{if ${1:-true}}} {{ .Name }} {{end}}" $containers) ) + names=( ${names[@]%_*} ) # strip trailing numbers + names=( ${names[@]#*_} ) # strip project name + COMPREPLY=( $(compgen -W "${names[*]}" -- "$cur") ) } # The services for which at least one running container exists -__docker_compose_services_running() { - __docker_compose_services_with '.State.Running' +__docker-compose_services_running() { + __docker-compose_services_with '.State.Running' } # The services for which at least one stopped container exists -__docker_compose_services_stopped() { - __docker_compose_services_with 'not .State.Running' +__docker-compose_services_stopped() { + __docker-compose_services_with 'not .State.Running' } -_docker_compose_build() { +_docker-compose_build() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--force-rm --help --no-cache --pull" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--no-cache" -- "$cur" ) ) ;; *) - __docker_compose_services_from_build + __docker-compose_services_from_build ;; esac } -_docker_compose_bundle() { +_docker-compose_docker-compose() { case "$prev" in - --output|-o) - _filedir - return - ;; - esac - - COMPREPLY=( $( compgen -W "--push-images --help --output -o" -- "$cur" ) ) -} - - -_docker_compose_config() { - COMPREPLY=( $( compgen -W "--help --quiet -q --services" -- "$cur" ) ) -} - - -_docker_compose_create() { - case "$cur" in - -*) - COMPREPLY=( $( compgen -W "--force-recreate --help --no-build --no-recreate" -- "$cur" ) ) - ;; - *) - __docker_compose_services_all - ;; - esac -} - - -_docker_compose_docker_compose() { - case "$prev" in - --tlscacert|--tlscert|--tlskey) - _filedir - return - ;; --file|-f) _filedir "y?(a)ml" return ;; - $(__docker_compose_to_extglob "$daemon_options_with_args") ) + --project-name|-p) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "$daemon_boolean_options $daemon_options_with_args --help -h --verbose --version -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--help -h --verbose --version -v --file -f --project-name -p" -- "$cur" ) ) ;; *) COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) @@ -164,64 +113,12 @@ _docker_compose_docker_compose() { } -_docker_compose_down() { - case "$prev" in - --rmi) - COMPREPLY=( $( compgen -W "all local" -- "$cur" ) ) - return - ;; - esac - - case "$cur" in - -*) - COMPREPLY=( $( compgen -W "--help --rmi --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_services_all - ;; - esac -} - - -_docker_compose_exec() { - case "$prev" in - --index|--user) - return - ;; - esac - - case "$cur" in - -*) - COMPREPLY=( $( compgen -W "-d --help --index --privileged -T --user" -- "$cur" ) ) - ;; - *) - __docker_compose_services_running - ;; - esac -} - - -_docker_compose_help() { +_docker-compose_help() { COMPREPLY=( $( compgen -W "${commands[*]}" -- "$cur" ) ) } -_docker_compose_kill() { +_docker-compose_kill() { case "$prev" in -s) COMPREPLY=( $( compgen -W "SIGHUP SIGINT SIGKILL SIGUSR1 SIGUSR2" -- "$(echo $cur | tr '[:lower:]' '[:upper:]')" ) ) @@ -231,46 +128,28 @@ _docker_compose_kill() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help -s" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-s" -- "$cur" ) ) ;; *) - __docker_compose_services_running + __docker-compose_services_running ;; esac } -_docker_compose_logs() { - case "$prev" in - --tail) - return - ;; - esac - +_docker-compose_logs() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--follow -f --help --no-color --tail --timestamps -t" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--no-color" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker-compose_services_all ;; esac } -_docker_compose_pause() { - case "$cur" in - -*) - COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) - ;; - *) - __docker_compose_services_running - ;; - esac -} - - -_docker_compose_port() { +_docker-compose_port() { case "$prev" in --protocol) COMPREPLY=( $( compgen -W "tcp udp" -- "$cur" ) ) @@ -283,276 +162,186 @@ _docker_compose_port() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --index --protocol" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--protocol --index" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker-compose_services_all ;; esac } -_docker_compose_ps() { +_docker-compose_ps() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help -q" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-q" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker-compose_services_all ;; esac } -_docker_compose_pull() { +_docker-compose_pull() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --ignore-pull-failures" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl" -- "$cur" ) ) ;; *) - __docker_compose_services_from_image + __docker-compose_services_from_image ;; esac } -_docker_compose_push() { - case "$cur" in - -*) - COMPREPLY=( $( compgen -W "--help --ignore-push-failures" -- "$cur" ) ) - ;; - *) - __docker_compose_services_all - ;; - esac -} - - -_docker_compose_restart() { +_docker-compose_restart() { case "$prev" in - --timeout|-t) + -t | --timeout) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --timeout -t" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-t --timeout" -- "$cur" ) ) ;; *) - __docker_compose_services_running + __docker-compose_services_running ;; esac } -_docker_compose_rm() { +_docker-compose_rm() { case "$cur" in -*) - COMPREPLY=( $( compgen -W "--force -f --help -v" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--force -f -v" -- "$cur" ) ) ;; *) - __docker_compose_services_stopped + __docker-compose_services_stopped ;; esac } -_docker_compose_run() { +_docker-compose_run() { case "$prev" in -e) COMPREPLY=( $( compgen -e -- "$cur" ) ) - __docker_compose_nospace + compopt -o nospace return ;; - --entrypoint|--name|--user|-u|--workdir|-w) + --entrypoint|--user|-u) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "-d --entrypoint -e --help --name --no-deps --publish -p --rm --service-ports -T --user -u --workdir -w" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --entrypoint -e --no-deps --rm --service-ports -T --user -u" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker-compose_services_all ;; esac } -_docker_compose_scale() { +_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_all_services_in_compose_file)" -- "$cur") ) - __docker_compose_nospace + COMPREPLY=( $(compgen -S "=" -W "$(___docker-compose_all_services_in_compose_file)" -- "$cur") ) + compopt -o nospace ;; esac } -_docker_compose_start() { - case "$cur" in - -*) - COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) - ;; - *) - __docker_compose_services_stopped - ;; - esac +_docker-compose_start() { + __docker-compose_services_stopped } -_docker_compose_stop() { +_docker-compose_stop() { case "$prev" in - --timeout|-t) + -t | --timeout) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "--help --timeout -t" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "-t --timeout" -- "$cur" ) ) ;; *) - __docker_compose_services_running + __docker-compose_services_running ;; esac } -_docker_compose_top() { - case "$cur" in - -*) - COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) - ;; - *) - __docker_compose_services_running - ;; - esac -} - - -_docker_compose_unpause() { - case "$cur" in - -*) - COMPREPLY=( $( compgen -W "--help" -- "$cur" ) ) - ;; - *) - __docker_compose_services_paused - ;; - esac -} - - -_docker_compose_up() { +_docker-compose_up() { case "$prev" in - --timeout|-t) + -t | --timeout) return ;; esac case "$cur" in -*) - COMPREPLY=( $( compgen -W "--abort-on-container-exit --build -d --force-recreate --help --no-build --no-color --no-deps --no-recreate --timeout -t --remove-orphans" -- "$cur" ) ) + COMPREPLY=( $( compgen -W "--allow-insecure-ssl -d --no-build --no-color --no-deps --no-recreate -t --timeout --x-smart-recreate" -- "$cur" ) ) ;; *) - __docker_compose_services_all + __docker-compose_services_all ;; esac } -_docker_compose_version() { - case "$cur" in - -*) - COMPREPLY=( $( compgen -W "--short" -- "$cur" ) ) - ;; - esac -} - - -_docker_compose() { +_docker-compose() { local previous_extglob_setting=$(shopt -p extglob) shopt -s extglob local commands=( build - bundle - config - create - down - events - exec help kill logs - pause + migrate-to-labels 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=" - --file -f - --host -H - --project-name -p - --tlscacert - --tlscert - --tlskey - " - 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 daemon_options=() + local command='docker-compose' local counter=1 - + local compose_file compose_project while [ $counter -lt $cword ]; do case "${words[$counter]}" in - $(__docker_compose_to_extglob "$daemon_boolean_options") ) - local opt=${words[counter]} - daemon_options+=($opt) + -f|--file) + (( counter++ )) + compose_file="${words[$counter]}" ;; - $(__docker_compose_to_extglob "$daemon_options_with_args") ) - local opt=${words[counter]} - local arg=${words[++counter]} - daemon_options+=($opt $arg) + -p|--project-name) + (( counter++ )) + compose_project="${words[$counter]}" ;; -*) ;; @@ -564,11 +353,11 @@ _docker_compose() { (( counter++ )) done - local completions_func=_docker_compose_${command//-/_} + local completions_func=_docker-compose_${command} declare -F $completions_func >/dev/null && $completions_func eval "$previous_extglob_setting" return 0 } -complete -F _docker_compose docker-compose +complete -F _docker-compose docker-compose diff --git a/contrib/completion/zsh/_docker-compose b/contrib/completion/zsh/_docker-compose index 66d924f7..31052e1e 100644 --- a/contrib/completion/zsh/_docker-compose +++ b/contrib/completion/zsh/_docker-compose @@ -7,7 +7,7 @@ # ------------------------------------------------------------------------- # Version # ------- -# 1.5.0 +# 0.1.0 # ------------------------------------------------------------------------- # Authors # ------- @@ -19,69 +19,58 @@ # * @felixr docker zsh completion script : https://github.com/felixr/docker-zsh-completion # ------------------------------------------------------------------------- -__docker-compose_q() { - docker-compose 2>/dev/null $compose_options "$@" +# For compatibility reasons, Compose and therefore its completion supports several +# stack compositon files as listed here, in descending priority. +# Support for these filenames might be dropped in some future version. +__docker-compose_compose_file() { + local file + for file in docker-compose.y{,a}ml fig.y{,a}ml ; do + [ -e $file ] && { + echo $file + return + } + done + echo docker-compose.yml } -# All services defined in docker-compose.yml -__docker-compose_all_services_in_compose_file() { +# Extracts all service names from docker-compose.yml. +___docker-compose_all_services_in_compose_file() { local already_selected local -a services - already_selected=$(echo $words | tr " " "|") - __docker-compose_q config --services \ - | grep -Ev "^(${already_selected})$" + already_selected=$(echo ${words[@]} | tr " " "|") + awk -F: '/^[a-zA-Z0-9]/{print $1}' "${compose_file:-$(__docker-compose_compose_file)}" 2>/dev/null | 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 + services=$(___docker-compose_all_services_in_compose_file) + _alternative "args:services:($services)" } # All services that have an entry with the given key in their docker-compose.yml section -__docker-compose_services_with_key() { +___docker-compose_services_with_key() { local already_selected local -a buildable - already_selected=$(echo $words | tr " " "|") + already_selected=$(echo ${words[@]} | tr " " "|") # flatten sections to one line, then filter lines containing the key and return section name. - __docker-compose_q config \ - | sed -n -e '/^services:/,/^[^ ]/p' \ - | sed -n 's/^ //p' \ - | awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' \ - | grep " \+$1:" \ - | cut -d: -f1 \ - | grep -Ev "^(${already_selected})$" + awk '/^[a-zA-Z0-9]/{printf "\n"};{printf $0;next;}' "${compose_file:-$(__docker-compose_compose_file)}" 2>/dev/null | awk -F: -v key=": +$1:" '$0 ~ key {print $1}' 2>/dev/null | grep -Ev "$already_selected" } # All services that are defined by a Dockerfile reference __docker-compose_services_from_build() { - [[ $PREFIX = -* ]] && return 1 - integer ret=1 - buildable=$(__docker-compose_services_with_key build) - _alternative "args:buildable services:($buildable)" && ret=0 - - return ret + buildable=$(___docker-compose_services_with_key build) + _alternative "args:buildable services:($buildable)" } # All services that are defined by an image __docker-compose_services_from_image() { - [[ $PREFIX = -* ]] && return 1 - integer ret=1 - pullable=$(__docker-compose_services_with_key image) - _alternative "args:pullable services:($pullable)" && ret=0 - - return ret + pullable=$(___docker-compose_services_with_key image) + _alternative "args:pullable services:($pullable)" } __docker-compose_get_services() { - [[ $PREFIX = -* ]] && return 1 - integer ret=1 - local kind - declare -a running paused stopped lines args services + local kind expl + declare -a running stopped lines args services docker_status=$(docker ps > /dev/null 2>&1) if [ $? -ne 0 ]; then @@ -91,78 +80,64 @@ __docker-compose_get_services() { kind=$1 shift - [[ $kind =~ (stopped|all) ]] && args=($args -a) + [[ $kind = (stopped|all) ]] && args=($args -a) - lines=(${(f)"$(_call_program commands docker $docker_options ps $args)"}) - services=(${(f)"$(_call_program commands docker-compose 2>/dev/null $compose_options ps -q)"}) + lines=(${(f)"$(_call_program commands docker ps ${args})"}) + services=(${(f)"$(_call_program commands docker-compose 2>/dev/null ${compose_file:+-f $compose_file} ${compose_project:+-p $compose_project} ps -q)"}) # Parse header line to find columns local i=1 j=1 k header=${lines[1]} declare -A begin end - while (( j < ${#header} - 1 )); do - i=$(( j + ${${header[$j,-1]}[(i)[^ ]]} - 1 )) - j=$(( i + ${${header[$i,-1]}[(i) ]} - 1 )) - k=$(( j + ${${header[$j,-1]}[(i)[^ ]]} - 2 )) - begin[${header[$i,$((j-1))]}]=$i - end[${header[$i,$((j-1))]}]=$k - done + while (( $j < ${#header} - 1 )) { + i=$(( $j + ${${header[$j,-1]}[(i)[^ ]]} - 1)) + j=$(( $i + ${${header[$i,-1]}[(i) ]} - 1)) + k=$(( $j + ${${header[$j,-1]}[(i)[^ ]]} - 2)) + begin[${header[$i,$(($j-1))]}]=$i + end[${header[$i,$(($j-1))]}]=$k + } lines=(${lines[2,-1]}) # Container ID local line s name local -a names for line in $lines; do - if [[ ${services[@]} == *"${line[${begin[CONTAINER ID]},${end[CONTAINER ID]}]%% ##}"* ]]; then + if [[ $services == *"${line[${begin[CONTAINER ID]},${end[CONTAINER ID]}]%% ##}"* ]]; then names=(${(ps:,:)${${line[${begin[NAMES]},-1]}%% *}}) for name in $names; do s="${${name%_*}#*_}:${(l:15:: :::)${${line[${begin[CREATED]},${end[CREATED]}]/ ago/}%% ##}}" s="$s, ${line[${begin[CONTAINER ID]},${end[CONTAINER ID]}]%% ##}" - s="$s, ${${${line[${begin[IMAGE]},${end[IMAGE]}]}/:/\\:}%% ##}" + s="$s, ${${${line[$begin[IMAGE],$end[IMAGE]]}/:/\\:}%% ##}" if [[ ${line[${begin[STATUS]},${end[STATUS]}]} = Exit* ]]; then stopped=($stopped $s) else - if [[ ${line[${begin[STATUS]},${end[STATUS]}]} = *\(Paused\)* ]]; then - paused=($paused $s) - fi running=($running $s) fi done fi done - [[ $kind =~ (running|all) ]] && _describe -t services-running "running services" running "$@" && ret=0 - [[ $kind =~ (paused|all) ]] && _describe -t services-paused "paused services" paused "$@" && ret=0 - [[ $kind =~ (stopped|all) ]] && _describe -t services-stopped "stopped services" stopped "$@" && ret=0 - - return ret -} - -__docker-compose_pausedservices() { - [[ $PREFIX = -* ]] && return 1 - __docker-compose_get_services paused "$@" + [[ $kind = (running|all) ]] && _describe -t services-running "running services" running + [[ $kind = (stopped|all) ]] && _describe -t services-stopped "stopped services" stopped } __docker-compose_stoppedservices() { - [[ $PREFIX = -* ]] && return 1 __docker-compose_get_services stopped "$@" } __docker-compose_runningservices() { - [[ $PREFIX = -* ]] && return 1 __docker-compose_get_services running "$@" } -__docker-compose_services() { - [[ $PREFIX = -* ]] && return 1 +__docker-compose_services () { __docker-compose_get_services all "$@" } __docker-compose_caching_policy() { - oldp=( "$1"(Nmh+1) ) # 1 hour + oldp=( "$1"(Nmh+1) ) # 1 hour (( $#oldp )) } -__docker-compose_commands() { +__docker-compose_commands () { local cache_policy zstyle -s ":completion:${curcontext}:" cache-policy cache_policy @@ -176,210 +151,106 @@ __docker-compose_commands() { 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 + _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.]" - +__docker-compose_subcommand () { + local -a _command_args integer ret=1 - case "$words[1]" in (build) _arguments \ - $opts_help \ - '--force-rm[Always remove intermediate containers.]' \ - '--no-cache[Do not use cache when building the image.]' \ - '--pull[Always attempt to pull a newer version of the image.]' \ + '--no-cache[Do not use cache when building the image]' \ '*:services:__docker-compose_services_from_build' && ret=0 ;; - (bundle) - _arguments \ - $opts_help \ - '--push-images[Automatically push images for any services which have a `build` option specified.]' \ - '(--output -o)'{--output,-o}'[Path to write the bundle file to. Defaults to ".dab".]:file:_files' && ret=0 - ;; - (config) - _arguments \ - $opts_help \ - '(--quiet -q)'{--quiet,-q}"[Only validate the configuration, don't print anything.]" \ - '--services[Print the service names, one per line.]' && 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_all' && ret=0 - ;; - (down) - _arguments \ - $opts_help \ - "--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_all' && ret=0 - ;; - (exec) - _arguments \ - $opts_help \ - '-d[Detached mode: Run command in the background.]' \ - '--privileged[Give extended privileges to the process.]' \ - '--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: ' \ - '(-):running services:__docker-compose_runningservices' \ - '(-):command: _command_names -e' \ - '*::arguments: _normal' && ret=0 - ;; (help) _arguments ':subcommand:__docker-compose_commands' && 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]' \ + '--no-color[Produce monochrome output.]' \ '*:services:__docker-compose_services_all' && ret=0 ;; - (pause) + (migrate-to-labels) _arguments \ - $opts_help \ - '*:running services:__docker-compose_runningservices' && ret=0 + '(-):Recreate containers to add labels' && 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: ' \ + '--protocol=-[tcp or udap (defaults to tcp)]:protocol:(tcp udp)' \ + '--index=-[index of the container if there are mutiple instances of a service (defaults to 1)]:index: ' \ '1:running services:__docker-compose_runningservices' \ '2:port:_ports' && ret=0 ;; (ps) _arguments \ - $opts_help \ '-q[Only display IDs]' \ '*:services:__docker-compose_services_all' && ret=0 ;; (pull) _arguments \ - $opts_help \ - '--ignore-pull-failures[Pull what it can and ignores images with pull failures.]' \ + '--allow-insecure-ssl[Allow insecure connections to the docker registry]' \ '*: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]' \ + '-v[Remove volumes associated with containers]' \ '*:stopped services:__docker-compose_stoppedservices' && ret=0 ;; (run) _arguments \ - $opts_help \ + '--allow-insecure-ssl[Allow insecure connections to the docker registry]' \ '-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: ' \ '--entrypoint[Overwrite the entrypoint of the image.]:entry point: ' \ - '--name=[Assign a name to the container]:name: ' \ - $opts_no_deps \ - '(-p --publish)'{-p,--publish=}"[Publish a container's port(s) to the host]" \ + '*-e[KEY=VAL Set an environment variable (can be used multiple times)]:environment variable KEY=VAL: ' \ + '(-u --user)'{-u,--user=-}'[Run as specified username or uid]:username or uid:_users' \ + "--no-deps[Don't start linked services.]" \ '--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' \ - '(-w --workdir)'{-w,--workdir=}'[Working directory inside the container]:workdir: ' \ '(-):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 + _arguments '*:running services:__docker-compose_runningservices' && ret=0 ;; (start) - _arguments \ - $opts_help \ - '*:stopped services:__docker-compose_stoppedservices' && ret=0 + _arguments '*:stopped services:__docker-compose_stoppedservices' && ret=0 ;; (stop|restart) _arguments \ - $opts_help \ - $opts_timeout \ + '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ '*: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.]' \ - $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.]" \ - '(-t --timeout)'{-t,--timeout}"[Use this timeout in seconds for container shutdown when attached or when containers are already running. (default: 10)]:seconds: " \ - $opts_remove_orphans \ + '--allow-insecure-ssl[Allow insecure connections to the docker registry]' \ + '-d[Detached mode: Run containers in the background, print new container names.]' \ + '--no-color[Produce monochrome output.]' \ + "--no-deps[Don't start linked services.]" \ + "--no-recreate[If containers already exist, don't recreate them.]" \ + "--no-build[Don't build an image, even if it's missing]" \ + '(-t --timeout)'{-t,--timeout}"[Specify a shutdown timeout in seconds. (default: 10)]:seconds: " \ + "--x-smart-recreate[Only recreate containers whose configuration or image needs to be updated. (EXPERIMENTAL)]" \ '*:services:__docker-compose_services_all' && ret=0 ;; - (version) - _arguments \ - $opts_help \ - "--short[Shows only Compose's version number.]" && ret=0 - ;; (*) - _message 'Unknown sub command' && ret=1 - ;; + _message 'Unknown sub command' esac return ret } -_docker-compose() { +_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 @@ -387,70 +258,34 @@ _docker-compose() { return fi - local curcontext="$curcontext" state line - integer ret=1 + local curcontext="$curcontext" state line 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:' \ '--verbose[Show more output]' \ '(- :)'{-v,--version}'[Print version and exit]' \ - '(-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)]" \ + '(-f --file)'{-f,--file}'[Specify an alternate docker-compose file (default: docker-compose.yml)]:file:_files -g "*.yml"' \ + '(-p --project-name)'{-p,--project-name}'[Specify an alternate project name (default: directory name)]:project name:' \ '(-): :->command' \ '(-)*:: :->option-or-argument' && ret=0 - local -a relevant_compose_flags relevant_docker_flags compose_options docker_options - - relevant_compose_flags=( - "--file" "-f" - "--host" "-H" - "--project-name" "-p" - "--tls" - "--tlscacert" - "--tlscert" - "--tlskey" - "--tlsverify" - "--skip-hostname-check" - ) - - 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 - compose_options+=$k - if [[ -n "$opt_args[$k]" ]]; then - compose_options+=$opt_args[$k] - fi - fi + local counter=1 + #local compose_file compose_project + while [ $counter -lt ${#words[@]} ]; do + case "${words[$counter]}" in + -f|--file) + (( counter++ )) + compose_file="${words[$counter]}" + ;; + -p|--project-name) + (( counter++ )) + compose_project="${words[$counter]}" + ;; + *) + ;; + esac + (( counter++ )) done case $state in diff --git a/contrib/migration/migrate-compose-file-v1-to-v2.py b/contrib/migration/migrate-compose-file-v1-to-v2.py deleted file mode 100755 index c1785b0d..00000000 --- a/contrib/migration/migrate-compose-file-v1-to-v2.py +++ /dev/null @@ -1,173 +0,0 @@ -#!/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+ -""" -from __future__ import absolute_import -from __future__ import unicode_literals - -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.warn( - "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.warn( - "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.warn( - "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, 'r') 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/docker-compose.spec b/docker-compose.spec deleted file mode 100644 index ef0e2593..00000000 --- a/docker-compose.spec +++ /dev/null @@ -1,56 +0,0 @@ -# -*- 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/config_schema_v2.0.json', - 'compose/config/config_schema_v2.0.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v2.1.json', - 'compose/config/config_schema_v2.1.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.0.json', - 'compose/config/config_schema_v3.0.json', - 'DATA' - ), - ( - 'compose/config/config_schema_v3.1.json', - 'compose/config/config_schema_v3.1.json', - 'DATA' - ), - ( - 'compose/GITSHA', - 'compose/GITSHA', - 'DATA' - ) - ], - - name='docker-compose', - debug=False, - strip=None, - upx=True, - console=True) diff --git a/docs/Dockerfile b/docs/Dockerfile new file mode 100644 index 00000000..55e7ce70 --- /dev/null +++ b/docs/Dockerfile @@ -0,0 +1,24 @@ +FROM docs/base:hugo +MAINTAINER Mary Anthony (@moxiegirl) + +# To get the git info for this repo +COPY . /src + +COPY . /docs/content/compose/ + +# Sed to process GitHub Markdown +# 1-2 Remove comment code from metadata block +# 3 Remove .md extension from link text +# 4 Change ](/ to ](/project/ in links +# 5 Change ](word) to ](/project/word) +# 6 Change ](../../ to ](/project/ +# 7 Change ](../ to ](/project/word) +# +# +RUN find /docs/content/compose -type f -name "*.md" -exec sed -i.old \ + -e '/^/g' \ + -e '/^/g' \ + -e 's/\([(]\)\(.*\)\(\.md\)/\1\2/g' \ + -e 's/\(\]\)\([(]\)\(\/\)/\1\2\/compose\//g' \ + -e 's/\(\][(]\)\([A-z]*[)]\)/\]\(\/compose\/\2/g' \ + -e 's/\(\][(]\)\(\.\.\/\)/\1\/compose\//g' {} \; diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..021e8f6e --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,55 @@ +.PHONY: all binary build cross default docs docs-build docs-shell shell test test-unit test-integration test-integration-cli test-docker-py validate + +# env vars passed through directly to Docker's build scripts +# to allow things like `make DOCKER_CLIENTONLY=1 binary` easily +# `docs/sources/contributing/devenvironment.md ` and `project/PACKAGERS.md` have some limited documentation of some of these +DOCKER_ENVS := \ + -e BUILDFLAGS \ + -e DOCKER_CLIENTONLY \ + -e DOCKER_EXECDRIVER \ + -e DOCKER_GRAPHDRIVER \ + -e TESTDIRS \ + -e TESTFLAGS \ + -e TIMEOUT +# note: we _cannot_ add "-e DOCKER_BUILDTAGS" here because even if it's unset in the shell, that would shadow the "ENV DOCKER_BUILDTAGS" set in our Dockerfile, which is very important for our official builds + +# to allow `make DOCSDIR=docs docs-shell` (to create a bind mount in docs) +DOCS_MOUNT := $(if $(DOCSDIR),-v $(CURDIR)/$(DOCSDIR):/$(DOCSDIR)) + +# to allow `make DOCSPORT=9000 docs` +DOCSPORT := 8000 + +# Get the IP ADDRESS +DOCKER_IP=$(shell python -c "import urlparse ; print urlparse.urlparse('$(DOCKER_HOST)').hostname or ''") +HUGO_BASE_URL=$(shell test -z "$(DOCKER_IP)" && echo localhost || echo "$(DOCKER_IP)") +HUGO_BIND_IP=0.0.0.0 + +GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null) +DOCKER_IMAGE := docker$(if $(GIT_BRANCH),:$(GIT_BRANCH)) +DOCKER_DOCS_IMAGE := docs-base$(if $(GIT_BRANCH),:$(GIT_BRANCH)) + + +DOCKER_RUN_DOCS := docker run --rm -it $(DOCS_MOUNT) -e AWS_S3_BUCKET -e NOCACHE + +# for some docs workarounds (see below in "docs-build" target) +GITCOMMIT := $(shell git rev-parse --short HEAD 2>/dev/null) + +default: docs + +docs: docs-build + $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" hugo server --port=$(DOCSPORT) --baseUrl=$(HUGO_BASE_URL) --bind=$(HUGO_BIND_IP) + +docs-draft: docs-build + $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 -e DOCKERHOST "$(DOCKER_DOCS_IMAGE)" hugo server --buildDrafts="true" --port=$(DOCSPORT) --baseUrl=$(HUGO_BASE_URL) --bind=$(HUGO_BIND_IP) + + +docs-shell: docs-build + $(DOCKER_RUN_DOCS) -p $(if $(DOCSPORT),$(DOCSPORT):)8000 "$(DOCKER_DOCS_IMAGE)" bash + + +docs-build: +# ( git remote | grep -v upstream ) || git diff --name-status upstream/release..upstream/docs ./ > ./changed-files +# echo "$(GIT_BRANCH)" > GIT_BRANCH +# echo "$(AWS_S3_BUCKET)" > AWS_S3_BUCKET +# echo "$(GITCOMMIT)" > GITCOMMIT + docker build -t "$(DOCKER_DOCS_IMAGE)" . diff --git a/docs/README.md b/docs/README.md index 50c91d20..00736e47 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,16 +1,77 @@ -# The docs have been moved! +# Contributing to the Docker Compose documentation -The documentation for Compose has been merged into -[the general documentation repo](https://github.com/docker/docker.github.io). +The documentation in this directory is part of the [https://docs.docker.com](https://docs.docker.com) website. Docker uses [the Hugo static generator](http://gohugo.io/overview/introduction/) to convert project Markdown files to a static HTML site. -The docs for Compose are now here: -https://github.com/docker/docker.github.io/tree/master/compose +You don't need to be a Hugo expert to contribute to the compose documentation. If you are familiar with Markdown, you can modify the content in the `docs` files. -Please submit pull requests for unpublished features on the `vnext-compose` branch (https://github.com/docker/docker.github.io/tree/vnext-compose). +If you want to add a new file or change the location of the document in the menu, you do need to know a little more. -If you submit a PR to this codebase that has a docs impact, create a second docs PR on `docker.github.io`. Use the docs PR template provided (coming soon - watch this space). +## Documentation contributing workflow -PRs for typos, additional information, etc. for already-published features should be labeled as `okay-to-publish` (we are still settling on a naming convention, will provide a label soon). You can submit these PRs either to `vnext-compose` or directly to `master` on `docker.github.io` +1. Edit a Markdown file in the tree. -As always, the docs remain open-source and we appreciate your feedback and -pull requests! +2. Save your changes. + +3. Make sure you in your `docs` subdirectory. + +4. Build the documentation. + + $ make docs + ---> ffcf3f6c4e97 + Removing intermediate container a676414185e8 + Successfully built ffcf3f6c4e97 + docker run --rm -it -e AWS_S3_BUCKET -e NOCACHE -p 8000:8000 -e DOCKERHOST "docs-base:test-tooling" hugo server --port=8000 --baseUrl=192.168.59.103 --bind=0.0.0.0 + ERROR: 2015/06/13 MenuEntry's .Url is deprecated and will be removed in Hugo 0.15. Use .URL instead. + 0 of 4 drafts rendered + 0 future content + 12 pages created + 0 paginator pages created + 0 tags created + 0 categories created + in 55 ms + Serving pages from /docs/public + Web Server is available at http://0.0.0.0:8000/ + Press Ctrl+C to stop + +5. Open the available server in your browser. + + The documentation server has the complete menu but only the Docker Compose + documentation resolves. You can't access the other project docs from this + localized build. + +## Tips on Hugo metadata and menu positioning + +The top of each Docker Compose documentation file contains TOML metadata. The metadata is commented out to prevent it from appears in GitHub. + + + +The metadata alone has this structure: + + +++ + title = "Extending services in Compose" + description = "How to use Docker Compose's extends keyword to share configuration between files and projects" + keywords = ["fig, composition, compose, docker, orchestration, documentation, docs"] + [menu.main] + parent="smn_workw_compose" + weight=2 + +++ + +The `[menu.main]` section refers to navigation defined [in the main Docker menu](https://github.com/docker/docs-base/blob/hugo/config.toml). This metadata says *add a menu item called* Extending services in Compose *to the menu with the* `smn_workdw_compose` *identifier*. If you locate the menu in the configuration, you'll find *Create multi-container applications* is the menu title. + +You can move an article in the tree by specifying a new parent. You can shift the location of the item by changing its weight. Higher numbers are heavier and shift the item to the bottom of menu. Low or no numbers shift it up. + + +## Other key documentation repositories + +The `docker/docs-base` repository contains [the Hugo theme and menu configuration](https://github.com/docker/docs-base). If you open the `Dockerfile` you'll see the `make docs` relies on this as a base image for building the Compose documentation. + +The `docker/docs.docker.com` repository contains [build system for building the Docker documentation site](https://github.com/docker/docs.docker.com). Fork this repository to build the entire documentation site. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..a2167d9c --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,202 @@ + + + +# Compose CLI reference + +Most Docker Compose commands are run against one or more services. If +the service is not specified, the command will apply to all services. + +For full usage information, run `docker-compose [COMMAND] --help`. + +## Commands + +### build + +Builds or rebuilds 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, run `docker-compose build` to rebuild it. + +### help + +Displays help and usage instructions for a command. + +### kill + +Forces running containers to stop by sending a `SIGKILL` signal. Optionally the +signal can be passed, for example: + + $ docker-compose kill -s SIGINT + +### logs + +Displays log output from services. + +### port + +Prints the public port for a port binding + +### ps + +Lists containers. + +### pull + +Pulls service images. + +### restart + +Restarts services. + +### rm + +Removes stopped service containers. + + +### run + +Runs a one-off command on a service. + +For example, + + $ docker-compose run web python manage.py shell + +will start the `web` service and then run `manage.py shell` in python. +Note that by default, linked services will also be started, unless they are +already running. + +One-off commands are started in new containers with the same configuration as a +normal container for that service, so volumes, links, etc will all be created as +expected. When using `run`, there are two differences from bringing up a +container normally: + +1. the command will be overridden with the one specified. So, if you run +`docker-compose run web bash`, the container's web command (which could default +to, e.g., `python app.py`) will be overridden to `bash` + +2. by default no ports will be created in case they collide with already opened +ports. + +Links are also created between one-off commands and the other containers which +are part of that service. So, for example, you could run: + + $ docker-compose run db psql -h db -U docker + +This would open up an interactive PostgreSQL shell for the linked `db` container +(which would get created or started as needed). + +If you do not want linked containers to start when running the one-off command, +specify the `--no-deps` flag: + + $ docker-compose run --no-deps web python manage.py shell + +Similarly, if you do want the service's ports to be created and mapped to the +host, specify the `--service-ports` flag: + + $ docker-compose run --service-ports web python manage.py shell + + +### scale + +Sets the number of containers to run for a service. + +Numbers are specified as arguments in the form `service=num`. For example: + + $ docker-compose scale web=2 worker=3 + +### start + +Starts existing containers for a service. + +### stop + +Stops running containers without removing them. They can be started again with +`docker-compose start`. + +### up + +Builds, (re)creates, starts, and attaches to containers for a service. + +Linked services will be started, unless they are already running. + +By default, `docker-compose up` will aggregate the output of each container and, +when it exits, all containers will be stopped. Running `docker-compose up -d`, +will start the containers in the background and leave them running. + +By default, if there are existing containers for a service, `docker-compose up` will stop and recreate them (preserving mounted volumes with [volumes-from]), so that changes in `docker-compose.yml` are picked up. If you do not want containers stopped and recreated, use `docker-compose up --no-recreate`. This will still start any stopped containers, if needed. + +[volumes-from]: http://docs.docker.io/en/latest/use/working_with_volumes/ + +## Options + +### --verbose + + Shows more output + +### -v, --version + + Prints version and exits + +### -f, --file FILE + + Specify what file to read configuration from. If not provided, Compose will look + for `docker-compose.yml` in the current working directory, and then each parent + directory successively, until found. + + +### -p, --project-name NAME + + Specifies an alternate project name (default: current directory name) + + +## Environment Variables + +Several environment variables are available for you to configure Compose's behaviour. + +Variables starting with `DOCKER_` are the same as those used to configure the +Docker command-line client. If you're using boot2docker, `eval "$(boot2docker shellinit)"` +will set them to their correct values. + +### COMPOSE\_PROJECT\_NAME + +Sets the project name, which is prepended to the name of every container started by Compose. Defaults to the `basename` of the current working directory. + +### COMPOSE\_FILE + +Specify what file to read configuration from. If not provided, Compose will look +for `docker-compose.yml` in the current working directory, and then each parent +directory successively, until found. + +### DOCKER\_HOST + +Sets the URL of the docker daemon. As with the Docker client, defaults to `unix:///var/run/docker.sock`. + +### DOCKER\_TLS\_VERIFY + +When set to anything other than an empty string, enables TLS communication with +the daemon. + +### DOCKER\_CERT\_PATH + +Configures the path to the `ca.pem`, `cert.pem`, and `key.pem` files used for TLS verification. Defaults to `~/.docker`. + +## Compose documentation + +- [User guide](compose-overview.md) +- [Installing Compose](install.md) +- [Get started with Django](django.md) +- [Get started with Rails](rails.md) +- [Get started with Wordpress](wordpress.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) diff --git a/docs/completion.md b/docs/completion.md new file mode 100644 index 00000000..7fb696d8 --- /dev/null +++ b/docs/completion.md @@ -0,0 +1,69 @@ + + +# Command Completion + +Compose comes with [command completion](http://en.wikipedia.org/wiki/Command-line_completion) +for the bash and zsh shell. + +## Installing Command Completion + +### Bash + +Make sure bash completion is installed. If you use a current Linux in a non-minimal installation, bash completion should be available. +On a Mac, install with `brew install bash-completion` + +Place the completion script in `/etc/bash_completion.d/` (`/usr/local/etc/bash_completion.d/` on a Mac), using e.g. + + curl -L https://raw.githubusercontent.com/docker/compose/$(docker-compose --version | awk '{print $2}')/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose + +Completion will be available upon next login. + +### Zsh + +Place the completion script in your `/path/to/zsh/completion`, using e.g. `~/.zsh/completion/` + + mkdir -p ~/.zsh/completion + curl -L https://raw.githubusercontent.com/docker/compose/$(docker-compose --version | awk '{print $2}')/contrib/completion/zsh/_docker-compose > ~/.zsh/completion/_docker-compose + +Include the directory in your `$fpath`, e.g. by adding in `~/.zshrc` + + fpath=(~/.zsh/completion $fpath) + +Make sure `compinit` is loaded or do it by adding in `~/.zshrc` + + autoload -Uz compinit && compinit -i + +Then reload your shell + + exec $SHELL -l + +## Available completions + +Depending on what you typed on the command line so far, it will complete + + - available docker-compose commands + - options that are available for a particular command + - service names that make sense in a given context (e.g. services with running or stopped instances or services based on images vs. services based on Dockerfiles). For `docker-compose scale`, completed service names will automatically have "=" appended. + - arguments for selected options, e.g. `docker-compose kill -s` will complete some signals like SIGHUP and SIGUSR1. + +Enjoy working with Compose faster and with less typos! + +## Compose documentation + +- [User guide](compose-overview.md) +- [Installing Compose](install.md) +- [Get started with Django](django.md) +- [Get started with Rails](rails.md) +- [Get started with Wordpress](wordpress.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) \ No newline at end of file diff --git a/docs/compose-overview.md b/docs/compose-overview.md new file mode 100644 index 00000000..33629957 --- /dev/null +++ b/docs/compose-overview.md @@ -0,0 +1,234 @@ + + + +# Overview of Docker Compose + +Compose is a tool for defining and running multi-container applications with +Docker. With Compose, you define a multi-container application in a single +file, then spin your application up in a single command which does everything +that needs to be done to get it running. + +Compose is great for development environments, staging servers, and CI. We don't +recommend that you use it in production yet. + +Using 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 `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 app. + +A `docker-compose.yml` looks like this: + +```yaml +web: + build: . + ports: + - "5000:5000" + volumes: + - .:/code + links: + - redis +redis: + image: redis +``` + +Compose has commands for managing the whole lifecycle of your application: + + * Start, stop and rebuild services + * View the status of running services + * Stream the log output of running services + * Run a one-off command on a service + +## Compose documentation + +- [Installing Compose](install.md) +- [Get started with Django](django.md) +- [Get started with Rails](rails.md) +- [Get started with Wordpress](wordpress.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) + +## Quick start + +Let's get started with a walkthrough of getting a simple Python web app running +on Compose. It assumes a little knowledge of Python, but the concepts +demonstrated here should be understandable even if you're not familiar with +Python. + +### Installation and set-up + +First, [install Docker and Compose](install.md). + +Next, you'll want to make a directory for the project: + + $ mkdir composetest + $ cd composetest + +Inside this directory, create `app.py`, a simple web app that uses the Flask +framework and increments a value in Redis: + +```python +from flask import Flask +from redis import Redis +import os +app = Flask(__name__) +redis = Redis(host='redis', port=6379) + +@app.route('/') +def hello(): + redis.incr('hits') + return 'Hello World! I have been seen %s times.' % redis.get('hits') + +if __name__ == "__main__": + app.run(host="0.0.0.0", debug=True) +``` + +Next, define the Python dependencies in a file called `requirements.txt`: + + flask + redis + +### Create a Docker image + +Now, create a Docker image containing all of your app's dependencies. You +specify how to build the image using a file called +[`Dockerfile`](http://docs.docker.com/reference/builder/): + + FROM python:2.7 + ADD . /code + WORKDIR /code + RUN pip install -r requirements.txt + CMD python app.py + +This tells Docker to: + +* Build an image starting with the Python 2.7 image. +* Add the current directory `.` into the path `/code` in the image. +* Set the working directory to `/code`. +* Install your Python dependencies. +* Set the default command for the container to `python app.py` + +For more information on how to write Dockerfiles, see the [Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). + +You can test that this builds by running `docker build -t web .`. + +### Define services + +Next, define a set of services using `docker-compose.yml`: + + web: + build: . + ports: + - "5000:5000" + volumes: + - .:/code + links: + - redis + redis: + image: redis + +This defines two services: + +#### web + +* Builds from the `Dockerfile` in the current directory. +* Forwards the exposed port 5000 on the container to port 5000 on the host machine. +* Connects the web container to the Redis service via a link. +* Mounts the current directory on the host to `/code` inside the container allowing you to modify the code without having to rebuild the image. + +#### redis + +* Uses the public [Redis](https://registry.hub.docker.com/_/redis/) image which gets pulled from the Docker Hub registry. + +### Build and run your app with Compose + +Now, when you run `docker-compose up`, Compose will pull a Redis image, build an image for your code, and start everything up: + + $ docker-compose up + Pulling image redis... + Building web... + Starting composetest_redis_1... + Starting composetest_web_1... + redis_1 | [8] 02 Jan 18:43:35.576 # Server started, Redis version 2.8.3 + web_1 | * Running on http://0.0.0.0:5000/ + +The web app should now be listening on port 5000 on your Docker daemon host (if +you're using Boot2docker, `boot2docker ip` will tell you its address). In a browser, +open `http://ip-from-boot2docker:5000` and you should get a message in your browser saying: + +`Hello World! I have been seen 1 times.` + +Refreshing the page will increment the number. + +If you want to run your services in the background, you can pass the `-d` flag +(for daemon mode) to `docker-compose up` and use `docker-compose ps` to see what +is currently running: + + $ docker-compose up -d + Starting composetest_redis_1... + Starting composetest_web_1... + $ docker-compose ps + Name Command State Ports + ------------------------------------------------------------------- + composetest_redis_1 /usr/local/bin/run Up + composetest_web_1 /bin/sh -c python app.py Up 5000->5000/tcp + +The `docker-compose run` command allows you to run one-off commands for your +services. For example, to see what environment variables are available to the +`web` service: + + $ docker-compose run web env + +See `docker-compose --help` to see other available commands. + +If you started Compose with `docker-compose up -d`, you'll probably want to stop +your services once you've finished with them: + + $ docker-compose stop + +At this point, you have seen the basics of how Compose works. + +- Next, try the quick start guide for [Django](django.md), + [Rails](rails.md), or [Wordpress](wordpress.md). +- See the reference guides for complete details on the [commands](cli.md), the + [configuration file](yml.md) and [environment variables](env.md). + +## Release Notes + +### Version 1.2.0 (April 7, 2015) + +For complete information on this release, see the [1.2.0 Milestone project page](https://github.com/docker/compose/wiki/1.2.0-Milestone-Project-Page). +In addition to bug fixes and refinements, this release adds the following: + +* The `extends` keyword, which adds the ability to extend services by sharing common configurations. For details, see +[PR #1088](https://github.com/docker/compose/pull/1088). + +* Better integration with Swarm. Swarm will now schedule inter-dependent +containers on the same host. For details, see +[PR #972](https://github.com/docker/compose/pull/972). + +## Getting help + +Docker Compose is still in its infancy and under active development. If you need +help, would like to contribute, or simply want to talk about the project with +like-minded individuals, we have a number of open channels for communication. + +* To report bugs or file feature requests: please use the [issue tracker on Github](https://github.com/docker/compose/issues). + +* To talk about the project with people in real time: please join the `#docker-compose` channel on IRC. + +* To contribute code or documentation changes: please submit a [pull request on Github](https://github.com/docker/compose/pulls). + +For more information and resources, please visit the [Getting Help project page](https://docs.docker.com/project/get-help/). diff --git a/docs/django.md b/docs/django.md new file mode 100644 index 00000000..c44329e1 --- /dev/null +++ b/docs/django.md @@ -0,0 +1,136 @@ + + + +## Quickstart Guide: Compose and Django + + +This Quick-start Guide will demonstrate how to use Compose to set up and run a +simple Django/PostgreSQL app. Before starting, you'll need to have +[Compose installed](install.md). + +### Define the project + +Start by setting up the three files you'll need to build the app. First, since +your app is going to run inside a Docker container containing all of its +dependencies, you'll need to define exactly what needs to be included in the +container. This is done using a file called `Dockerfile`. To begin with, the +Dockerfile consists of: + + FROM python:2.7 + ENV PYTHONUNBUFFERED 1 + RUN mkdir /code + WORKDIR /code + ADD requirements.txt /code/ + RUN pip install -r requirements.txt + ADD . /code/ + +This Dockerfile will define an image that is used to build a container that +includes your application and has Python installed alongside all of your Python +dependencies. For more information on how to write Dockerfiles, see the +[Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). + +Second, you'll define your Python dependencies in a file called +`requirements.txt`: + + Django + psycopg2 + +Finally, this is all tied together with a file called `docker-compose.yml`. It +describes the services that comprise your app (here, a web server and database), +which Docker images they use, how they link together, what volumes will be +mounted inside the containers, and what ports they expose. + + db: + image: postgres + web: + build: . + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - .:/code + ports: + - "8000:8000" + links: + - db + +See the [`docker-compose.yml` reference](yml.html) for more information on how +this file works. + +### Build the project + +You can now start a Django project with `docker-compose run`: + + $ docker-compose run web django-admin.py startproject composeexample . + +First, Compose will build an image for the `web` service using the `Dockerfile`. +It will then run `django-admin.py startproject composeexample .` inside a +container built using that image. + +This will generate a Django app inside the current directory: + + $ ls + Dockerfile docker-compose.yml composeexample manage.py requirements.txt + +### Connect the database + +Now you need to set up the database connection. Replace the `DATABASES = ...` +definition in `composeexample/settings.py` to read: + + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': 'postgres', + 'USER': 'postgres', + 'HOST': 'db', + 'PORT': 5432, + } + } + +These settings are determined by the +[postgres](https://registry.hub.docker.com/_/postgres/) Docker image specified +in the Dockerfile. + +Then, run `docker-compose up`: + + Recreating myapp_db_1... + Recreating myapp_web_1... + Attaching to myapp_db_1, myapp_web_1 + myapp_db_1 | + myapp_db_1 | PostgreSQL stand-alone backend 9.1.11 + myapp_db_1 | 2014-01-27 12:17:03 UTC LOG: database system is ready to accept connections + myapp_db_1 | 2014-01-27 12:17:03 UTC LOG: autovacuum launcher started + myapp_web_1 | Validating models... + myapp_web_1 | + myapp_web_1 | 0 errors found + myapp_web_1 | January 27, 2014 - 12:12:40 + myapp_web_1 | Django version 1.6.1, using settings 'composeexample.settings' + myapp_web_1 | Starting development server at http://0.0.0.0:8000/ + myapp_web_1 | Quit the server with CONTROL-C. + +Your Django app should nw be running at port 8000 on your Docker daemon (if +you're using Boot2docker, `boot2docker ip` will tell you its address). + +You can also run management commands with Docker. To set up your database, for +example, run `docker-compose up` and in another terminal run: + + $ docker-compose run web python manage.py syncdb + +## More Compose documentation + +- [User guide](compose-overview.md) +- [Installing Compose](install.md) +- [Get started with Django](django.md) +- [Get started with Rails](rails.md) +- [Get started with Wordpress](wordpress.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) diff --git a/docs/env.md b/docs/env.md new file mode 100644 index 00000000..73496f32 --- /dev/null +++ b/docs/env.md @@ -0,0 +1,50 @@ + + +# Compose environment variables reference +=============================== + +**Note:** Environment variables are no longer the recommended method for connecting to linked services. Instead, you should use the link name (by default, the name of the linked service) as the hostname to connect to. See the [docker-compose.yml documentation](yml.md#links) for details. + +Compose uses [Docker links] to expose services' containers to one another. Each linked container injects a set of environment variables, each of which begins with the uppercase name of the container. + +To see what environment variables are available to a service, run `docker-compose run SERVICE env`. + +name\_PORT
+Full URL, e.g. `DB_PORT=tcp://172.17.0.5:5432` + +name\_PORT\_num\_protocol
+Full URL, e.g. `DB_PORT_5432_TCP=tcp://172.17.0.5:5432` + +name\_PORT\_num\_protocol\_ADDR
+Container's IP address, e.g. `DB_PORT_5432_TCP_ADDR=172.17.0.5` + +name\_PORT\_num\_protocol\_PORT
+Exposed port number, e.g. `DB_PORT_5432_TCP_PORT=5432` + +name\_PORT\_num\_protocol\_PROTO
+Protocol (tcp or udp), e.g. `DB_PORT_5432_TCP_PROTO=tcp` + +name\_NAME
+Fully qualified container name, e.g. `DB_1_NAME=/myapp_web_1/myapp_db_1` + +[Docker links]: http://docs.docker.com/userguide/dockerlinks/ + +## Compose documentation + +- [User guide](compose-overview.md) +- [Installing Compose](install.md) +- [Get started with Django](django.md) +- [Get started with Rails](rails.md) +- [Get started with Wordpress](wordpress.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose command line completion](completion.md) diff --git a/docs/extends.md b/docs/extends.md new file mode 100644 index 00000000..8527c81b --- /dev/null +++ b/docs/extends.md @@ -0,0 +1,381 @@ + + + +## Extending services in Compose + +Docker Compose's `extends` keyword enables sharing of common configurations +among different files, or even different projects entirely. Extending services +is useful if you have several applications that reuse commonly-defined services. +Using `extends` you can define a service in one place and refer to it from +anywhere. + +Alternatively, you can deploy the same application to multiple environments with +a slightly different set of services in each case (or with changes to the +configuration of some services). Moreover, you can do so without copy-pasting +the configuration around. + +### Understand the extends configuration + +When defining any service in `docker-compose.yml`, you can declare that you are +extending another service like this: + +```yaml +web: + extends: + file: common-services.yml + service: webapp +``` + +This instructs Compose to re-use the configuration for the `webapp` service +defined in the `common-services.yml` file. Suppose that `common-services.yml` +looks like this: + +```yaml +webapp: + build: . + ports: + - "8000:8000" + volumes: + - "/data" +``` + +In this case, you'll get exactly the same result as if you wrote +`docker-compose.yml` with that `build`, `ports` and `volumes` configuration +defined directly under `web`. + +You can go further and define (or re-define) configuration locally in +`docker-compose.yml`: + +```yaml +web: + extends: + file: common-services.yml + service: webapp + environment: + - DEBUG=1 + cpu_shares: 5 +``` + +You can also write other services and link your `web` service to them: + +```yaml +web: + extends: + file: common-services.yml + service: webapp + environment: + - DEBUG=1 + cpu_shares: 5 + links: + - db +db: + image: postgres +``` + +For full details on how to use `extends`, refer to the [reference](#reference). + +### Example use case + +In this example, you’ll repurpose the example app from the [quick start +guide](compose-overview.md). (If you're not familiar with Compose, it's recommended that +you go through the quick start first.) This example assumes you want to use +Compose both to develop an application locally and then deploy it to a +production environment. + +The local and production environments are similar, but there are some +differences. In development, you mount the application code as a volume so that +it can pick up changes; in production, the code should be immutable from the +outside. This ensures it’s not accidentally changed. The development environment +uses a local Redis container, but in production another team manages the Redis +service, which is listening at `redis-production.example.com`. + +To configure with `extends` for this sample, you must: + +1. Define the web application as a Docker image in `Dockerfile` and a Compose + service in `common.yml`. + +2. Define the development environment in the standard Compose file, + `docker-compose.yml`. + + - Use `extends` to pull in the web service. + - Configure a volume to enable code reloading. + - Create an additional Redis service for the application to use locally. + +3. Define the production environment in a third Compose file, `production.yml`. + + - Use `extends` to pull in the web service. + - Configure the web service to talk to the external, production Redis service. + +#### Define the web app + +Defining the web application requires the following: + +1. Create an `app.py` file. + + This file contains a simple Python application that uses Flask to serve HTTP + and increments a counter in Redis: + + from flask import Flask + from redis import Redis + import os + + app = Flask(__name__) + redis = Redis(host=os.environ['REDIS_HOST'], port=6379) + + @app.route('/') + def hello(): + redis.incr('hits') + return 'Hello World! I have been seen %s times.\n' % redis.get('hits') + + if __name__ == "__main__": + app.run(host="0.0.0.0", debug=True) + + This code uses a `REDIS_HOST` environment variable to determine where to + find Redis. + +2. Define the Python dependencies in a `requirements.txt` file: + + flask + redis + +3. Create a `Dockerfile` to build an image containing the app: + + FROM python:2.7 + ADD . /code + WORKDIR /code + RUN pip install -r requirements.txt + CMD python app.py + +4. Create a Compose configuration file called `common.yml`: + + This configuration defines how to run the app. + + web: + build: . + ports: + - "5000:5000" + + Typically, you would have dropped this configuration into + `docker-compose.yml` file, but in order to pull it into multiple files with + `extends`, it needs to be in a separate file. + +#### Define the development environment + +1. Create a `docker-compose.yml` file. + + The `extends` option pulls in the `web` service from the `common.yml` file + you created in the previous section. + + web: + extends: + file: common.yml + service: web + volumes: + - .:/code + links: + - redis + environment: + - REDIS_HOST=redis + redis: + image: redis + + The new addition defines a `web` service that: + + - Fetches the base configuration for `web` out of `common.yml`. + - Adds `volumes` and `links` configuration to the base (`common.yml`) + configuration. + - Sets the `REDIS_HOST` environment variable to point to the linked redis + container. This environment uses a stock `redis` image from the Docker Hub. + +2. Run `docker-compose up`. + + Compose creates, links, and starts a web and redis container linked together. + It mounts your application code inside the web container. + +3. Verify that the code is mounted by changing the message in + `app.py`—say, from `Hello world!` to `Hello from Compose!`. + + Don't forget to refresh your browser to see the change! + +#### Define the production environment + +You are almost done. Now, define your production environment: + +1. Create a `production.yml` file. + + As with `docker-compose.yml`, the `extends` option pulls in the `web` service + from `common.yml`. + + web: + extends: + file: common.yml + service: web + environment: + - REDIS_HOST=redis-production.example.com + +2. Run `docker-compose -f production.yml up`. + + Compose creates *just* a web container and configures the Redis connection via + the `REDIS_HOST` environment variable. This variable points to the production + Redis instance. + + > **Note**: If you try to load up the webapp in your browser you'll get an + > error—`redis-production.example.com` isn't actually a Redis server. + +You've now done a basic `extends` configuration. As your application develops, +you can make any necessary changes to the web service in `common.yml`. Compose +picks up both the development and production environments when you next run +`docker-compose`. You don't have to do any copy-and-paste, and you don't have to +manually keep both environments in sync. + + +### Reference + +You can use `extends` on any service together with other configuration keys. It +always expects a dictionary that should always contain two keys: `file` and +`service`. + +The `file` key specifies which file to look in. It can be an absolute path or a +relative one—if relative, it's treated as relative to the current file. + +The `service` key specifies the name of the service to extend, for example `web` +or `database`. + +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 them. + +#### Adding and overriding configuration + +Compose copies configurations from the original service over to the local one, +**except** for `links` and `volumes_from`. These exceptions exist to avoid +implicit dependencies—you always define `links` and `volumes_from` +locally. This ensures dependencies between services are clearly visible when +reading the current file. Defining these locally also ensures changes to the +referenced file don't result in breakage. + +If a configuration option is defined in both the original service and the local +service, the local value either *override*s or *extend*s the definition of the +original service. This works differently for other configuration options. + +For single-value options like `image`, `command` or `mem_limit`, the new value +replaces the old value. **This is the default behaviour - all exceptions are +listed below.** + +```yaml +# original service +command: python app.py + +# local service +command: python otherapp.py + +# result +command: python otherapp.py +``` + +In the case of `build` and `image`, using one in the local service causes +Compose to discard the other, if it was defined in the original service. + +```yaml +# original service +build: . + +# local service +image: redis + +# result +image: redis +``` + +```yaml +# original service +image: redis + +# local service +build: . + +# result +build: . +``` + +For the **multi-value options** `ports`, `expose`, `external_links`, `dns` and +`dns_search`, Compose concatenates both sets of values: + +```yaml +# original service +expose: + - "3000" + +# local service +expose: + - "4000" + - "5000" + +# result +expose: + - "3000" + - "4000" + - "5000" +``` + +In the case of `environment` and `labels`, Compose "merges" entries together +with locally-defined values taking precedence: + +```yaml +# original service +environment: + - FOO=original + - BAR=original + +# local service +environment: + - BAR=local + - BAZ=local + +# result +environment: + - FOO=original + - BAR=local + - BAZ=local +``` + +Finally, for `volumes` and `devices`, Compose "merges" entries together with +locally-defined bindings taking precedence: + +```yaml +# original service +volumes: + - /original-dir/foo:/foo + - /original-dir/bar:/bar + +# local service +volumes: + - /local-dir/bar:/bar + - /local-dir/baz/:baz + +# result +volumes: + - /original-dir/foo:/foo + - /local-dir/bar:/bar + - /local-dir/baz/:baz +``` + +## Compose documentation + +- [User guide](compose-overview.md) +- [Installing Compose](install.md) +- [Get started with Django](django.md) +- [Get started with Rails](rails.md) +- [Get started with Wordpress](wordpress.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose command line completion](completion.md) diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 00000000..cf1ca922 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,67 @@ + + + +# Install Docker Compose + +To install Compose, you'll need to install Docker first. You'll then install +Compose with a `curl` command. + +## Install Docker + +First, install Docker version 1.6 or greater: + +- [Instructions for Mac OS X](http://docs.docker.com/installation/mac/) +- [Instructions for Ubuntu](http://docs.docker.com/installation/ubuntulinux/) +- [Instructions for other systems](http://docs.docker.com/installation/) + +## Install Compose + +To install Compose, run the following commands: + + curl -L https://github.com/docker/compose/releases/download/1.3.0rc3/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose + chmod +x /usr/local/bin/docker-compose + +> Note: If you get a "Permission denied" error, your `/usr/local/bin` directory probably isn't writable and you'll need to install Compose as the superuser. Run `sudo -i`, then the two commands above, then `exit`. + +Optionally, you can also install [command completion](completion.md) for the +bash shell. + +Compose is available for OS X and 64-bit Linux. If you're on another platform, +Compose can also be installed as a Python package: + + $ sudo pip install -U docker-compose + +No further steps are required; Compose should now be successfully installed. +You can test the installation by running `docker-compose --version`. + +### Upgrading + +If you're coming from Compose 1.2 or earlier, you'll need to 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 so they need to be recreated with labels added. + +If Compose detects containers that were created without labels, it will refuse 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 migrate them with the following command: + + docker-compose migrate-to-labels + +Alternatively, if you're not worried about keeping them, you can remove them - Compose will just create new ones. + + docker rm -f myapp_web_1 myapp_db_1 ... + +## Compose documentation + +- [User guide](compose-overview.md) +- [Get started with Django](django.md) +- [Get started with Rails](rails.md) +- [Get started with Wordpress](wordpress.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) diff --git a/docs/production.md b/docs/production.md new file mode 100644 index 00000000..294f3c4e --- /dev/null +++ b/docs/production.md @@ -0,0 +1,96 @@ + + + +## Using Compose in production + +While **Compose is not yet considered production-ready**, if you'd like to experiment and learn more about using it in production deployments, this guide +can help. +The project is actively working towards becoming +production-ready; to learn more about the progress being made, check out the +[roadmap](https://github.com/docker/compose/blob/master/ROADMAP.md) for details +on how it's coming along and what still needs to be done. + +When deploying to production, you'll almost certainly want to make changes to +your app configuration that are more appropriate to a live environment. These +changes may include: + +- Removing any volume bindings for application code, so that code stays inside + the container and can't be changed from outside +- Binding to different ports on the host +- Setting environment variables differently (e.g., to decrease the verbosity of + logging, or to enable email sending) +- Specifying a restart policy (e.g., `restart: always`) to avoid downtime +- Adding extra services (e.g., a log aggregator) + +For this reason, you'll probably want to define a separate Compose file, say +`production.yml`, which specifies production-appropriate configuration. + +> **Note:** The [extends](extends.md) keyword is useful for maintaining multiple +> Compose files which re-use common services without having to manually copy and +> paste. + +Once you've got an alternate configuration file, make Compose use it +by setting the `COMPOSE_FILE` environment variable: + + $ COMPOSE_FILE=production.yml + $ docker-compose up -d + +> **Note:** You can also use the file for a one-off command without setting +> an environment variable. You do this by passing the `-f` flag, e.g., +> `docker-compose -f production.yml up -d`. + +### Deploying changes + +When you make changes to your app code, you'll need to rebuild your image and +recreate your app's containers. To redeploy a service called +`web`, you would use: + + $ docker-compose build web + $ docker-compose up --no-deps -d web + +This will first rebuild the image for `web` and then stop, destroy, and recreate +*just* the `web` service. The `--no-deps` flag prevents Compose from also +recreating any services which `web` depends on. + +### Running Compose on a single server + +You can use Compose to deploy an app to a remote Docker host by setting the +`DOCKER_HOST`, `DOCKER_TLS_VERIFY`, and `DOCKER_CERT_PATH` environment variables +appropriately. For tasks like this, +[Docker Machine](https://docs.docker.com/machine) makes managing local and +remote Docker hosts very easy, and is recommended even if you're not deploying +remotely. + +Once you've set up your environment variables, all the normal `docker-compose` +commands will work with no further configuration. + +### Running Compose on a Swarm cluster + +[Docker Swarm](https://docs.docker.com/swarm), a Docker-native clustering +system, exposes the same API as a single Docker host, which means you can use +Compose against a Swarm instance and run your apps across multiple hosts. + +Compose/Swarm integration is still in the experimental stage, and Swarm is still +in beta, but if you'd like to explore and experiment, check out the +[integration guide](https://github.com/docker/compose/blob/master/SWARM.md). + +## Compose documentation + +- [Installing Compose](install.md) +- [Get started with Django](django.md) +- [Get started with Rails](rails.md) +- [Get started with Wordpress](wordpress.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) + diff --git a/docs/rails.md b/docs/rails.md new file mode 100644 index 00000000..2ff6f175 --- /dev/null +++ b/docs/rails.md @@ -0,0 +1,135 @@ + + +## Quickstart Guide: Compose and Rails + +This Quickstart guide will show you how to use Compose to set up and run a Rails/PostgreSQL app. Before starting, you'll need to have [Compose installed](install.md). + +### Define the project + +Start by setting up the three files you'll need to build the app. First, since +your app is going to run inside a Docker container containing all of its +dependencies, you'll need to define exactly what needs to be included in the +container. This is done using a file called `Dockerfile`. To begin with, the +Dockerfile consists of: + + FROM ruby:2.2.0 + RUN apt-get update -qq && apt-get install -y build-essential libpq-dev + RUN mkdir /myapp + WORKDIR /myapp + ADD Gemfile /myapp/Gemfile + RUN bundle install + ADD . /myapp + +That'll put your application code inside an image that will build a container with Ruby, Bundler and all your dependencies inside it. For more information on how to write Dockerfiles, see the [Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the [Dockerfile reference](http://docs.docker.com/reference/builder/). + +Next, create a bootstrap `Gemfile` which just loads Rails. It'll be overwritten in a moment by `rails new`. + + source 'https://rubygems.org' + gem 'rails', '4.2.0' + +Finally, `docker-compose.yml` is where the magic happens. This file describes the services that comprise your app (a database and a web app), how to get each one's Docker image (the database just runs on a pre-made PostgreSQL image, and the web app is built from the current directory), and the configuration needed to link them together and expose the web app's port. + + db: + image: postgres + ports: + - "5432" + web: + build: . + command: bundle exec rails s -p 3000 -b '0.0.0.0' + volumes: + - .:/myapp + ports: + - "3000:3000" + links: + - db + +### Build the project + +With those three files in place, you can now generate the Rails skeleton app +using `docker-compose run`: + + $ docker-compose run web rails new . --force --database=postgresql --skip-bundle + +First, Compose will build the image for the `web` service using the +`Dockerfile`. Then it'll run `rails new` inside a new container, using that +image. Once it's done, you should have generated a fresh app: + + $ ls + Dockerfile app docker-compose.yml tmp + Gemfile bin lib vendor + Gemfile.lock config log + README.rdoc config.ru public + Rakefile db test + +Uncomment the line in your new `Gemfile` which loads `therubyracer`, so you've +got a Javascript runtime: + + gem 'therubyracer', platforms: :ruby + +Now that you've got a new `Gemfile`, you need to build the image again. (This, +and changes to the Dockerfile itself, should be the only times you'll need to +rebuild.) + + $ docker-compose build + +### Connect the database + +The app is now bootable, but you're not quite there yet. By default, Rails +expects a database to be running on `localhost` - so you need to point it at the +`db` container instead. You also need to change the database and username to +align with the defaults set by the `postgres` image. + +Open up your newly-generated `database.yml` file. Replace its contents with the +following: + + development: &default + adapter: postgresql + encoding: unicode + database: postgres + pool: 5 + username: postgres + password: + host: db + + test: + <<: *default + database: myapp_test + +You can now boot the app with: + + $ docker-compose up + +If all's well, you should see some PostgreSQL output, and then—after a few +seconds—the familiar refrain: + + myapp_web_1 | [2014-01-17 17:16:29] INFO WEBrick 1.3.1 + myapp_web_1 | [2014-01-17 17:16:29] INFO ruby 2.2.0 (2014-12-25) [x86_64-linux-gnu] + myapp_web_1 | [2014-01-17 17:16:29] INFO WEBrick::HTTPServer#start: pid=1 port=3000 + +Finally, you need to create the database. In another terminal, run: + + $ docker-compose run web rake db:create + +That's it. Your app should now be running on port 3000 on your Docker daemon (if +you're using Boot2docker, `boot2docker ip` will tell you its address). + +## More Compose documentation + +- [User guide](compose-overview.md) +- [Installing Compose](install.md) +- [Get started with Django](django.md) +- [Get started with Rails](rails.md) +- [Get started with Wordpress](wordpress.md) +- [Command line reference](cli.md) +- [Yaml file reference](yml.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) diff --git a/docs/wordpress.md b/docs/wordpress.md new file mode 100644 index 00000000..ad0e6296 --- /dev/null +++ b/docs/wordpress.md @@ -0,0 +1,132 @@ + + + +# Quickstart Guide: Compose and Wordpress + +You can use Compose to easily run Wordpress in an isolated environment built +with Docker containers. + +## Define the project + +First, [Install Compose](install.md) and then download Wordpress into the +current directory: + + $ curl https://wordpress.org/latest.tar.gz | tar -xvzf - + +This will create a directory called `wordpress`. If you wish, you can rename it +to the name of your project. + +Next, inside that directory, create a `Dockerfile`, a file that defines what +environment your app is going to run in. For more information on how to write +Dockerfiles, see the +[Docker user guide](https://docs.docker.com/userguide/dockerimages/#building-an-image-from-a-dockerfile) and the +[Dockerfile reference](http://docs.docker.com/reference/builder/). In this case, +your Dockerfile should be: + +``` +FROM orchardup/php5 +ADD . /code +``` + +This tells Docker how to build an image defining a container that contains PHP +and Wordpress. + +Next you'll create a `docker-compose.yml` file that will start your web service +and a separate MySQL instance: + +``` +web: + build: . + command: php -S 0.0.0.0:8000 -t /code + ports: + - "8000:8000" + links: + - db + volumes: + - .:/code +db: + image: orchardup/mysql + environment: + MYSQL_DATABASE: wordpress +``` + +Two supporting files are needed to get this working - first, `wp-config.php` is +the standard Wordpress config file with a single change to point the database +configuration at the `db` container: + +``` + ++++ +title = "docker-compose.yml reference" +description = "docker-compose.yml reference" +keywords = ["fig, composition, compose, docker"] +[menu.main] +parent="smn_compose_ref" ++++ + + + +# docker-compose.yml reference + +Each service defined in `docker-compose.yml` must specify exactly one of +`image` or `build`. Other keys are optional, and are analogous to their +`docker run` command-line counterparts. + +As with `docker run`, options specified in the Dockerfile (e.g., `CMD`, +`EXPOSE`, `VOLUME`, `ENV`) are respected by default - you don't need to +specify them again in `docker-compose.yml`. + +### image + +Tag or partial image ID. Can be local or remote - Compose will attempt to +pull if it doesn't exist locally. + +``` +image: ubuntu +image: orchardup/postgresql +image: a4bc65fd +``` + +### build + +Path to a directory containing a Dockerfile. When the value supplied is a +relative path, it is interpreted as relative to the location of the yml file +itself. This directory is also the build context that is sent to the Docker daemon. + +Compose will build and tag it with a generated name, and use that image thereafter. + +``` +build: /path/to/build/dir +``` + +### dockerfile + +Alternate Dockerfile. + +Compose will use an alternate file to build with. + +``` +dockerfile: Dockerfile-alternate +``` + +### command + +Override the default command. + +``` +command: bundle exec thin -p 3000 +``` + + +### links + +Link to containers in another service. Either specify both the service name and +the link alias (`SERVICE:ALIAS`), or just the service name (which will also be +used for the alias). + +``` +links: + - db + - db:database + - redis +``` + +An entry with the alias' name will be created in `/etc/hosts` inside containers +for this service, e.g: + +``` +172.17.2.186 db +172.17.2.186 database +172.17.2.187 redis +``` + +Environment variables will also be created - see the [environment variable +reference](env.md) for details. + +### 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 `links` when specifying both the +container name and the link alias (`CONTAINER:ALIAS`). + +``` +external_links: + - redis_1 + - project_db_1:mysql + - project_db_1:postgresql +``` + +### extra_hosts + +Add hostname mappings. Use the same values as the docker client `--add-host` parameter. + +``` +extra_hosts: + - "somehost:162.242.195.82" + - "otherhost:50.31.209.229" +``` + +An entry with the ip address and hostname will be created in `/etc/hosts` inside containers for this service, e.g: + +``` +162.242.195.82 somehost +50.31.209.229 otherhost +``` + +### ports + +Expose ports. Either specify both ports (`HOST:CONTAINER`), or just the container +port (a random host port will be 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 will +> parse numbers in the format `xx:yy` as sexagesimal (base 60). For this reason, +> we recommend always explicitly specifying your port mappings as strings. + +``` +ports: + - "3000" + - "8000:8000" + - "49100:22" + - "127.0.0.1:8001:8001" +``` + +### 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. + +``` +expose: + - "3000" + - "8000" +``` + +### volumes + +Mount paths as volumes, optionally specifying a path on the host machine +(`HOST:CONTAINER`), or an access mode (`HOST:CONTAINER:ro`). + +``` +volumes: + - /var/lib/mysql + - cache/:/tmp/cache + - ~/configs:/etc/configs/:ro +``` + +### volumes_from + +Mount all of the volumes from another service or container. + +``` +volumes_from: + - service_name + - container_name +``` + +### environment + +Add environment variables. You can use either an array or a dictionary. + +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. + +``` +environment: + RACK_ENV: development + SESSION_SECRET: + +environment: + - RACK_ENV=development + - SESSION_SECRET +``` + +### 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 specified in `environment` override these values. + +``` +env_file: .env + +env_file: + - ./common.env + - ./apps/web.env + - /opt/secrets.env +``` + +Compose expects each line in an env file to be in `VAR=VAL` format. Lines +beginning with `#` (i.e. comments) are ignored, as are blank lines. + +``` +# Set Rails/Rack environment +RACK_ENV=development +``` + +### extends + +Extend another service, in the current file or another, optionally overriding +configuration. + +Here's a simple example. Suppose we have 2 files - **common.yml** and +**development.yml**. We can use `extends` to define a service in +**development.yml** which uses configuration defined in **common.yml**: + +**common.yml** + +``` +webapp: + build: ./webapp + environment: + - DEBUG=false + - SEND_EMAILS=false +``` + +**development.yml** + +``` +web: + extends: + file: common.yml + service: webapp + ports: + - "8000:8000" + links: + - db + environment: + - DEBUG=true +db: + image: postgres +``` + +Here, the `web` service in **development.yml** inherits the configuration of +the `webapp` service in **common.yml** - the `build` and `environment` keys - +and adds `ports` and `links` configuration. It overrides one of the defined +environment variables (DEBUG) with a new value, and the other one +(SEND_EMAILS) is left untouched. + +For more on `extends`, see the [tutorial](extends.md#example) and +[reference](extends.md#reference). + +### labels + +Add metadata to containers using [Docker labels](http://docs.docker.com/userguide/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. + +``` +labels: + com.example.description: "Accounting webapp" + com.example.department: "Finance" + com.example.label-with-empty-value: "" + +labels: + - "com.example.description=Accounting webapp" + - "com.example.department=Finance" + - "com.example.label-with-empty-value" +``` + +### log driver + +Specify a logging driver for the service's containers, as with the ``--log-driver`` option for docker run ([documented here](http://docs.docker.com/reference/run/#logging-drivers-log-driver)). + +Allowed values are currently ``json-file``, ``syslog`` and ``none``. The list will change over time as more drivers are added to the Docker engine. + +The default value is json-file. + +``` +log_driver: "json-file" +log_driver: "syslog" +log_driver: "none" +``` + +### net + +Networking mode. Use the same values as the docker client `--net` parameter. + +``` +net: "bridge" +net: "none" +net: "container:[name or id]" +net: "host" +``` +### pid + +``` +pid: "host" +``` + +Sets the PID mode to the host PID mode. This turns on sharing between +container and the host operating system the PID address space. Containers +launched with this flag will be able to access and manipulate other +containers in the bare-metal machine's namespace and vise-versa. + +### dns + +Custom DNS servers. Can be a single value or a list. + +``` +dns: 8.8.8.8 +dns: + - 8.8.8.8 + - 9.9.9.9 +``` + +### cap_add, cap_drop + +Add or drop container capabilities. +See `man 7 capabilities` for a full list. + +``` +cap_add: + - ALL + +cap_drop: + - NET_ADMIN + - SYS_ADMIN +``` + +### dns_search + +Custom DNS search domains. Can be a single value or a list. + +``` +dns_search: example.com +dns_search: + - dc1.example.com + - dc2.example.com +``` + +### devices + +List of device mappings. Uses the same format as the `--device` docker +client create option. + +``` +devices: + - "/dev/ttyUSB0:/dev/ttyUSB0" +``` + +### security_opt + +Override the default labeling scheme for each container. + +``` +security_opt: + - label:user:USER + - label:role:ROLE +``` + +### working\_dir, entrypoint, user, hostname, domainname, mem\_limit, privileged, restart, stdin\_open, tty, cpu\_shares, cpuset, read\_only + +Each of these is a single value, analogous to its +[docker run](https://docs.docker.com/reference/run/) counterpart. + +``` +cpu_shares: 73 +cpuset: 0,1 + +working_dir: /code +entrypoint: /code/entrypoint.sh +user: postgresql + +hostname: foo +domainname: foo.com + +mem_limit: 1000000000 +privileged: true + +restart: always + +stdin_open: true +tty: true +read_only: true +``` + +## Compose documentation + +- [User guide](compose-overview.md) +- [Installing Compose](install.md) +- [Get started with Django](django.md) +- [Get started with Rails](rails.md) +- [Get started with Wordpress](wordpress.md) +- [Command line reference](cli.md) +- [Compose environment variables](env.md) +- [Compose command line completion](completion.md) diff --git a/experimental/compose_swarm_networking.md b/experimental/compose_swarm_networking.md deleted file mode 100644 index 905f52f8..00000000 --- a/experimental/compose_swarm_networking.md +++ /dev/null @@ -1,5 +0,0 @@ -# Experimental: Compose, Swarm and Multi-Host Networking - -Compose now supports multi-host networking as standard. Read more here: - -https://docs.docker.com/compose/networking diff --git a/logo.png b/logo.png deleted file mode 100644 index 9bc5eb2f..00000000 Binary files a/logo.png and /dev/null differ diff --git a/project/ISSUE-TRIAGE.md b/project/ISSUE-TRIAGE.md deleted file mode 100644 index b89cdc24..00000000 --- a/project/ISSUE-TRIAGE.md +++ /dev/null @@ -1,35 +0,0 @@ -Triaging of issues ------------------- - -The docker-compose issue triage process follows -https://github.com/docker/docker/blob/master/project/ISSUE-TRIAGE.md -with the following additions or exceptions. - - -### Classify the Issue - -The following labels are provided in additional to the standard labels: - -| Kind | Description | -|--------------|-------------------------------------------------------------------| -| kind/cleanup | A refactor or improvement that is related to quality not function | -| kind/parity | A request for feature parity with docker cli | - - -### Functional areas - -Most issues should fit into one of the following functional areas: - -| Area | -|-----------------| -| area/build | -| area/cli | -| area/config | -| area/logs | -| area/networking | -| area/packaging | -| area/run | -| area/scale | -| area/tests | -| area/up | -| area/volumes | diff --git a/project/RELEASE-PROCESS.md b/project/RELEASE-PROCESS.md deleted file mode 100644 index c1834f2f..00000000 --- a/project/RELEASE-PROCESS.md +++ /dev/null @@ -1,139 +0,0 @@ -Building a Compose release -========================== - -## Prerequisites - -The release scripts require the following tools installed on the host: - -* https://hub.github.com/ -* https://stedolan.github.io/jq/ -* http://pandoc.org/ - -## To get started with a new release - -Create a branch, update version, and add release notes by running `make-branch` - - ./script/release/make-branch $VERSION [$BASE_VERSION] - -`$BASE_VERSION` will default to master. Use the last version tag for a bug fix -release. - -As part of this script you'll be asked to: - -1. Update the version in `compose/__init__.py` and `script/run/run.sh`. - - If the next release will be an RC, append `-rcN`, e.g. `1.4.0-rc1`. - -2. Write release notes in `CHANGES.md`. - - Almost every feature enhancement should be mentioned, with the most - visible/exciting ones first. Use descriptive sentences and give context - where appropriate. - - Bug fixes are worth mentioning if it's likely that they've affected lots - of people, or if they were regressions in the previous version. - - Improvements to the code are not worth mentioning. - -3. Create a new repository on [bintray](https://bintray.com/docker-compose). - The name has to match the name of the branch (e.g. `bump-1.9.0`) and the - type should be "Generic". Other fields can be left blank. - -4. Check that the `vnext-compose` branch on - [the docs repo](https://github.com/docker/docker.github.io/) has - documentation for all the new additions in the upcoming release, and create - a PR there for what needs to be amended. - - -## When a PR is merged into master that we want in the release - -1. Check out the bump branch and run the cherry pick script - - git checkout bump-$VERSION - ./script/release/cherry-pick-pr $PR_NUMBER - -2. When you are done cherry-picking branches move the bump version commit to HEAD - - ./script/release/rebase-bump-commit - git push --force $USERNAME bump-$VERSION - - -## To release a version (whether RC or stable) - -Check out the bump branch and run the `build-binaries` script - - git checkout bump-$VERSION - ./script/release/build-binaries - -When prompted build the non-linux binaries and test them. - -1. Download the osx binary from Bintray. Make sure that the latest Travis - build has finished, otherwise you'll be downloading an old binary. - - https://dl.bintray.com/docker-compose/$BRANCH_NAME/ - -2. Download the windows binary from AppVeyor - - https://ci.appveyor.com/project/docker/compose - -3. Draft a release from the tag on GitHub (the script will open the window for - you) - - The tag will only be present on Github when you run the `push-release` - script in step 7, but you can pre-fill it at that point. - -4. Paste in installation instructions and release notes. Here's an example - - change the Compose version and Docker version as appropriate: - - If you're a Mac or Windows user, the best way to install Compose and keep it up-to-date is **[Docker for Mac and Windows](https://www.docker.com/products/docker)**. - - Note that Compose 1.9.0 requires Docker Engine 1.10.0 or later for version 2 of the Compose File format, and Docker Engine 1.9.1 or later for version 1. Docker for Mac and Windows will automatically install the latest version of Docker Engine for you. - - Alternatively, you can use the usual commands to install or upgrade Compose: - - ``` - curl -L https://github.com/docker/compose/releases/download/1.9.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose - chmod +x /usr/local/bin/docker-compose - ``` - - See the [install docs](https://docs.docker.com/compose/install/) for more install options and instructions. - - Here's what's new: - - ...release notes go here... - -5. Attach the binaries and `script/run/run.sh` - -6. Add "Thanks" with a list of contributors. The contributor list can be generated - by running `./script/release/contributors`. - -7. If everything looks good, it's time to push the release. - - - ./script/release/push-release - - -8. Merge the bump PR. - -8. Publish the release on GitHub. - -9. Check that all the binaries download (following the install instructions) and run. - -10. Email maintainers@dockerproject.org and engineering@docker.com about the new release. - -## If it’s a stable release (not an RC) - -1. Close the release’s milestone. - -## If it’s a minor release (1.x.0), rather than a patch release (1.x.y) - -1. Open a PR against `master` to: - - - update `CHANGELOG.md` to bring it in line with `release` - - bump the version in `compose/__init__.py` to the *next* minor version number with `dev` appended. For example, if you just released `1.4.0`, update it to `1.5.0dev`. - -2. Get the PR merged. - -## Finally - -1. Celebrate, however you’d like. diff --git a/requirements-build.txt b/requirements-build.txt deleted file mode 100644 index 27f610ca..00000000 --- a/requirements-build.txt +++ /dev/null @@ -1 +0,0 @@ -pyinstaller==3.2.1 diff --git a/requirements-dev.txt b/requirements-dev.txt index 73b80783..7b529623 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,6 @@ -coverage==3.7.1 -mock>=1.0.1 -pytest==2.7.2 -pytest-cov==2.1.0 +mock >= 1.0.1 +nose==1.3.4 +git+https://github.com/pyinstaller/pyinstaller.git@12e40471c77f588ea5be352f7219c873ddaae056#egg=pyinstaller +unittest2==0.8.0 +flake8==2.3.0 +pep8==1.6.1 diff --git a/requirements.txt b/requirements.txt index 3b06bff4..47fa1e05 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,8 @@ -PyYAML==3.11 -backports.ssl-match-hostname==3.5.0.1; python_version < '3' -cached-property==1.2.0 -colorama==0.3.7 -docker==2.0.2 -dockerpty==0.4.1 +PyYAML==3.10 +docker-py==1.2.3-rc1 +dockerpty==0.3.4 docopt==0.6.1 -enum34==1.0.4; python_version < '3.4' -functools32==3.2.3.post2; python_version < '3.2' -ipaddress==1.0.16 -jsonschema==2.5.1 -pypiwin32==219; sys_platform == 'win32' -requests==2.11.1 -six==1.10.0 -texttable==0.8.4 -websocket-client==0.32.0 +requests==2.6.1 +six==1.7.3 +texttable==0.8.2 +websocket-client==0.11.0 diff --git a/script/build-linux b/script/build-linux new file mode 100755 index 00000000..5e4a9470 --- /dev/null +++ b/script/build-linux @@ -0,0 +1,12 @@ +#!/bin/bash + +set -ex + +TAG="docker-compose" +docker build -t "$TAG" . +docker run \ + --rm \ + --user=user \ + --volume="$(pwd):/code" \ + --entrypoint="script/build-linux-inner" \ + "$TAG" diff --git a/script/build-linux-inner b/script/build-linux-inner new file mode 100755 index 00000000..34b0c06f --- /dev/null +++ b/script/build-linux-inner @@ -0,0 +1,10 @@ +#!/bin/bash + +set -ex + +mkdir -p `pwd`/dist +chmod 777 `pwd`/dist + +pyinstaller -F bin/docker-compose +mv dist/docker-compose dist/docker-compose-Linux-x86_64 +dist/docker-compose-Linux-x86_64 --version diff --git a/script/build/osx b/script/build-osx similarity index 51% rename from script/build/osx rename to script/build-osx index 3de34576..d6561aee 100755 --- a/script/build/osx +++ b/script/build-osx @@ -4,12 +4,10 @@ set -ex PATH="/usr/local/bin:$PATH" rm -rf venv - virtualenv -p /usr/local/bin/python venv venv/bin/pip install -r requirements.txt -venv/bin/pip install -r requirements-build.txt -venv/bin/pip install --no-deps . -./script/build/write-git-sha -venv/bin/pyinstaller docker-compose.spec +venv/bin/pip install -r requirements-dev.txt +venv/bin/pip install . +venv/bin/pyinstaller -F bin/docker-compose mv dist/docker-compose dist/docker-compose-Darwin-x86_64 -dist/docker-compose-Darwin-x86_64 version +dist/docker-compose-Darwin-x86_64 --version diff --git a/script/build/image b/script/build/image deleted file mode 100755 index 3590ce14..00000000 --- a/script/build/image +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - -set -e - -if [ -z "$1" ]; then - >&2 echo "First argument must be image tag." - exit 1 -fi - -TAG=$1 -VERSION="$(python setup.py --version)" - -./script/build/write-git-sha -python setup.py sdist bdist_wheel -docker build --build-arg version=$VERSION -t docker/compose:$TAG -f Dockerfile.run . diff --git a/script/build/linux b/script/build/linux deleted file mode 100755 index 1a4cd4d9..00000000 --- a/script/build/linux +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -set -ex - -./script/clean - -TAG="docker-compose" -docker build -t "$TAG" . | tail -n 200 -docker run \ - --rm --entrypoint="script/build/linux-entrypoint" \ - -v $(pwd)/dist:/code/dist \ - -v $(pwd)/.git:/code/.git \ - "$TAG" diff --git a/script/build/linux-entrypoint b/script/build/linux-entrypoint deleted file mode 100755 index bf515060..00000000 --- a/script/build/linux-entrypoint +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - -set -ex - -TARGET=dist/docker-compose-$(uname -s)-$(uname -m) -VENV=/code/.tox/py27 - -mkdir -p `pwd`/dist -chmod 777 `pwd`/dist - -$VENV/bin/pip install -q -r requirements-build.txt -./script/build/write-git-sha -su -c "$VENV/bin/pyinstaller docker-compose.spec" user -mv dist/docker-compose $TARGET -$TARGET version diff --git a/script/build/windows.ps1 b/script/build/windows.ps1 deleted file mode 100644 index db643274..00000000 --- a/script/build/windows.ps1 +++ /dev/null @@ -1,60 +0,0 @@ -# Builds the Windows binary. -# -# From a fresh 64-bit Windows 10 install, prepare the system as follows: -# -# 1. Install Git: -# -# http://git-scm.com/download/win -# -# 2. Install Python 2.7.10: -# -# https://www.python.org/downloads/ -# -# 3. Append ";C:\Python27;C:\Python27\Scripts" to the "Path" environment variable: -# -# https://www.microsoft.com/resources/documentation/windows/xp/all/proddocs/en-us/sysdm_advancd_environmnt_addchange_variable.mspx?mfr=true -# -# 4. In Powershell, run the following commands: -# -# $ pip install virtualenv -# $ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned -# -# 5. Clone the repository: -# -# $ git clone https://github.com/docker/compose.git -# $ cd compose -# -# 6. Build the binary: -# -# .\script\build\windows.ps1 - -$ErrorActionPreference = "Stop" - -# Remove virtualenv -if (Test-Path venv) { - Remove-Item -Recurse -Force .\venv -} - -# Remove .pyc files -Get-ChildItem -Recurse -Include *.pyc | foreach ($_) { Remove-Item $_.FullName } - -# Create virtualenv -virtualenv .\venv - -# pip and pyinstaller generate lots of warnings, so we need to ignore them -$ErrorActionPreference = "Continue" - -# Install dependencies -.\venv\Scripts\pip install pypiwin32==219 -.\venv\Scripts\pip install -r requirements.txt -.\venv\Scripts\pip install --no-deps . -.\venv\Scripts\pip install --allow-external pyinstaller -r requirements-build.txt - -git rev-parse --short HEAD | out-file -encoding ASCII compose\GITSHA - -# Build binary -.\venv\Scripts\pyinstaller .\docker-compose.spec -$ErrorActionPreference = "Stop" - -Move-Item -Force .\dist\docker-compose.exe .\dist\docker-compose-Windows-x86_64.exe -.\dist\docker-compose-Windows-x86_64.exe --version diff --git a/script/build/write-git-sha b/script/build/write-git-sha deleted file mode 100755 index d16743c6..00000000 --- a/script/build/write-git-sha +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -# -# Write the current commit sha to the file GITSHA. This file is included in -# packaging so that `docker-compose version` can include the git sha. -# -set -e -git rev-parse --short HEAD > compose/GITSHA diff --git a/script/ci b/script/ci index 7b3489a1..2e4ec919 100755 --- a/script/ci +++ b/script/ci @@ -1,8 +1,15 @@ #!/bin/bash +# This should be run inside a container built from the Dockerfile +# at the root of the repo: # -# Backwards compatiblity for jenkins -# -# TODO: remove this script after all current PRs and jenkins are updated with -# the new script/test/ci change +# $ TAG="docker-compose:$(git rev-parse --short HEAD)" +# $ docker build -t "$TAG" . +# $ docker run --rm --volume="/var/run/docker.sock:/var/run/docker.sock" --volume="$(pwd)/.git:/code/.git" -e "TAG=$TAG" --entrypoint="script/ci" "$TAG" + set -e -exec script/test/ci + +export DOCKER_VERSIONS=all +. script/test-versions + +>&2 echo "Building Linux binary" +su -c script/build-linux-inner user diff --git a/script/clean b/script/clean index fb7ba3be..07a9cff1 100755 --- a/script/clean +++ b/script/clean @@ -1,7 +1,3 @@ #!/bin/sh -set -e - find . -type f -name '*.pyc' -delete -find . -name .coverage.* -delete -find . -name __pycache__ -delete rm -rf docs/_site build dist docker-compose.egg-info diff --git a/script/dev b/script/dev new file mode 100755 index 00000000..80b3d013 --- /dev/null +++ b/script/dev @@ -0,0 +1,21 @@ +#!/bin/bash +# This is a script for running Compose inside a Docker container. It's handy for +# development. +# +# $ ln -s `pwd`/script/dev /usr/local/bin/docker-compose +# $ cd /a/compose/project +# $ docker-compose up +# + +set -e + +# Follow symbolic links +if [ -h "$0" ]; then + DIR=$(readlink "$0") +else + DIR=$0 +fi +DIR="$(dirname "$DIR")"/.. + +docker build -t docker-compose $DIR +exec docker run -i -t -v /var/run/docker.sock:/var/run/docker.sock -v `pwd`:`pwd` -w `pwd` docker-compose $@ diff --git a/script/dind b/script/dind new file mode 100755 index 00000000..f8fae637 --- /dev/null +++ b/script/dind @@ -0,0 +1,88 @@ +#!/bin/bash +set -e + +# DinD: a wrapper script which allows docker to be run inside a docker container. +# Original version by Jerome Petazzoni +# See the blog post: http://blog.docker.com/2013/09/docker-can-now-run-within-docker/ +# +# This script should be executed inside a docker container in privilieged mode +# ('docker run --privileged', introduced in docker 0.6). + +# Usage: dind CMD [ARG...] + +# apparmor sucks and Docker needs to know that it's in a container (c) @tianon +export container=docker + +# First, make sure that cgroups are mounted correctly. +CGROUP=/cgroup + +mkdir -p "$CGROUP" + +if ! mountpoint -q "$CGROUP"; then + mount -n -t tmpfs -o uid=0,gid=0,mode=0755 cgroup $CGROUP || { + echo >&2 'Could not make a tmpfs mount. Did you use --privileged?' + exit 1 + } +fi + +if [ -d /sys/kernel/security ] && ! mountpoint -q /sys/kernel/security; then + mount -t securityfs none /sys/kernel/security || { + echo >&2 'Could not mount /sys/kernel/security.' + echo >&2 'AppArmor detection and -privileged mode might break.' + } +fi + +# Mount the cgroup hierarchies exactly as they are in the parent system. +for SUBSYS in $(cut -d: -f2 /proc/1/cgroup); do + mkdir -p "$CGROUP/$SUBSYS" + if ! mountpoint -q $CGROUP/$SUBSYS; then + mount -n -t cgroup -o "$SUBSYS" cgroup "$CGROUP/$SUBSYS" + fi + + # The two following sections address a bug which manifests itself + # by a cryptic "lxc-start: no ns_cgroup option specified" when + # trying to start containers withina container. + # The bug seems to appear when the cgroup hierarchies are not + # mounted on the exact same directories in the host, and in the + # container. + + # Named, control-less cgroups are mounted with "-o name=foo" + # (and appear as such under /proc//cgroup) but are usually + # mounted on a directory named "foo" (without the "name=" prefix). + # Systemd and OpenRC (and possibly others) both create such a + # cgroup. To avoid the aforementioned bug, we symlink "foo" to + # "name=foo". This shouldn't have any adverse effect. + name="${SUBSYS#name=}" + if [ "$name" != "$SUBSYS" ]; then + ln -s "$SUBSYS" "$CGROUP/$name" + fi + + # Likewise, on at least one system, it has been reported that + # systemd would mount the CPU and CPU accounting controllers + # (respectively "cpu" and "cpuacct") with "-o cpuacct,cpu" + # but on a directory called "cpu,cpuacct" (note the inversion + # in the order of the groups). This tries to work around it. + if [ "$SUBSYS" = 'cpuacct,cpu' ]; then + ln -s "$SUBSYS" "$CGROUP/cpu,cpuacct" + fi +done + +# Note: as I write those lines, the LXC userland tools cannot setup +# a "sub-container" properly if the "devices" cgroup is not in its +# own hierarchy. Let's detect this and issue a warning. +if ! grep -q :devices: /proc/1/cgroup; then + echo >&2 'WARNING: the "devices" cgroup should be in its own hierarchy.' +fi +if ! grep -qw devices /proc/1/cgroup; then + echo >&2 'WARNING: it looks like the "devices" cgroup is not mounted.' +fi + +# Mount /tmp +mount -t tmpfs none /tmp + +if [ $# -gt 0 ]; then + exec "$@" +fi + +echo >&2 'ERROR: No command specified.' +echo >&2 'You probably want to run hack/make.sh, or maybe a shell?' diff --git a/script/docs b/script/docs new file mode 100755 index 00000000..31c58861 --- /dev/null +++ b/script/docs @@ -0,0 +1,11 @@ +#!/bin/sh +set -ex + +# import the existing docs build cmds from docker/docker +DOCSPORT=8000 +GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) +DOCKER_DOCS_IMAGE="compose-docs$GIT_BRANCH" +DOCKER_RUN_DOCS="docker run --rm -it -e NOCACHE" + +docker build -t "$DOCKER_DOCS_IMAGE" -f docs/Dockerfile . +$DOCKER_RUN_DOCS -p $DOCSPORT:8000 "$DOCKER_DOCS_IMAGE" mkdocs serve diff --git a/script/setup/osx b/script/prepare-osx similarity index 76% rename from script/setup/osx rename to script/prepare-osx index e6ab62a8..ca2776b6 100755 --- a/script/setup/osx +++ b/script/prepare-osx @@ -10,13 +10,13 @@ openssl_version() { python -c "import ssl; print ssl.OPENSSL_VERSION" } -desired_python_version="2.7.12" -desired_python_brew_version="2.7.12" -python_formula="https://raw.githubusercontent.com/Homebrew/homebrew-core/737a2e34a89b213c1f0a2a24fc1a3c06635eed04/Formula/python.rb" +desired_python_version="2.7.9" +desired_python_brew_version="2.7.9" +python_formula="https://raw.githubusercontent.com/Homebrew/homebrew/1681e193e4d91c9620c4901efd4458d9b6fcda8e/Library/Formula/python.rb" -desired_openssl_version="1.0.2j" -desired_openssl_brew_version="1.0.2j" -openssl_formula="https://raw.githubusercontent.com/Homebrew/homebrew-core/30d3766453347f6e22b3ed6c74bb926d6def2eb5/Formula/openssl.rb" +desired_openssl_version="1.0.1j" +desired_openssl_brew_version="1.0.1j_1" +openssl_formula="https://raw.githubusercontent.com/Homebrew/homebrew/62fc2a1a65e83ba9dbb30b2e0a2b7355831c714b/Library/Formula/openssl.rb" PATH="/usr/local/bin:$PATH" @@ -24,7 +24,7 @@ if !(which brew); then ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" fi -brew update > /dev/null +brew update if !(python_version | grep "$desired_python_version"); then if brew list | grep python; then diff --git a/script/release/build-binaries b/script/release/build-binaries deleted file mode 100755 index 9d4a606e..00000000 --- a/script/release/build-binaries +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash -# -# Build the release binaries -# - -. "$(dirname "${BASH_SOURCE[0]}")/utils.sh" - -function usage() { - >&2 cat << EOM -Build binaries for the release. - -This script requires that 'git config branch.${BRANCH}.release' is set to the -release version for the release branch. - -EOM - exit 1 -} - -BRANCH="$(git rev-parse --abbrev-ref HEAD)" -VERSION="$(git config "branch.${BRANCH}.release")" || usage -REPO=docker/compose - -# Build the binaries -script/clean -script/build/linux - -echo "Building the container distribution" -script/build/image $VERSION - -echo "Create a github release" -# TODO: script more of this https://developer.github.com/v3/repos/releases/ -browser https://github.com/$REPO/releases/new - -echo "Don't forget to download the osx and windows binaries from appveyor/bintray\!" -echo "https://dl.bintray.com/docker-compose/$BRANCH/" -echo "https://ci.appveyor.com/project/docker/compose" -echo diff --git a/script/release/cherry-pick-pr b/script/release/cherry-pick-pr deleted file mode 100755 index f4a5a740..00000000 --- a/script/release/cherry-pick-pr +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -# -# Cherry-pick a PR into the release branch -# - -set -e -set -o pipefail - - -function usage() { - >&2 cat << EOM -Cherry-pick commits from a github pull request. - -Usage: - - $0 -EOM - exit 1 -} - -[ -n "$1" ] || usage - -if [ -z "$(command -v hub 2> /dev/null)" ]; then - >&2 echo "$0 requires https://hub.github.com/." - >&2 echo "Please install it and make sure it is available on your \$PATH." - exit 2 -fi - - -REPO=docker/compose -GITHUB=https://github.com/$REPO/pull -PR=$1 -url="$GITHUB/$PR" -hub am -3 $url diff --git a/script/release/contributors b/script/release/contributors deleted file mode 100755 index 4657dd80..00000000 --- a/script/release/contributors +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/bash -set -e - - -function usage() { - >&2 cat << EOM -Print the list of github contributors for the release - -Usage: - - $0 -EOM - exit 1 -} - -[[ -n "$1" ]] || usage -PREV_RELEASE=$1 -BRANCH="$(git rev-parse --abbrev-ref HEAD)" -URL="https://api.github.com/repos/docker/compose/compare" - -contribs=$(curl -sf "$URL/$PREV_RELEASE...$BRANCH" | \ - jq -r '.commits[].author.login' | \ - sort | \ - uniq -c | \ - sort -nr) - -echo "Contributions by user: " -echo "$contribs" -echo -echo "$contribs" | awk '{print "@"$2","}' | xargs diff --git a/script/release/make-branch b/script/release/make-branch deleted file mode 100755 index 7ccf3f05..00000000 --- a/script/release/make-branch +++ /dev/null @@ -1,87 +0,0 @@ -#!/bin/bash -# -# Prepare a new release branch -# - -. "$(dirname "${BASH_SOURCE[0]}")/utils.sh" - -function usage() { - >&2 cat << EOM -Create a new release branch 'release-' - -Usage: - - $0 [] - -Options: - - version version string for the release (ex: 1.6.0) - base_version branch or tag to start from. Defaults to master. For - bug-fix releases use the previous stage release tag. - -EOM - exit 1 -} - - -[ -n "$1" ] || usage -VERSION=$1 -BRANCH=bump-$VERSION -REPO=docker/compose -GITHUB_REPO=git@github.com:$REPO - -if [ -z "$2" ]; then - BASE_VERSION="master" -else - BASE_VERSION=$2 -fi - - -DEFAULT_REMOTE=release -REMOTE="$(find_remote "$GITHUB_REPO")" -# If we don't have a docker remote add one -if [ -z "$REMOTE" ]; then - echo "Creating $DEFAULT_REMOTE remote" - git remote add ${DEFAULT_REMOTE} ${GITHUB_REPO} -fi - -# handle the difference between a branch and a tag -if [ -z "$(git name-rev --tags $BASE_VERSION | grep tags)" ]; then - BASE_VERSION=$REMOTE/$BASE_VERSION -fi - -echo "Creating a release branch $VERSION from $BASE_VERSION" -read -n1 -r -p "Continue? (ctrl+c to cancel)" -git fetch $REMOTE -p -git checkout -b $BRANCH $BASE_VERSION - -echo "Merging remote release branch into new release branch" -git merge --strategy=ours --no-edit $REMOTE/release - -# Store the release version for this branch in git, so that other release -# scripts can use it -git config "branch.${BRANCH}.release" $VERSION - - -editor=${EDITOR:-vim} - -echo "Update versions in docs/install.md, compose/__init__.py, script/run/run.sh" -$editor docs/install.md -$editor compose/__init__.py -$editor script/run/run.sh - - -echo "Write release notes in CHANGELOG.md" -browser "https://github.com/docker/compose/issues?q=milestone%3A$VERSION+is%3Aclosed" -$editor CHANGELOG.md - - -git diff -echo "Verify changes before commit. Exit the shell to commit changes" -$SHELL || true -git commit -a -m "Bump $VERSION" --signoff --no-verify - - -echo "Push branch to docker remote" -git push $REMOTE -browser https://github.com/$REPO/compare/docker:release...$BRANCH?expand=1 diff --git a/script/release/push-release b/script/release/push-release deleted file mode 100755 index 9db6f689..00000000 --- a/script/release/push-release +++ /dev/null @@ -1,78 +0,0 @@ -#!/bin/bash -# -# Create the official release -# - -. "$(dirname "${BASH_SOURCE[0]}")/utils.sh" - -function usage() { - >&2 cat << EOM -Publish a release by building all artifacts and pushing them. - -This script requires that 'git config branch.${BRANCH}.release' is set to the -release version for the release branch. - -EOM - exit 1 -} - -BRANCH="$(git rev-parse --abbrev-ref HEAD)" -VERSION="$(git config "branch.${BRANCH}.release")" || usage - -if [ -z "$(command -v jq 2> /dev/null)" ]; then - >&2 echo "$0 requires https://stedolan.github.io/jq/" - >&2 echo "Please install it and make sure it is available on your \$PATH." - exit 2 -fi - - -if [ -z "$(command -v pandoc 2> /dev/null)" ]; then - >&2 echo "$0 requires http://pandoc.org/" - >&2 echo "Please install it and make sure it is available on your \$PATH." - exit 2 -fi - -API=https://api.github.com/repos -REPO=docker/compose -GITHUB_REPO=git@github.com:$REPO - -# Check the build status is green -sha=$(git rev-parse HEAD) -url=$API/$REPO/statuses/$sha -build_status=$(curl -s $url | jq -r '.[0].state') -if [ -n "$SKIP_BUILD_CHECK" ]; then - echo "Skipping build status check..." -elif [[ "$build_status" != "success" ]]; then - >&2 echo "Build status is $build_status, but it should be success." - exit -1 -fi - -echo "Tagging the release as $VERSION" -git tag $VERSION -git push $GITHUB_REPO $VERSION - -echo "Uploading the docker image" -docker push docker/compose:$VERSION - -echo "Uploading package to PyPI" -pandoc -f markdown -t rst README.md -o README.rst -sed -i -e 's/logo.png?raw=true/https:\/\/github.com\/docker\/compose\/raw\/master\/logo.png?raw=true/' README.rst -./script/build/write-git-sha -python setup.py sdist bdist_wheel -if [ "$(command -v twine 2> /dev/null)" ]; then - twine upload ./dist/docker-compose-${VERSION/-/}.tar.gz ./dist/docker_compose-${VERSION/-/}-py2.py3-none-any.whl -else - python setup.py upload -fi - -echo "Testing pip package" -deactivate || true -virtualenv venv-test -source venv-test/bin/activate -pip install docker-compose==$VERSION -docker-compose version -deactivate -rm -rf venv-test - -echo "Now publish the github release, and test the downloads." -echo "Email maintainers@dockerproject.org and engineering@docker.com about the new release." diff --git a/script/release/rebase-bump-commit b/script/release/rebase-bump-commit deleted file mode 100755 index 3c2ae72b..00000000 --- a/script/release/rebase-bump-commit +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash -# -# Move the "bump to " commit to the HEAD of the branch -# - -. "$(dirname "${BASH_SOURCE[0]}")/utils.sh" - -function usage() { - >&2 cat << EOM -Move the "bump to " commit to the HEAD of the branch - -This script requires that 'git config branch.${BRANCH}.release' is set to the -release version for the release branch. - -EOM - exit 1 -} - - -BRANCH="$(git rev-parse --abbrev-ref HEAD)" -VERSION="$(git config "branch.${BRANCH}.release")" || usage - - -COMMIT_MSG="Bump $VERSION" -sha="$(git log --grep "$COMMIT_MSG\$" --format="%H")" -if [ -z "$sha" ]; then - >&2 echo "No commit with message \"$COMMIT_MSG\"" - exit 2 -fi -if [[ "$sha" == "$(git rev-parse HEAD)" ]]; then - >&2 echo "Bump commit already at HEAD" - exit 0 -fi - -commits=$(git log --format="%H" "$sha..HEAD" | wc -l | xargs echo) - -git rebase --onto $sha~1 HEAD~$commits $BRANCH -git cherry-pick $sha diff --git a/script/release/utils.sh b/script/release/utils.sh deleted file mode 100644 index 321c1fb7..00000000 --- a/script/release/utils.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash -# -# Util functions for release scripts -# - -set -e -set -o pipefail - - -function browser() { - local url=$1 - xdg-open $url || open $url -} - - -function find_remote() { - local url=$1 - for remote in $(git remote); do - git config --get remote.${remote}.url | grep $url > /dev/null && echo -n $remote - done - # Always return true, extra remotes cause it to return false - true -} diff --git a/script/run/run.ps1 b/script/run/run.ps1 deleted file mode 100644 index 47ec5469..00000000 --- a/script/run/run.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -# Run docker-compose in a container via boot2docker. -# -# The current directory will be mirrored as a volume and additional -# volumes (or any other options) can be mounted by using -# $Env:DOCKER_COMPOSE_OPTIONS. - -if ($Env:DOCKER_COMPOSE_VERSION -eq $null -or $Env:DOCKER_COMPOSE_VERSION.Length -eq 0) { - $Env:DOCKER_COMPOSE_VERSION = "latest" -} - -if ($Env:DOCKER_COMPOSE_OPTIONS -eq $null) { - $Env:DOCKER_COMPOSE_OPTIONS = "" -} - -if (-not $Env:DOCKER_HOST) { - docker-machine env --shell=powershell default | Invoke-Expression - if (-not $?) { exit $LastExitCode } -} - -$local="/$($PWD -replace '^(.):(.*)$', '"$1".ToLower()+"$2".Replace("\","/")' | Invoke-Expression)" -docker run --rm -ti -v /var/run/docker.sock:/var/run/docker.sock -v "${local}:$local" -w "$local" $Env:DOCKER_COMPOSE_OPTIONS "docker/compose:$Env:DOCKER_COMPOSE_VERSION" $args -exit $LastExitCode diff --git a/script/run/run.sh b/script/run/run.sh deleted file mode 100755 index 4e173894..00000000 --- a/script/run/run.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash -# -# Run docker-compose in a container -# -# This script will attempt to mirror the host paths by using volumes for the -# following paths: -# * $(pwd) -# * $(dirname $COMPOSE_FILE) if it's set -# * $HOME if it's set -# -# You can add additional volumes (or any docker run options) using -# the $COMPOSE_OPTIONS environment variable. -# - - -set -e - -VERSION="1.12.0dev" -IMAGE="docker/compose:$VERSION" - - -# Setup options for connecting to docker host -if [ -z "$DOCKER_HOST" ]; then - DOCKER_HOST="/var/run/docker.sock" -fi -if [ -S "$DOCKER_HOST" ]; then - DOCKER_ADDR="-v $DOCKER_HOST:$DOCKER_HOST -e DOCKER_HOST" -else - DOCKER_ADDR="-e DOCKER_HOST -e DOCKER_TLS_VERIFY -e DOCKER_CERT_PATH" -fi - - -# Setup volume mounts for compose config and context -if [ "$(pwd)" != '/' ]; then - VOLUMES="-v $(pwd):$(pwd)" -fi -if [ -n "$COMPOSE_FILE" ]; then - compose_dir=$(realpath $(dirname $COMPOSE_FILE)) -fi -# TODO: also check --file argument -if [ -n "$compose_dir" ]; then - VOLUMES="$VOLUMES -v $compose_dir:$compose_dir" -fi -if [ -n "$HOME" ]; then - VOLUMES="$VOLUMES -v $HOME:$HOME -v $HOME:/root" # mount $HOME in /root to share docker.config -fi - -# Only allocate tty if we detect one -if [ -t 1 ]; then - DOCKER_RUN_OPTIONS="-t" -fi -if [ -t 0 ]; then - DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -i" -fi - -exec docker run --rm $DOCKER_RUN_OPTIONS $DOCKER_ADDR $COMPOSE_OPTIONS $VOLUMES -w "$(pwd)" $IMAGE "$@" diff --git a/script/shell b/script/shell new file mode 100755 index 00000000..903be76f --- /dev/null +++ b/script/shell @@ -0,0 +1,4 @@ +#!/bin/sh +set -ex +docker build -t docker-compose . +exec docker run -v /var/run/docker.sock:/var/run/docker.sock -v `pwd`:/code -ti --rm --entrypoint bash docker-compose diff --git a/script/test b/script/test new file mode 100755 index 00000000..625af09b --- /dev/null +++ b/script/test @@ -0,0 +1,17 @@ +#!/bin/bash +# See CONTRIBUTING.md for usage. + +set -ex + +TAG="docker-compose:$(git rev-parse --short HEAD)" + +docker build -t "$TAG" . +docker run \ + --rm \ + --volume="/var/run/docker.sock:/var/run/docker.sock" \ + -e DOCKER_VERSIONS \ + -e "TAG=$TAG" \ + -e "affinity:image==$TAG" \ + --entrypoint="script/test-versions" \ + "$TAG" \ + "$@" diff --git a/script/test-versions b/script/test-versions new file mode 100755 index 00000000..7f1a14a9 --- /dev/null +++ b/script/test-versions @@ -0,0 +1,26 @@ +#!/bin/bash +# This should be run inside a container built from the Dockerfile +# at the root of the repo - script/test will do it automatically. + +set -e + +>&2 echo "Running lint checks" +flake8 compose tests setup.py + +if [ "$DOCKER_VERSIONS" == "" ]; then + DOCKER_VERSIONS="default" +elif [ "$DOCKER_VERSIONS" == "all" ]; then + DOCKER_VERSIONS="$ALL_DOCKER_VERSIONS" +fi + +for version in $DOCKER_VERSIONS; do + >&2 echo "Running tests against Docker $version" + docker run \ + --rm \ + --privileged \ + --volume="/var/lib/docker" \ + -e "DOCKER_VERSION=$version" \ + --entrypoint="script/dind" \ + "$TAG" \ + script/wrapdocker nosetests "$@" +done diff --git a/script/test/all b/script/test/all deleted file mode 100755 index 7151a75e..00000000 --- a/script/test/all +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/bash -# This should be run inside a container built from the Dockerfile -# at the root of the repo - script/test will do it automatically. - -set -e - ->&2 echo "Running lint checks" -docker run --rm \ - --tty \ - ${GIT_VOLUME} \ - --entrypoint="tox" \ - "$TAG" -e pre-commit - -get_versions="docker run --rm - --entrypoint=/code/.tox/py27/bin/python - $TAG - /code/script/test/versions.py docker/docker" - -if [ "$DOCKER_VERSIONS" == "" ]; then - DOCKER_VERSIONS="$($get_versions default)" -elif [ "$DOCKER_VERSIONS" == "all" ]; then - DOCKER_VERSIONS=$($get_versions -n 2 recent) -fi - - -BUILD_NUMBER=${BUILD_NUMBER-$USER} -PY_TEST_VERSIONS=${PY_TEST_VERSIONS:-py27,py34} - -for version in $DOCKER_VERSIONS; do - >&2 echo "Running tests against Docker $version" - - daemon_container="compose-dind-$version-$BUILD_NUMBER" - - function on_exit() { - if [[ "$?" != "0" ]]; then - docker logs "$daemon_container" 2>&1 | tail -n 100 - fi - docker rm -vf "$daemon_container" - } - - trap "on_exit" EXIT - - repo="dockerswarm/dind" - - docker run \ - -d \ - --name "$daemon_container" \ - --privileged \ - --volume="/var/lib/docker" \ - "$repo:$version" \ - docker daemon -H tcp://0.0.0.0:2375 $DOCKER_DAEMON_ARGS \ - 2>&1 | tail -n 10 - - docker run \ - --rm \ - --tty \ - --link="$daemon_container:docker" \ - --env="DOCKER_HOST=tcp://docker:2375" \ - --env="DOCKER_VERSION=$version" \ - --entrypoint="tox" \ - "$TAG" \ - -e "$PY_TEST_VERSIONS" -- "$@" - -done diff --git a/script/test/ci b/script/test/ci deleted file mode 100755 index c5927b2c..00000000 --- a/script/test/ci +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash -# This should be run inside a container built from the Dockerfile -# at the root of the repo: -# -# $ TAG="docker-compose:$(git rev-parse --short HEAD)" -# $ docker build -t "$TAG" . -# $ docker run --rm \ -# --volume="/var/run/docker.sock:/var/run/docker.sock" \ -# --volume="$(pwd)/.git:/code/.git" \ -# -e "TAG=$TAG" \ -# --entrypoint="script/test/ci" "$TAG" - -set -ex - -docker version - -export DOCKER_VERSIONS=all -STORAGE_DRIVER=${STORAGE_DRIVER:-overlay} -export DOCKER_DAEMON_ARGS="--storage-driver=$STORAGE_DRIVER" - -GIT_VOLUME="--volumes-from=$(hostname)" -. script/test/all - ->&2 echo "Building Linux binary" -. script/build/linux-entrypoint diff --git a/script/test/default b/script/test/default deleted file mode 100755 index fa741a19..00000000 --- a/script/test/default +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -# See CONTRIBUTING.md for usage. - -set -ex - -TAG="docker-compose:$(git rev-parse --short HEAD)" - -rm -rf coverage-html -# Create the host directory so it's owned by $USER -mkdir -p coverage-html - -docker build -t "$TAG" . - -GIT_VOLUME="--volume=$(pwd)/.git:/code/.git" -. script/test/all diff --git a/script/test/versions.py b/script/test/versions.py deleted file mode 100755 index 0c3b8162..00000000 --- a/script/test/versions.py +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env python -""" -Query the github API for the git tags of a project, and return a list of -version tags for recent releases, or the default release. - -The default release is the most recent non-RC version. - -Recent is a list of unique major.minor versions, where each is the most -recent version in the series. - -For example, if the list of versions is: - - 1.8.0-rc2 - 1.8.0-rc1 - 1.7.1 - 1.7.0 - 1.7.0-rc1 - 1.6.2 - 1.6.1 - -`default` would return `1.7.1` and -`recent -n 3` would return `1.8.0-rc2 1.7.1 1.6.2` -""" -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - -import argparse -import itertools -import operator -import sys -from collections import namedtuple - -import requests - - -GITHUB_API = 'https://api.github.com/repos' - - -class Version(namedtuple('_Version', 'major minor patch rc')): - - @classmethod - def parse(cls, version): - version = version.lstrip('v') - version, _, rc = version.partition('-') - major, minor, patch = version.split('.', 3) - return cls(int(major), int(minor), int(patch), rc) - - @property - def major_minor(self): - return self.major, self.minor - - @property - def order(self): - """Return a representation that allows this object to be sorted - correctly with the default comparator. - """ - # rc releases should appear before official releases - rc = (0, self.rc) if self.rc else (1, ) - return (self.major, self.minor, self.patch) + rc - - def __str__(self): - rc = '-{}'.format(self.rc) if self.rc else '' - return '.'.join(map(str, self[:3])) + rc - - -def group_versions(versions): - """Group versions by `major.minor` releases. - - Example: - - >>> group_versions([ - Version(1, 0, 0), - Version(2, 0, 0, 'rc1'), - Version(2, 0, 0), - Version(2, 1, 0), - ]) - - [ - [Version(1, 0, 0)], - [Version(2, 0, 0), Version(2, 0, 0, 'rc1')], - [Version(2, 1, 0)], - ] - """ - return list( - list(releases) - for _, releases - in itertools.groupby(versions, operator.attrgetter('major_minor')) - ) - - -def get_latest_versions(versions, num=1): - """Return a list of the most recent versions for each major.minor version - group. - """ - versions = group_versions(versions) - return [versions[index][0] for index in range(num)] - - -def get_default(versions): - """Return a :class:`Version` for the latest non-rc version.""" - for version in versions: - if not version.rc: - return version - - -def get_versions(tags): - for tag in tags: - try: - yield Version.parse(tag['name']) - except ValueError: - print("Skipping invalid tag: {name}".format(**tag), file=sys.stderr) - - -def get_github_releases(project): - """Query the Github API for a list of version tags and return them in - sorted order. - - See https://developer.github.com/v3/repos/#list-tags - """ - url = '{}/{}/tags'.format(GITHUB_API, project) - response = requests.get(url) - response.raise_for_status() - versions = get_versions(response.json()) - return sorted(versions, reverse=True, key=operator.attrgetter('order')) - - -def parse_args(argv): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('project', help="Github project name (ex: docker/docker)") - parser.add_argument('command', choices=['recent', 'default']) - parser.add_argument('-n', '--num', type=int, default=2, - help="Number of versions to return from `recent`") - return parser.parse_args(argv) - - -def main(argv=None): - args = parse_args(argv) - versions = get_github_releases(args.project) - - if args.command == 'recent': - print(' '.join(map(str, get_latest_versions(versions, args.num)))) - elif args.command == 'default': - print(get_default(versions)) - else: - raise ValueError("Unknown command {}".format(args.command)) - - -if __name__ == "__main__": - main() diff --git a/script/travis/bintray.json.tmpl b/script/travis/bintray.json.tmpl deleted file mode 100644 index f9728558..00000000 --- a/script/travis/bintray.json.tmpl +++ /dev/null @@ -1,29 +0,0 @@ -{ - "package": { - "name": "${TRAVIS_OS_NAME}", - "repo": "${TRAVIS_BRANCH}", - "subject": "docker-compose", - "desc": "Automated build of master branch from travis ci.", - "website_url": "https://github.com/docker/compose", - "issue_tracker_url": "https://github.com/docker/compose/issues", - "vcs_url": "https://github.com/docker/compose.git", - "licenses": ["Apache-2.0"] - }, - - "version": { - "name": "${TRAVIS_BRANCH}", - "desc": "Automated build of the ${TRAVIS_BRANCH} branch.", - "released": "${DATE}", - "vcs_tag": "master" - }, - - "files": [ - { - "includePattern": "dist/(.*)", - "excludePattern": ".*\.tar.gz", - "uploadPattern": "$1", - "matrixParams": { "override": 1 } - } - ], - "publish": true -} diff --git a/script/travis/build-binary b/script/travis/build-binary deleted file mode 100755 index 7707a1ee..00000000 --- a/script/travis/build-binary +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -set -ex - -if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then - script/build/linux - # TODO: requires auth to push, so disable for now - # script/build/image master - # docker push docker/compose:master -else - script/setup/osx - script/build/osx -fi diff --git a/script/travis/ci b/script/travis/ci deleted file mode 100755 index cd4fcc6d..00000000 --- a/script/travis/ci +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -set -e - -if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then - tox -e py27,py34 -- tests/unit -else - # TODO: we could also install py34 and test against it - tox -e py27 -- tests/unit -fi diff --git a/script/travis/install b/script/travis/install deleted file mode 100755 index d4b34786..00000000 --- a/script/travis/install +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -set -ex - -if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then - pip install tox==2.1.1 -else - sudo pip install --upgrade pip tox==2.1.1 virtualenv - pip --version -fi diff --git a/script/travis/render-bintray-config.py b/script/travis/render-bintray-config.py deleted file mode 100755 index b5364a0b..00000000 --- a/script/travis/render-bintray-config.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env python -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - -import datetime -import os.path -import sys - -os.environ['DATE'] = str(datetime.date.today()) - -for line in sys.stdin: - print(os.path.expandvars(line), end='') diff --git a/script/wrapdocker b/script/wrapdocker new file mode 100755 index 00000000..2e07bdad --- /dev/null +++ b/script/wrapdocker @@ -0,0 +1,18 @@ +#!/bin/bash + +if [ "$DOCKER_VERSION" != "" ] && [ "$DOCKER_VERSION" != "default" ]; then + ln -fs "/usr/local/bin/docker-$DOCKER_VERSION" "/usr/local/bin/docker" +fi + +# If a pidfile is still around (for example after a container restart), +# delete it so that docker can start. +rm -rf /var/run/docker.pid +docker -d $DOCKER_DAEMON_ARGS &>/var/log/docker.log & + +>&2 echo "Waiting for Docker to start..." +while ! docker ps &>/dev/null; do + sleep 1 +done + +>&2 echo ">" "$@" +exec "$@" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 3c6e79cf..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[bdist_wheel] -universal=1 diff --git a/setup.py b/setup.py index eafbc356..a94d8737 100644 --- a/setup.py +++ b/setup.py @@ -1,18 +1,13 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from __future__ import absolute_import -from __future__ import print_function from __future__ import unicode_literals - +from __future__ import absolute_import +from setuptools import setup, find_packages import codecs import os import re import sys -import pkg_resources -from setuptools import find_packages -from setuptools import setup - def read(*parts): path = os.path.join(os.path.dirname(__file__), *parts) @@ -30,46 +25,27 @@ def find_version(*file_paths): install_requires = [ - 'cached-property >= 1.2.0, < 2', - 'colorama >= 0.3.7, < 0.4', 'docopt >= 0.6.1, < 0.7', 'PyYAML >= 3.10, < 4', - 'requests >= 2.6.1, != 2.11.0, < 2.12', + 'requests >= 2.6.1, < 2.7', 'texttable >= 0.8.1, < 0.9', - 'websocket-client >= 0.32.0, < 1.0', - 'docker >= 2.0.2, < 3.0', - 'dockerpty >= 0.4.1, < 0.5', + 'websocket-client >= 0.11.0, < 1.0', + 'docker-py >= 1.2.3-rc1, < 1.3', + 'dockerpty >= 0.3.4, < 0.4', 'six >= 1.3.0, < 2', - 'jsonschema >= 2.5.1, < 3', ] tests_require = [ - 'pytest', + 'mock >= 1.0.1', + 'nose', + 'pyinstaller', + 'flake8', ] -if sys.version_info[:2] < (3, 4): - tests_require.append('mock >= 1.0.1') - -extras_require = { - ':python_version < "3.4"': ['enum34 >= 1.0.4, < 2'], - ':python_version < "3.5"': ['backports.ssl_match_hostname >= 3.5'], - ':python_version < "3.3"': ['ipaddress >= 1.0.16'], -} - - -try: - if 'bdist_wheel' not in sys.argv: - for key, value in extras_require.items(): - if key.startswith(':') and pkg_resources.evaluate_marker(key[1:]): - install_requires.extend(value) -except Exception as e: - print("Failed to compute platform dependencies: {}. ".format(e) + - "All dependencies will be installed as a result.", file=sys.stderr) - for key, value in extras_require.items(): - if key.startswith(':'): - install_requires.extend(value) +if sys.version_info < (2, 7): + tests_require.append('unittest2') setup( @@ -83,20 +59,9 @@ setup( include_package_data=True, test_suite='nose.collector', install_requires=install_requires, - extras_require=extras_require, tests_require=tests_require, entry_points=""" [console_scripts] docker-compose=compose.cli.main:main """, - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - ], ) diff --git a/tests/__init__.py b/tests/__init__.py index 1ac1b21c..08a7865e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,14 +1,6 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - import sys if sys.version_info >= (2, 7): import unittest # NOQA else: import unittest2 as unittest # NOQA - -try: - from unittest import mock -except ImportError: - import mock # NOQA diff --git a/tests/acceptance/__init__.py b/tests/acceptance/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py deleted file mode 100644 index 8366ca75..00000000 --- a/tests/acceptance/cli_test.py +++ /dev/null @@ -1,1929 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import -from __future__ import unicode_literals - -import datetime -import json -import os -import signal -import subprocess -import time -from collections import Counter -from collections import namedtuple -from operator import attrgetter - -import py -import six -import yaml -from docker import errors - -from .. import mock -from compose.cli.command import get_project -from compose.container import Container -from compose.project import OneOffFilter -from compose.utils import nanoseconds_from_time_seconds -from tests.integration.testcases import DockerClientTestCase -from tests.integration.testcases import get_links -from tests.integration.testcases import pull_busybox -from tests.integration.testcases import v2_1_only -from tests.integration.testcases import v2_only -from tests.integration.testcases import v3_only - - -ProcessResult = namedtuple('ProcessResult', 'stdout stderr') - - -BUILD_CACHE_TEXT = 'Using cache' -BUILD_PULL_TEXT = 'Status: Image is up to date for busybox:latest' - - -def start_process(base_dir, options): - proc = subprocess.Popen( - ['docker-compose'] + options, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=base_dir) - print("Running process: %s" % proc.pid) - return proc - - -def wait_on_process(proc, returncode=0): - stdout, stderr = proc.communicate() - if proc.returncode != returncode: - print("Stderr: {}".format(stderr)) - print("Stdout: {}".format(stdout)) - assert proc.returncode == returncode - return ProcessResult(stdout.decode('utf-8'), stderr.decode('utf-8')) - - -def wait_on_condition(condition, delay=0.1, timeout=40): - start_time = time.time() - while not condition(): - if time.time() - start_time > timeout: - raise AssertionError("Timeout: %s" % condition) - time.sleep(delay) - - -def kill_service(service): - for container in service.containers(): - container.kill() - - -class ContainerCountCondition(object): - - def __init__(self, project, expected): - self.project = project - self.expected = expected - - def __call__(self): - return len(self.project.containers()) == self.expected - - def __str__(self): - return "waiting for counter count == %s" % self.expected - - -class ContainerStateCondition(object): - - def __init__(self, client, name, status): - self.client = client - self.name = name - self.status = status - - def __call__(self): - try: - container = self.client.inspect_container(self.name) - return container['State']['Status'] == self.status - except errors.APIError: - return False - - def __str__(self): - return "waiting for container to be %s" % self.status - - -class CLITestCase(DockerClientTestCase): - - def setUp(self): - super(CLITestCase, self).setUp() - self.base_dir = 'tests/fixtures/simple-composefile' - - def tearDown(self): - if self.base_dir: - self.project.kill() - self.project.remove_stopped() - - for container in self.project.containers(stopped=True, one_off=OneOffFilter.only): - container.remove(force=True) - - networks = self.client.networks() - for n in networks: - if n['Name'].startswith('{}_'.format(self.project.name)): - self.client.remove_network(n['Name']) - if hasattr(self, '_project'): - del self._project - - super(CLITestCase, self).tearDown() - - @property - def project(self): - # Hack: allow project to be overridden - if not hasattr(self, '_project'): - self._project = get_project(self.base_dir) - return self._project - - def dispatch(self, options, project_options=None, returncode=0): - project_options = project_options or [] - proc = start_process(self.base_dir, project_options + options) - return wait_on_process(proc, returncode=returncode) - - def execute(self, container, cmd): - # Remove once Hijack and CloseNotifier sign a peace treaty - self.client.close() - exc = self.client.exec_create(container.id, cmd) - self.client.exec_start(exc) - return self.client.exec_inspect(exc)['ExitCode'] - - def lookup(self, container, hostname): - return self.execute(container, ["nslookup", hostname]) == 0 - - def test_help(self): - self.base_dir = 'tests/fixtures/no-composefile' - result = self.dispatch(['help', 'up'], returncode=0) - assert 'Usage: up [options] [SERVICE...]' in result.stdout - # Prevent tearDown from trying to create a project - self.base_dir = None - - def test_shorthand_host_opt(self): - self.dispatch( - ['-H={0}'.format(os.environ.get('DOCKER_HOST', 'unix://')), - 'up', '-d'], - returncode=0 - ) - - def test_host_not_reachable(self): - result = self.dispatch(['-H=tcp://doesnotexist:8000', 'ps'], returncode=1) - assert "Couldn't connect to Docker daemon" in result.stderr - - def test_host_not_reachable_volumes_from_container(self): - self.base_dir = 'tests/fixtures/volumes-from-container' - - container = self.client.create_container('busybox', 'true', name='composetest_data_container') - self.addCleanup(self.client.remove_container, container) - - result = self.dispatch(['-H=tcp://doesnotexist:8000', 'ps'], returncode=1) - assert "Couldn't connect to Docker daemon" in result.stderr - - def test_config_list_services(self): - self.base_dir = 'tests/fixtures/v2-full' - result = self.dispatch(['config', '--services']) - assert set(result.stdout.rstrip().split('\n')) == {'web', 'other'} - - def test_config_quiet_with_error(self): - self.base_dir = None - result = self.dispatch([ - '-f', 'tests/fixtures/invalid-composefile/invalid.yml', - 'config', '-q' - ], returncode=1) - assert "'notaservice' must be a mapping" in result.stderr - - def test_config_quiet(self): - self.base_dir = 'tests/fixtures/v2-full' - assert self.dispatch(['config', '-q']).stdout == '' - - def test_config_default(self): - self.base_dir = 'tests/fixtures/v2-full' - result = self.dispatch(['config']) - # assert there are no python objects encoded in the output - assert '!!' not in result.stdout - - output = yaml.load(result.stdout) - expected = { - 'version': '2.0', - 'volumes': {'data': {'driver': 'local'}}, - 'networks': {'front': {}}, - 'services': { - 'web': { - 'build': { - 'context': os.path.abspath(self.base_dir), - }, - 'networks': {'front': None, 'default': None}, - 'volumes_from': ['service:other:rw'], - }, - 'other': { - 'image': 'busybox:latest', - 'command': 'top', - 'volumes': ['/data:rw'], - }, - }, - } - assert output == expected - - def test_config_restart(self): - self.base_dir = 'tests/fixtures/restart' - result = self.dispatch(['config']) - assert yaml.load(result.stdout) == { - 'version': '2.0', - 'services': { - 'never': { - 'image': 'busybox', - 'restart': 'no', - }, - 'always': { - 'image': 'busybox', - 'restart': 'always', - }, - 'on-failure': { - 'image': 'busybox', - 'restart': 'on-failure', - }, - 'on-failure-5': { - 'image': 'busybox', - 'restart': 'on-failure:5', - }, - 'restart-null': { - 'image': 'busybox', - 'restart': '' - }, - }, - 'networks': {}, - 'volumes': {}, - } - - def test_config_external_network(self): - self.base_dir = 'tests/fixtures/networks' - result = self.dispatch(['-f', 'external-networks.yml', 'config']) - json_result = yaml.load(result.stdout) - assert 'networks' in json_result - assert json_result['networks'] == { - 'networks_foo': { - 'external': True # {'name': 'networks_foo'} - }, - 'bar': { - 'external': {'name': 'networks_bar'} - } - } - - def test_config_external_volume(self): - self.base_dir = 'tests/fixtures/volumes' - result = self.dispatch(['-f', 'external-volumes.yml', 'config']) - json_result = yaml.load(result.stdout) - assert 'volumes' in json_result - assert json_result['volumes'] == { - 'foo': { - 'external': True - }, - 'bar': { - 'external': {'name': 'some_bar'} - } - } - - def test_config_v1(self): - self.base_dir = 'tests/fixtures/v1-config' - result = self.dispatch(['config']) - assert yaml.load(result.stdout) == { - 'version': '2.1', - 'services': { - 'net': { - 'image': 'busybox', - 'network_mode': 'bridge', - }, - 'volume': { - 'image': 'busybox', - 'volumes': ['/data:rw'], - 'network_mode': 'bridge', - }, - 'app': { - 'image': 'busybox', - 'volumes_from': ['service:volume:rw'], - 'network_mode': 'service:net', - }, - }, - 'networks': {}, - 'volumes': {}, - } - - @v3_only() - def test_config_v3(self): - self.base_dir = 'tests/fixtures/v3-full' - result = self.dispatch(['config']) - - assert yaml.load(result.stdout) == { - 'version': '3.0', - 'networks': {}, - 'volumes': { - 'foobar': { - 'labels': { - 'com.docker.compose.test': 'true', - }, - }, - }, - 'services': { - 'web': { - 'image': 'busybox', - 'deploy': { - 'mode': 'replicated', - 'replicas': 6, - 'labels': ['FOO=BAR'], - 'update_config': { - 'parallelism': 3, - 'delay': '10s', - 'failure_action': 'continue', - 'monitor': '60s', - 'max_failure_ratio': 0.3, - }, - 'resources': { - 'limits': { - 'cpus': '0.001', - 'memory': '50M', - }, - 'reservations': { - 'cpus': '0.0001', - 'memory': '20M', - }, - }, - 'restart_policy': { - 'condition': 'on_failure', - 'delay': '5s', - 'max_attempts': 3, - 'window': '120s', - }, - 'placement': { - 'constraints': ['node=foo'], - }, - }, - - 'healthcheck': { - 'test': 'cat /etc/passwd', - 'interval': '10s', - 'timeout': '1s', - 'retries': 5, - }, - - 'stop_grace_period': '20s', - }, - }, - } - - def test_ps(self): - self.project.get_service('simple').create_container() - result = self.dispatch(['ps']) - assert 'simplecomposefile_simple_1' in result.stdout - - def test_ps_default_composefile(self): - self.base_dir = 'tests/fixtures/multiple-composefiles' - self.dispatch(['up', '-d']) - result = self.dispatch(['ps']) - - self.assertIn('multiplecomposefiles_simple_1', result.stdout) - self.assertIn('multiplecomposefiles_another_1', result.stdout) - self.assertNotIn('multiplecomposefiles_yetanother_1', result.stdout) - - def test_ps_alternate_composefile(self): - config_path = os.path.abspath( - 'tests/fixtures/multiple-composefiles/compose2.yml') - self._project = get_project(self.base_dir, [config_path]) - - self.base_dir = 'tests/fixtures/multiple-composefiles' - self.dispatch(['-f', 'compose2.yml', 'up', '-d']) - result = self.dispatch(['-f', 'compose2.yml', 'ps']) - - self.assertNotIn('multiplecomposefiles_simple_1', result.stdout) - self.assertNotIn('multiplecomposefiles_another_1', result.stdout) - self.assertIn('multiplecomposefiles_yetanother_1', result.stdout) - - def test_pull(self): - result = self.dispatch(['pull']) - assert sorted(result.stderr.split('\n'))[1:] == [ - 'Pulling another (busybox:latest)...', - 'Pulling simple (busybox:latest)...', - ] - - def test_pull_with_digest(self): - result = self.dispatch(['-f', 'digest.yml', 'pull']) - - assert 'Pulling simple (busybox:latest)...' in result.stderr - assert ('Pulling digest (busybox@' - 'sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b520' - '04ee8502d)...') in result.stderr - - def test_pull_with_ignore_pull_failures(self): - result = self.dispatch([ - '-f', 'ignore-pull-failures.yml', - 'pull', '--ignore-pull-failures'] - ) - - assert 'Pulling simple (busybox:latest)...' in result.stderr - assert 'Pulling another (nonexisting-image:latest)...' in result.stderr - assert ('repository nonexisting-image not found' in result.stderr or - 'image library/nonexisting-image:latest not found' in result.stderr) - - def test_build_plain(self): - self.base_dir = 'tests/fixtures/simple-dockerfile' - self.dispatch(['build', 'simple']) - - result = self.dispatch(['build', 'simple']) - assert BUILD_CACHE_TEXT in result.stdout - assert BUILD_PULL_TEXT not in result.stdout - - def test_build_no_cache(self): - self.base_dir = 'tests/fixtures/simple-dockerfile' - self.dispatch(['build', 'simple']) - - result = self.dispatch(['build', '--no-cache', 'simple']) - assert BUILD_CACHE_TEXT not in result.stdout - assert BUILD_PULL_TEXT not in result.stdout - - def test_build_pull(self): - # Make sure we have the latest busybox already - pull_busybox(self.client) - self.base_dir = 'tests/fixtures/simple-dockerfile' - self.dispatch(['build', 'simple'], None) - - result = self.dispatch(['build', '--pull', 'simple']) - assert BUILD_CACHE_TEXT in result.stdout - assert BUILD_PULL_TEXT in result.stdout - - def test_build_no_cache_pull(self): - # Make sure we have the latest busybox already - pull_busybox(self.client) - self.base_dir = 'tests/fixtures/simple-dockerfile' - self.dispatch(['build', 'simple']) - - result = self.dispatch(['build', '--no-cache', '--pull', 'simple']) - assert BUILD_CACHE_TEXT not in result.stdout - assert BUILD_PULL_TEXT in result.stdout - - def test_build_failed(self): - self.base_dir = 'tests/fixtures/simple-failing-dockerfile' - self.dispatch(['build', 'simple'], returncode=1) - - labels = ["com.docker.compose.test_failing_image=true"] - containers = [ - Container.from_ps(self.project.client, c) - for c in self.project.client.containers( - all=True, - filters={"label": labels}) - ] - assert len(containers) == 1 - - def test_build_failed_forcerm(self): - self.base_dir = 'tests/fixtures/simple-failing-dockerfile' - self.dispatch(['build', '--force-rm', 'simple'], returncode=1) - - labels = ["com.docker.compose.test_failing_image=true"] - - containers = [ - Container.from_ps(self.project.client, c) - for c in self.project.client.containers( - all=True, - filters={"label": labels}) - ] - assert not containers - - def test_bundle_with_digests(self): - self.base_dir = 'tests/fixtures/bundle-with-digests/' - tmpdir = py.test.ensuretemp('cli_test_bundle') - self.addCleanup(tmpdir.remove) - filename = str(tmpdir.join('example.dab')) - - self.dispatch(['bundle', '--output', filename]) - with open(filename, 'r') as fh: - bundle = json.load(fh) - - assert bundle == { - 'Version': '0.1', - 'Services': { - 'web': { - 'Image': ('dockercloud/hello-world@sha256:fe79a2cfbd17eefc3' - '44fb8419420808df95a1e22d93b7f621a7399fd1e9dca1d'), - 'Networks': ['default'], - }, - 'redis': { - 'Image': ('redis@sha256:a84cb8f53a70e19f61ff2e1d5e73fb7ae62d' - '374b2b7392de1e7d77be26ef8f7b'), - 'Networks': ['default'], - } - }, - } - - def test_create(self): - self.dispatch(['create']) - service = self.project.get_service('simple') - another = self.project.get_service('another') - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(another.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertEqual(len(another.containers(stopped=True)), 1) - - def test_create_with_force_recreate(self): - self.dispatch(['create'], None) - service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) - - old_ids = [c.id for c in service.containers(stopped=True)] - - self.dispatch(['create', '--force-recreate'], None) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) - - new_ids = [c.id for c in service.containers(stopped=True)] - - self.assertNotEqual(old_ids, new_ids) - - def test_create_with_no_recreate(self): - self.dispatch(['create'], None) - service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) - - old_ids = [c.id for c in service.containers(stopped=True)] - - self.dispatch(['create', '--no-recreate'], None) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) - - new_ids = [c.id for c in service.containers(stopped=True)] - - self.assertEqual(old_ids, new_ids) - - def test_create_with_force_recreate_and_no_recreate(self): - self.dispatch( - ['create', '--force-recreate', '--no-recreate'], - returncode=1) - - def test_down_invalid_rmi_flag(self): - result = self.dispatch(['down', '--rmi', 'bogus'], returncode=1) - assert '--rmi flag must be' in result.stderr - - @v2_only() - def test_down(self): - self.base_dir = 'tests/fixtures/v2-full' - - self.dispatch(['up', '-d']) - wait_on_condition(ContainerCountCondition(self.project, 2)) - - self.dispatch(['run', 'web', 'true']) - self.dispatch(['run', '-d', 'web', 'tail', '-f', '/dev/null']) - assert len(self.project.containers(one_off=OneOffFilter.only, stopped=True)) == 2 - - result = self.dispatch(['down', '--rmi=local', '--volumes']) - assert 'Stopping v2full_web_1' in result.stderr - assert 'Stopping v2full_other_1' in result.stderr - assert 'Stopping v2full_web_run_2' in result.stderr - assert 'Removing v2full_web_1' in result.stderr - assert 'Removing v2full_other_1' in result.stderr - assert 'Removing v2full_web_run_1' in result.stderr - assert 'Removing v2full_web_run_2' in result.stderr - assert 'Removing volume v2full_data' in result.stderr - assert 'Removing image v2full_web' in result.stderr - assert 'Removing image busybox' not in result.stderr - assert 'Removing network v2full_default' in result.stderr - assert 'Removing network v2full_front' in result.stderr - - def test_up_detached(self): - self.dispatch(['up', '-d']) - service = self.project.get_service('simple') - another = self.project.get_service('another') - self.assertEqual(len(service.containers()), 1) - self.assertEqual(len(another.containers()), 1) - - # Ensure containers don't have stdin and stdout connected in -d mode - container, = service.containers() - self.assertFalse(container.get('Config.AttachStderr')) - self.assertFalse(container.get('Config.AttachStdout')) - self.assertFalse(container.get('Config.AttachStdin')) - - def test_up_attached(self): - self.base_dir = 'tests/fixtures/echo-services' - result = self.dispatch(['up', '--no-color']) - - assert 'simple_1 | simple' in result.stdout - assert 'another_1 | another' in result.stdout - assert 'simple_1 exited with code 0' in result.stdout - assert 'another_1 exited with code 0' in result.stdout - - @v2_only() - def test_up(self): - self.base_dir = 'tests/fixtures/v2-simple' - self.dispatch(['up', '-d'], None) - - services = self.project.get_services() - - network_name = self.project.networks.networks['default'].full_name - networks = self.client.networks(names=[network_name]) - self.assertEqual(len(networks), 1) - self.assertEqual(networks[0]['Driver'], 'bridge') - assert 'com.docker.network.bridge.enable_icc' not in networks[0]['Options'] - - network = self.client.inspect_network(networks[0]['Id']) - - for service in services: - containers = service.containers() - self.assertEqual(len(containers), 1) - - container = containers[0] - self.assertIn(container.id, network['Containers']) - - networks = container.get('NetworkSettings.Networks') - self.assertEqual(list(networks), [network['Name']]) - - self.assertEqual( - sorted(networks[network['Name']]['Aliases']), - sorted([service.name, container.short_id])) - - for service in services: - assert self.lookup(container, service.name) - - @v2_only() - def test_up_with_default_network_config(self): - filename = 'default-network-config.yml' - - self.base_dir = 'tests/fixtures/networks' - self._project = get_project(self.base_dir, [filename]) - - self.dispatch(['-f', filename, 'up', '-d'], None) - - network_name = self.project.networks.networks['default'].full_name - networks = self.client.networks(names=[network_name]) - - assert networks[0]['Options']['com.docker.network.bridge.enable_icc'] == 'false' - - @v2_only() - def test_up_with_network_aliases(self): - filename = 'network-aliases.yml' - self.base_dir = 'tests/fixtures/networks' - self.dispatch(['-f', filename, 'up', '-d'], None) - back_name = '{}_back'.format(self.project.name) - front_name = '{}_front'.format(self.project.name) - - networks = [ - n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) - ] - - # Two networks were created: back and front - assert sorted(n['Name'] for n in networks) == [back_name, front_name] - web_container = self.project.get_service('web').containers()[0] - - back_aliases = web_container.get( - 'NetworkSettings.Networks.{}.Aliases'.format(back_name) - ) - assert 'web' in back_aliases - front_aliases = web_container.get( - 'NetworkSettings.Networks.{}.Aliases'.format(front_name) - ) - assert 'web' in front_aliases - assert 'forward_facing' in front_aliases - assert 'ahead' in front_aliases - - @v2_only() - def test_up_with_network_internal(self): - self.require_api_version('1.23') - filename = 'network-internal.yml' - self.base_dir = 'tests/fixtures/networks' - self.dispatch(['-f', filename, 'up', '-d'], None) - internal_net = '{}_internal'.format(self.project.name) - - networks = [ - n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) - ] - - # One network was created: internal - assert sorted(n['Name'] for n in networks) == [internal_net] - - assert networks[0]['Internal'] is True - - @v2_only() - def test_up_with_network_static_addresses(self): - filename = 'network-static-addresses.yml' - ipv4_address = '172.16.100.100' - ipv6_address = 'fe80::1001:100' - self.base_dir = 'tests/fixtures/networks' - self.dispatch(['-f', filename, 'up', '-d'], None) - static_net = '{}_static_test'.format(self.project.name) - - networks = [ - n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) - ] - - # One networks was created: front - assert sorted(n['Name'] for n in networks) == [static_net] - web_container = self.project.get_service('web').containers()[0] - - ipam_config = web_container.get( - 'NetworkSettings.Networks.{}.IPAMConfig'.format(static_net) - ) - assert ipv4_address in ipam_config.values() - assert ipv6_address in ipam_config.values() - - @v2_only() - def test_up_with_networks(self): - self.base_dir = 'tests/fixtures/networks' - self.dispatch(['up', '-d'], None) - - back_name = '{}_back'.format(self.project.name) - front_name = '{}_front'.format(self.project.name) - - networks = [ - n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) - ] - - # Two networks were created: back and front - assert sorted(n['Name'] for n in networks) == [back_name, front_name] - - back_network = [n for n in networks if n['Name'] == back_name][0] - front_network = [n for n in networks if n['Name'] == front_name][0] - - web_container = self.project.get_service('web').containers()[0] - app_container = self.project.get_service('app').containers()[0] - db_container = self.project.get_service('db').containers()[0] - - for net_name in [front_name, back_name]: - links = app_container.get('NetworkSettings.Networks.{}.Links'.format(net_name)) - assert '{}:database'.format(db_container.name) in links - - # db and app joined the back network - assert sorted(back_network['Containers']) == sorted([db_container.id, app_container.id]) - - # web and app joined the front network - assert sorted(front_network['Containers']) == sorted([web_container.id, app_container.id]) - - # web can see app but not db - assert self.lookup(web_container, "app") - assert not self.lookup(web_container, "db") - - # app can see db - assert self.lookup(app_container, "db") - - # app has aliased db to "database" - assert self.lookup(app_container, "database") - - @v2_only() - def test_up_missing_network(self): - self.base_dir = 'tests/fixtures/networks' - - result = self.dispatch( - ['-f', 'missing-network.yml', 'up', '-d'], - returncode=1) - - assert 'Service "web" uses an undefined network "foo"' in result.stderr - - @v2_only() - def test_up_with_network_mode(self): - c = self.client.create_container('busybox', 'top', name='composetest_network_mode_container') - self.addCleanup(self.client.remove_container, c, force=True) - self.client.start(c) - container_mode_source = 'container:{}'.format(c['Id']) - - filename = 'network-mode.yml' - - self.base_dir = 'tests/fixtures/networks' - self._project = get_project(self.base_dir, [filename]) - - self.dispatch(['-f', filename, 'up', '-d'], None) - - networks = [ - n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) - ] - assert not networks - - for name in ['bridge', 'host', 'none']: - container = self.project.get_service(name).containers()[0] - assert list(container.get('NetworkSettings.Networks')) == [name] - assert container.get('HostConfig.NetworkMode') == name - - service_mode_source = 'container:{}'.format( - self.project.get_service('bridge').containers()[0].id) - service_mode_container = self.project.get_service('service').containers()[0] - assert not service_mode_container.get('NetworkSettings.Networks') - assert service_mode_container.get('HostConfig.NetworkMode') == service_mode_source - - container_mode_container = self.project.get_service('container').containers()[0] - assert not container_mode_container.get('NetworkSettings.Networks') - assert container_mode_container.get('HostConfig.NetworkMode') == container_mode_source - - @v2_only() - def test_up_external_networks(self): - filename = 'external-networks.yml' - - self.base_dir = 'tests/fixtures/networks' - self._project = get_project(self.base_dir, [filename]) - - result = self.dispatch(['-f', filename, 'up', '-d'], returncode=1) - assert 'declared as external, but could not be found' in result.stderr - - networks = [ - n['Name'] for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) - ] - assert not networks - - network_names = ['{}_{}'.format(self.project.name, n) for n in ['foo', 'bar']] - for name in network_names: - self.client.create_network(name) - - self.dispatch(['-f', filename, 'up', '-d']) - container = self.project.containers()[0] - assert sorted(list(container.get('NetworkSettings.Networks'))) == sorted(network_names) - - @v2_only() - def test_up_with_external_default_network(self): - filename = 'external-default.yml' - - self.base_dir = 'tests/fixtures/networks' - self._project = get_project(self.base_dir, [filename]) - - result = self.dispatch(['-f', filename, 'up', '-d'], returncode=1) - assert 'declared as external, but could not be found' in result.stderr - - networks = [ - n['Name'] for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) - ] - assert not networks - - network_name = 'composetest_external_network' - self.client.create_network(network_name) - - self.dispatch(['-f', filename, 'up', '-d']) - container = self.project.containers()[0] - assert list(container.get('NetworkSettings.Networks')) == [network_name] - - @v2_1_only() - def test_up_with_network_labels(self): - filename = 'network-label.yml' - - self.base_dir = 'tests/fixtures/networks' - self._project = get_project(self.base_dir, [filename]) - - self.dispatch(['-f', filename, 'up', '-d'], returncode=0) - - network_with_label = '{}_network_with_label'.format(self.project.name) - - networks = [ - n for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) - ] - - assert [n['Name'] for n in networks] == [network_with_label] - assert 'label_key' in networks[0]['Labels'] - assert networks[0]['Labels']['label_key'] == 'label_val' - - @v2_1_only() - def test_up_with_volume_labels(self): - filename = 'volume-label.yml' - - self.base_dir = 'tests/fixtures/volumes' - self._project = get_project(self.base_dir, [filename]) - - self.dispatch(['-f', filename, 'up', '-d'], returncode=0) - - volume_with_label = '{}_volume_with_label'.format(self.project.name) - - volumes = [ - v for v in self.client.volumes().get('Volumes', []) - if v['Name'].startswith('{}_'.format(self.project.name)) - ] - - assert [v['Name'] for v in volumes] == [volume_with_label] - assert 'label_key' in volumes[0]['Labels'] - assert volumes[0]['Labels']['label_key'] == 'label_val' - - @v2_only() - def test_up_no_services(self): - self.base_dir = 'tests/fixtures/no-services' - self.dispatch(['up', '-d'], None) - - network_names = [ - n['Name'] for n in self.client.networks() - if n['Name'].startswith('{}_'.format(self.project.name)) - ] - assert network_names == [] - - def test_up_with_links_v1(self): - self.base_dir = 'tests/fixtures/links-composefile' - self.dispatch(['up', '-d', 'web'], None) - - # No network was created - network_name = self.project.networks.networks['default'].full_name - networks = self.client.networks(names=[network_name]) - assert networks == [] - - web = self.project.get_service('web') - db = self.project.get_service('db') - console = self.project.get_service('console') - - # console was not started - self.assertEqual(len(web.containers()), 1) - self.assertEqual(len(db.containers()), 1) - self.assertEqual(len(console.containers()), 0) - - # web has links - web_container = web.containers()[0] - self.assertTrue(web_container.get('HostConfig.Links')) - - def test_up_with_net_is_invalid(self): - self.base_dir = 'tests/fixtures/net-container' - - result = self.dispatch( - ['-f', 'v2-invalid.yml', 'up', '-d'], - returncode=1) - - assert "Unsupported config option for services.bar: 'net'" in result.stderr - - def test_up_with_net_v1(self): - self.base_dir = 'tests/fixtures/net-container' - self.dispatch(['up', '-d'], None) - - bar = self.project.get_service('bar') - bar_container = bar.containers()[0] - - foo = self.project.get_service('foo') - foo_container = foo.containers()[0] - - assert foo_container.get('HostConfig.NetworkMode') == \ - 'container:{}'.format(bar_container.id) - - @v3_only() - def test_up_with_healthcheck(self): - def wait_on_health_status(container, status): - def condition(): - container.inspect() - return container.get('State.Health.Status') == status - - return wait_on_condition(condition, delay=0.5) - - self.base_dir = 'tests/fixtures/healthcheck' - self.dispatch(['up', '-d'], None) - - passes = self.project.get_service('passes') - passes_container = passes.containers()[0] - - assert passes_container.get('Config.Healthcheck') == { - "Test": ["CMD-SHELL", "/bin/true"], - "Interval": nanoseconds_from_time_seconds(1), - "Timeout": nanoseconds_from_time_seconds(30 * 60), - "Retries": 1, - } - - wait_on_health_status(passes_container, 'healthy') - - fails = self.project.get_service('fails') - fails_container = fails.containers()[0] - - assert fails_container.get('Config.Healthcheck') == { - "Test": ["CMD", "/bin/false"], - "Interval": nanoseconds_from_time_seconds(2.5), - "Retries": 2, - } - - wait_on_health_status(fails_container, 'unhealthy') - - disabled = self.project.get_service('disabled') - disabled_container = disabled.containers()[0] - - assert disabled_container.get('Config.Healthcheck') == { - "Test": ["NONE"], - } - - assert 'Health' not in disabled_container.get('State') - - def test_up_with_no_deps(self): - self.base_dir = 'tests/fixtures/links-composefile' - self.dispatch(['up', '-d', '--no-deps', 'web'], None) - web = self.project.get_service('web') - db = self.project.get_service('db') - console = self.project.get_service('console') - self.assertEqual(len(web.containers()), 1) - self.assertEqual(len(db.containers()), 0) - self.assertEqual(len(console.containers()), 0) - - def test_up_with_force_recreate(self): - self.dispatch(['up', '-d'], None) - service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 1) - - old_ids = [c.id for c in service.containers()] - - self.dispatch(['up', '-d', '--force-recreate'], None) - self.assertEqual(len(service.containers()), 1) - - new_ids = [c.id for c in service.containers()] - - self.assertNotEqual(old_ids, new_ids) - - def test_up_with_no_recreate(self): - self.dispatch(['up', '-d'], None) - service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 1) - - old_ids = [c.id for c in service.containers()] - - self.dispatch(['up', '-d', '--no-recreate'], None) - self.assertEqual(len(service.containers()), 1) - - new_ids = [c.id for c in service.containers()] - - self.assertEqual(old_ids, new_ids) - - def test_up_with_force_recreate_and_no_recreate(self): - self.dispatch( - ['up', '-d', '--force-recreate', '--no-recreate'], - returncode=1) - - def test_up_with_timeout(self): - self.dispatch(['up', '-d', '-t', '1']) - service = self.project.get_service('simple') - another = self.project.get_service('another') - self.assertEqual(len(service.containers()), 1) - self.assertEqual(len(another.containers()), 1) - - # Ensure containers don't have stdin and stdout connected in -d mode - config = service.containers()[0].inspect()['Config'] - self.assertFalse(config['AttachStderr']) - self.assertFalse(config['AttachStdout']) - self.assertFalse(config['AttachStdin']) - - def test_up_handles_sigint(self): - proc = start_process(self.base_dir, ['up', '-t', '2']) - wait_on_condition(ContainerCountCondition(self.project, 2)) - - os.kill(proc.pid, signal.SIGINT) - wait_on_condition(ContainerCountCondition(self.project, 0)) - - def test_up_handles_sigterm(self): - proc = start_process(self.base_dir, ['up', '-t', '2']) - wait_on_condition(ContainerCountCondition(self.project, 2)) - - os.kill(proc.pid, signal.SIGTERM) - wait_on_condition(ContainerCountCondition(self.project, 0)) - - @v2_only() - def test_up_handles_force_shutdown(self): - self.base_dir = 'tests/fixtures/sleeps-composefile' - proc = start_process(self.base_dir, ['up', '-t', '200']) - wait_on_condition(ContainerCountCondition(self.project, 2)) - - os.kill(proc.pid, signal.SIGTERM) - time.sleep(0.1) - os.kill(proc.pid, signal.SIGTERM) - wait_on_condition(ContainerCountCondition(self.project, 0)) - - def test_up_handles_abort_on_container_exit(self): - start_process(self.base_dir, ['up', '--abort-on-container-exit']) - wait_on_condition(ContainerCountCondition(self.project, 2)) - self.project.stop(['simple']) - wait_on_condition(ContainerCountCondition(self.project, 0)) - - def test_exec_without_tty(self): - self.base_dir = 'tests/fixtures/links-composefile' - self.dispatch(['up', '-d', 'console']) - self.assertEqual(len(self.project.containers()), 1) - - stdout, stderr = self.dispatch(['exec', '-T', 'console', 'ls', '-1d', '/']) - self.assertEquals(stdout, "/\n") - self.assertEquals(stderr, "") - - def test_exec_custom_user(self): - self.base_dir = 'tests/fixtures/links-composefile' - self.dispatch(['up', '-d', 'console']) - self.assertEqual(len(self.project.containers()), 1) - - stdout, stderr = self.dispatch(['exec', '-T', '--user=operator', 'console', 'whoami']) - self.assertEquals(stdout, "operator\n") - self.assertEquals(stderr, "") - - def test_run_service_without_links(self): - self.base_dir = 'tests/fixtures/links-composefile' - self.dispatch(['run', 'console', '/bin/true']) - self.assertEqual(len(self.project.containers()), 0) - - # Ensure stdin/out was open - container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] - config = container.inspect()['Config'] - self.assertTrue(config['AttachStderr']) - self.assertTrue(config['AttachStdout']) - self.assertTrue(config['AttachStdin']) - - def test_run_service_with_links(self): - self.base_dir = 'tests/fixtures/links-composefile' - self.dispatch(['run', 'web', '/bin/true'], None) - db = self.project.get_service('db') - console = self.project.get_service('console') - self.assertEqual(len(db.containers()), 1) - self.assertEqual(len(console.containers()), 0) - - @v2_only() - def test_run_service_with_dependencies(self): - self.base_dir = 'tests/fixtures/v2-dependencies' - self.dispatch(['run', 'web', '/bin/true'], None) - db = self.project.get_service('db') - console = self.project.get_service('console') - self.assertEqual(len(db.containers()), 1) - self.assertEqual(len(console.containers()), 0) - - def test_run_with_no_deps(self): - self.base_dir = 'tests/fixtures/links-composefile' - self.dispatch(['run', '--no-deps', 'web', '/bin/true']) - db = self.project.get_service('db') - self.assertEqual(len(db.containers()), 0) - - def test_run_does_not_recreate_linked_containers(self): - self.base_dir = 'tests/fixtures/links-composefile' - self.dispatch(['up', '-d', 'db']) - db = self.project.get_service('db') - self.assertEqual(len(db.containers()), 1) - - old_ids = [c.id for c in db.containers()] - - self.dispatch(['run', 'web', '/bin/true'], None) - self.assertEqual(len(db.containers()), 1) - - new_ids = [c.id for c in db.containers()] - - self.assertEqual(old_ids, new_ids) - - def test_run_without_command(self): - self.base_dir = 'tests/fixtures/commands-composefile' - self.check_build('tests/fixtures/simple-dockerfile', tag='composetest_test') - - self.dispatch(['run', 'implicit']) - service = self.project.get_service('implicit') - containers = service.containers(stopped=True, one_off=OneOffFilter.only) - self.assertEqual( - [c.human_readable_command for c in containers], - [u'/bin/sh -c echo "success"'], - ) - - self.dispatch(['run', 'explicit']) - service = self.project.get_service('explicit') - containers = service.containers(stopped=True, one_off=OneOffFilter.only) - self.assertEqual( - [c.human_readable_command for c in containers], - [u'/bin/true'], - ) - - def test_run_service_with_dockerfile_entrypoint(self): - self.base_dir = 'tests/fixtures/entrypoint-dockerfile' - self.dispatch(['run', 'test']) - container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] - assert container.get('Config.Entrypoint') == ['printf'] - assert container.get('Config.Cmd') == ['default', 'args'] - - def test_run_service_with_dockerfile_entrypoint_overridden(self): - self.base_dir = 'tests/fixtures/entrypoint-dockerfile' - self.dispatch(['run', '--entrypoint', 'echo', 'test']) - container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] - assert container.get('Config.Entrypoint') == ['echo'] - assert not container.get('Config.Cmd') - - def test_run_service_with_dockerfile_entrypoint_and_command_overridden(self): - self.base_dir = 'tests/fixtures/entrypoint-dockerfile' - self.dispatch(['run', '--entrypoint', 'echo', 'test', 'foo']) - container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] - assert container.get('Config.Entrypoint') == ['echo'] - assert container.get('Config.Cmd') == ['foo'] - - def test_run_service_with_compose_file_entrypoint(self): - self.base_dir = 'tests/fixtures/entrypoint-composefile' - self.dispatch(['run', 'test']) - container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] - assert container.get('Config.Entrypoint') == ['printf'] - assert container.get('Config.Cmd') == ['default', 'args'] - - def test_run_service_with_compose_file_entrypoint_overridden(self): - self.base_dir = 'tests/fixtures/entrypoint-composefile' - self.dispatch(['run', '--entrypoint', 'echo', 'test']) - container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] - assert container.get('Config.Entrypoint') == ['echo'] - assert not container.get('Config.Cmd') - - def test_run_service_with_compose_file_entrypoint_and_command_overridden(self): - self.base_dir = 'tests/fixtures/entrypoint-composefile' - self.dispatch(['run', '--entrypoint', 'echo', 'test', 'foo']) - container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] - assert container.get('Config.Entrypoint') == ['echo'] - assert container.get('Config.Cmd') == ['foo'] - - def test_run_service_with_compose_file_entrypoint_and_empty_string_command(self): - self.base_dir = 'tests/fixtures/entrypoint-composefile' - self.dispatch(['run', '--entrypoint', 'echo', 'test', '']) - container = self.project.containers(stopped=True, one_off=OneOffFilter.only)[0] - assert container.get('Config.Entrypoint') == ['echo'] - assert container.get('Config.Cmd') == [''] - - def test_run_service_with_user_overridden(self): - self.base_dir = 'tests/fixtures/user-composefile' - name = 'service' - user = 'sshd' - self.dispatch(['run', '--user={user}'.format(user=user), name], returncode=1) - service = self.project.get_service(name) - container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] - self.assertEqual(user, container.get('Config.User')) - - def test_run_service_with_user_overridden_short_form(self): - self.base_dir = 'tests/fixtures/user-composefile' - name = 'service' - user = 'sshd' - self.dispatch(['run', '-u', user, name], returncode=1) - service = self.project.get_service(name) - container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] - self.assertEqual(user, container.get('Config.User')) - - def test_run_service_with_environment_overridden(self): - name = 'service' - self.base_dir = 'tests/fixtures/environment-composefile' - self.dispatch([ - 'run', '-e', 'foo=notbar', - '-e', 'allo=moto=bobo', - '-e', 'alpha=beta', - name, - '/bin/true', - ]) - service = self.project.get_service(name) - container = service.containers(stopped=True, one_off=OneOffFilter.only)[0] - # env overridden - self.assertEqual('notbar', container.environment['foo']) - # keep environment from yaml - self.assertEqual('world', container.environment['hello']) - # added option from command line - self.assertEqual('beta', container.environment['alpha']) - # make sure a value with a = don't crash out - self.assertEqual('moto=bobo', container.environment['allo']) - - def test_run_service_without_map_ports(self): - # create one off container - self.base_dir = 'tests/fixtures/ports-composefile' - self.dispatch(['run', '-d', 'simple']) - container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] - - # get port information - port_random = container.get_local_port(3000) - port_assigned = container.get_local_port(3001) - - # close all one off containers we just created - container.stop() - - # check the ports - self.assertEqual(port_random, None) - self.assertEqual(port_assigned, None) - - def test_run_service_with_map_ports(self): - # create one off container - self.base_dir = 'tests/fixtures/ports-composefile' - self.dispatch(['run', '-d', '--service-ports', 'simple']) - container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] - - # get port information - port_random = container.get_local_port(3000) - port_assigned = container.get_local_port(3001) - port_range = container.get_local_port(3002), container.get_local_port(3003) - - # close all one off containers we just created - container.stop() - - # check the ports - self.assertNotEqual(port_random, None) - self.assertIn("0.0.0.0", port_random) - self.assertEqual(port_assigned, "0.0.0.0:49152") - self.assertEqual(port_range[0], "0.0.0.0:49153") - self.assertEqual(port_range[1], "0.0.0.0:49154") - - def test_run_service_with_explicitly_mapped_ports(self): - # create one off container - self.base_dir = 'tests/fixtures/ports-composefile' - self.dispatch(['run', '-d', '-p', '30000:3000', '--publish', '30001:3001', 'simple']) - container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] - - # get port information - port_short = container.get_local_port(3000) - port_full = container.get_local_port(3001) - - # close all one off containers we just created - container.stop() - - # check the ports - self.assertEqual(port_short, "0.0.0.0:30000") - self.assertEqual(port_full, "0.0.0.0:30001") - - def test_run_service_with_explicitly_mapped_ip_ports(self): - # create one off container - self.base_dir = 'tests/fixtures/ports-composefile' - self.dispatch([ - 'run', '-d', - '-p', '127.0.0.1:30000:3000', - '--publish', '127.0.0.1:30001:3001', - 'simple' - ]) - container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] - - # get port information - port_short = container.get_local_port(3000) - port_full = container.get_local_port(3001) - - # close all one off containers we just created - container.stop() - - # check the ports - self.assertEqual(port_short, "127.0.0.1:30000") - self.assertEqual(port_full, "127.0.0.1:30001") - - def test_run_with_expose_ports(self): - # create one off container - self.base_dir = 'tests/fixtures/expose-composefile' - self.dispatch(['run', '-d', '--service-ports', 'simple']) - container = self.project.get_service('simple').containers(one_off=OneOffFilter.only)[0] - - ports = container.ports - self.assertEqual(len(ports), 9) - # exposed ports are not mapped to host ports - assert ports['3000/tcp'] is None - assert ports['3001/tcp'] is None - assert ports['3001/udp'] is None - assert ports['3002/tcp'] is None - assert ports['3003/tcp'] is None - assert ports['3004/tcp'] is None - assert ports['3005/tcp'] is None - assert ports['3006/udp'] is None - assert ports['3007/udp'] is None - - # close all one off containers we just created - container.stop() - - def test_run_with_custom_name(self): - self.base_dir = 'tests/fixtures/environment-composefile' - name = 'the-container-name' - self.dispatch(['run', '--name', name, 'service', '/bin/true']) - - service = self.project.get_service('service') - container, = service.containers(stopped=True, one_off=OneOffFilter.only) - self.assertEqual(container.name, name) - - def test_run_service_with_workdir_overridden(self): - self.base_dir = 'tests/fixtures/run-workdir' - name = 'service' - workdir = '/var' - self.dispatch(['run', '--workdir={workdir}'.format(workdir=workdir), name]) - service = self.project.get_service(name) - container = service.containers(stopped=True, one_off=True)[0] - self.assertEqual(workdir, container.get('Config.WorkingDir')) - - def test_run_service_with_workdir_overridden_short_form(self): - self.base_dir = 'tests/fixtures/run-workdir' - name = 'service' - workdir = '/var' - self.dispatch(['run', '-w', workdir, name]) - service = self.project.get_service(name) - container = service.containers(stopped=True, one_off=True)[0] - self.assertEqual(workdir, container.get('Config.WorkingDir')) - - @v2_only() - def test_run_interactive_connects_to_network(self): - self.base_dir = 'tests/fixtures/networks' - - self.dispatch(['up', '-d']) - self.dispatch(['run', 'app', 'nslookup', 'app']) - self.dispatch(['run', 'app', 'nslookup', 'db']) - - containers = self.project.get_service('app').containers( - stopped=True, one_off=OneOffFilter.only) - assert len(containers) == 2 - - for container in containers: - networks = container.get('NetworkSettings.Networks') - - assert sorted(list(networks)) == [ - '{}_{}'.format(self.project.name, name) - for name in ['back', 'front'] - ] - - for _, config in networks.items(): - # TODO: once we drop support for API <1.24, this can be changed to: - # assert config['Aliases'] == [container.short_id] - aliases = set(config['Aliases'] or []) - set([container.short_id]) - assert not aliases - - @v2_only() - def test_run_detached_connects_to_network(self): - self.base_dir = 'tests/fixtures/networks' - self.dispatch(['up', '-d']) - self.dispatch(['run', '-d', 'app', 'top']) - - container = self.project.get_service('app').containers(one_off=OneOffFilter.only)[0] - networks = container.get('NetworkSettings.Networks') - - assert sorted(list(networks)) == [ - '{}_{}'.format(self.project.name, name) - for name in ['back', 'front'] - ] - - for _, config in networks.items(): - # TODO: once we drop support for API <1.24, this can be changed to: - # assert config['Aliases'] == [container.short_id] - aliases = set(config['Aliases'] or []) - set([container.short_id]) - assert not aliases - - assert self.lookup(container, 'app') - assert self.lookup(container, 'db') - - def test_run_handles_sigint(self): - proc = start_process(self.base_dir, ['run', '-T', 'simple', 'top']) - wait_on_condition(ContainerStateCondition( - self.project.client, - 'simplecomposefile_simple_run_1', - 'running')) - - os.kill(proc.pid, signal.SIGINT) - wait_on_condition(ContainerStateCondition( - self.project.client, - 'simplecomposefile_simple_run_1', - 'exited')) - - def test_run_handles_sigterm(self): - proc = start_process(self.base_dir, ['run', '-T', 'simple', 'top']) - wait_on_condition(ContainerStateCondition( - self.project.client, - 'simplecomposefile_simple_run_1', - 'running')) - - os.kill(proc.pid, signal.SIGTERM) - wait_on_condition(ContainerStateCondition( - self.project.client, - 'simplecomposefile_simple_run_1', - 'exited')) - - @mock.patch.dict(os.environ) - def test_run_unicode_env_values_from_system(self): - value = 'ą, ć, ę, ł, ń, ó, ś, ź, ż' - if six.PY2: # os.environ doesn't support unicode values in Py2 - os.environ['BAR'] = value.encode('utf-8') - else: # ... and doesn't support byte values in Py3 - os.environ['BAR'] = value - self.base_dir = 'tests/fixtures/unicode-environment' - result = self.dispatch(['run', 'simple']) - - if six.PY2: # Can't retrieve output on Py3. See issue #3670 - assert value == result.stdout.strip() - - container = self.project.containers(one_off=OneOffFilter.only, stopped=True)[0] - environment = container.get('Config.Env') - assert 'FOO={}'.format(value) in environment - - @mock.patch.dict(os.environ) - def test_run_env_values_from_system(self): - os.environ['FOO'] = 'bar' - os.environ['BAR'] = 'baz' - - self.dispatch(['run', '-e', 'FOO', 'simple', 'true'], None) - - container = self.project.containers(one_off=OneOffFilter.only, stopped=True)[0] - environment = container.get('Config.Env') - assert 'FOO=bar' in environment - assert 'BAR=baz' not in environment - - def test_rm(self): - service = self.project.get_service('simple') - service.create_container() - kill_service(service) - self.assertEqual(len(service.containers(stopped=True)), 1) - self.dispatch(['rm', '--force'], None) - self.assertEqual(len(service.containers(stopped=True)), 0) - service = self.project.get_service('simple') - service.create_container() - kill_service(service) - self.assertEqual(len(service.containers(stopped=True)), 1) - self.dispatch(['rm', '-f'], None) - self.assertEqual(len(service.containers(stopped=True)), 0) - - def test_rm_all(self): - service = self.project.get_service('simple') - service.create_container(one_off=False) - service.create_container(one_off=True) - kill_service(service) - self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 1) - self.dispatch(['rm', '-f'], None) - self.assertEqual(len(service.containers(stopped=True)), 0) - self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 0) - - service.create_container(one_off=False) - service.create_container(one_off=True) - kill_service(service) - self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 1) - self.dispatch(['rm', '-f', '--all'], None) - self.assertEqual(len(service.containers(stopped=True)), 0) - self.assertEqual(len(service.containers(stopped=True, one_off=OneOffFilter.only)), 0) - - def test_stop(self): - self.dispatch(['up', '-d'], None) - service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 1) - self.assertTrue(service.containers()[0].is_running) - - self.dispatch(['stop', '-t', '1'], None) - - self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertFalse(service.containers(stopped=True)[0].is_running) - - def test_stop_signal(self): - self.base_dir = 'tests/fixtures/stop-signal-composefile' - self.dispatch(['up', '-d'], None) - service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 1) - self.assertTrue(service.containers()[0].is_running) - - self.dispatch(['stop', '-t', '1'], None) - self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertFalse(service.containers(stopped=True)[0].is_running) - self.assertEqual(service.containers(stopped=True)[0].exit_code, 0) - - def test_start_no_containers(self): - result = self.dispatch(['start'], returncode=1) - assert 'No containers to start' in result.stderr - - @v2_only() - def test_up_logging(self): - self.base_dir = 'tests/fixtures/logging-composefile' - self.dispatch(['up', '-d']) - simple = self.project.get_service('simple').containers()[0] - log_config = simple.get('HostConfig.LogConfig') - self.assertTrue(log_config) - self.assertEqual(log_config.get('Type'), 'none') - - another = self.project.get_service('another').containers()[0] - log_config = another.get('HostConfig.LogConfig') - self.assertTrue(log_config) - self.assertEqual(log_config.get('Type'), 'json-file') - self.assertEqual(log_config.get('Config')['max-size'], '10m') - - def test_up_logging_legacy(self): - self.base_dir = 'tests/fixtures/logging-composefile-legacy' - self.dispatch(['up', '-d']) - simple = self.project.get_service('simple').containers()[0] - log_config = simple.get('HostConfig.LogConfig') - self.assertTrue(log_config) - self.assertEqual(log_config.get('Type'), 'none') - - another = self.project.get_service('another').containers()[0] - log_config = another.get('HostConfig.LogConfig') - self.assertTrue(log_config) - self.assertEqual(log_config.get('Type'), 'json-file') - self.assertEqual(log_config.get('Config')['max-size'], '10m') - - def test_pause_unpause(self): - self.dispatch(['up', '-d'], None) - service = self.project.get_service('simple') - self.assertFalse(service.containers()[0].is_paused) - - self.dispatch(['pause'], None) - self.assertTrue(service.containers()[0].is_paused) - - self.dispatch(['unpause'], None) - self.assertFalse(service.containers()[0].is_paused) - - def test_pause_no_containers(self): - result = self.dispatch(['pause'], returncode=1) - assert 'No containers to pause' in result.stderr - - def test_unpause_no_containers(self): - result = self.dispatch(['unpause'], returncode=1) - assert 'No containers to unpause' in result.stderr - - def test_logs_invalid_service_name(self): - self.dispatch(['logs', 'madeupname'], returncode=1) - - def test_logs_follow(self): - self.base_dir = 'tests/fixtures/echo-services' - self.dispatch(['up', '-d']) - - result = self.dispatch(['logs', '-f']) - - assert result.stdout.count('\n') == 5 - assert 'simple' in result.stdout - assert 'another' in result.stdout - assert 'exited with code 0' in result.stdout - - def test_logs_follow_logs_from_new_containers(self): - self.base_dir = 'tests/fixtures/logs-composefile' - self.dispatch(['up', '-d', 'simple']) - - proc = start_process(self.base_dir, ['logs', '-f']) - - self.dispatch(['up', '-d', 'another']) - wait_on_condition(ContainerStateCondition( - self.project.client, - 'logscomposefile_another_1', - 'exited')) - - self.dispatch(['kill', 'simple']) - - result = wait_on_process(proc) - - assert 'hello' in result.stdout - assert 'test' in result.stdout - assert 'logscomposefile_another_1 exited with code 0' in result.stdout - assert 'logscomposefile_simple_1 exited with code 137' in result.stdout - - def test_logs_default(self): - self.base_dir = 'tests/fixtures/logs-composefile' - self.dispatch(['up', '-d']) - - result = self.dispatch(['logs']) - assert 'hello' in result.stdout - assert 'test' in result.stdout - assert 'exited with' not in result.stdout - - def test_logs_on_stopped_containers_exits(self): - self.base_dir = 'tests/fixtures/echo-services' - self.dispatch(['up']) - - result = self.dispatch(['logs']) - assert 'simple' in result.stdout - assert 'another' in result.stdout - assert 'exited with' not in result.stdout - - def test_logs_timestamps(self): - self.base_dir = 'tests/fixtures/echo-services' - self.dispatch(['up', '-d']) - - result = self.dispatch(['logs', '-f', '-t']) - self.assertRegexpMatches(result.stdout, '(\d{4})-(\d{2})-(\d{2})T(\d{2})\:(\d{2})\:(\d{2})') - - def test_logs_tail(self): - self.base_dir = 'tests/fixtures/logs-tail-composefile' - self.dispatch(['up']) - - result = self.dispatch(['logs', '--tail', '2']) - assert result.stdout.count('\n') == 3 - - def test_kill(self): - self.dispatch(['up', '-d'], None) - service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 1) - self.assertTrue(service.containers()[0].is_running) - - self.dispatch(['kill'], None) - - self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertFalse(service.containers(stopped=True)[0].is_running) - - def test_kill_signal_sigstop(self): - self.dispatch(['up', '-d'], None) - service = self.project.get_service('simple') - self.assertEqual(len(service.containers()), 1) - self.assertTrue(service.containers()[0].is_running) - - self.dispatch(['kill', '-s', 'SIGSTOP'], None) - - self.assertEqual(len(service.containers()), 1) - # The container is still running. It has only been paused - self.assertTrue(service.containers()[0].is_running) - - def test_kill_stopped_service(self): - self.dispatch(['up', '-d'], None) - service = self.project.get_service('simple') - self.dispatch(['kill', '-s', 'SIGSTOP'], None) - self.assertTrue(service.containers()[0].is_running) - - self.dispatch(['kill', '-s', 'SIGKILL'], None) - - self.assertEqual(len(service.containers(stopped=True)), 1) - self.assertFalse(service.containers(stopped=True)[0].is_running) - - def test_restart(self): - service = self.project.get_service('simple') - container = service.create_container() - service.start_container(container) - started_at = container.dictionary['State']['StartedAt'] - self.dispatch(['restart', '-t', '1'], None) - container.inspect() - self.assertNotEqual( - container.dictionary['State']['FinishedAt'], - '0001-01-01T00:00:00Z', - ) - self.assertNotEqual( - container.dictionary['State']['StartedAt'], - started_at, - ) - - def test_restart_stopped_container(self): - service = self.project.get_service('simple') - container = service.create_container() - container.start() - container.kill() - self.assertEqual(len(service.containers(stopped=True)), 1) - self.dispatch(['restart', '-t', '1'], None) - self.assertEqual(len(service.containers(stopped=False)), 1) - - def test_restart_no_containers(self): - result = self.dispatch(['restart'], returncode=1) - assert 'No containers to restart' in result.stderr - - def test_scale(self): - project = self.project - - self.dispatch(['scale', 'simple=1']) - self.assertEqual(len(project.get_service('simple').containers()), 1) - - self.dispatch(['scale', 'simple=3', 'another=2']) - self.assertEqual(len(project.get_service('simple').containers()), 3) - self.assertEqual(len(project.get_service('another').containers()), 2) - - self.dispatch(['scale', 'simple=1', 'another=1']) - self.assertEqual(len(project.get_service('simple').containers()), 1) - self.assertEqual(len(project.get_service('another').containers()), 1) - - self.dispatch(['scale', 'simple=1', 'another=1']) - self.assertEqual(len(project.get_service('simple').containers()), 1) - self.assertEqual(len(project.get_service('another').containers()), 1) - - self.dispatch(['scale', 'simple=0', 'another=0']) - self.assertEqual(len(project.get_service('simple').containers()), 0) - self.assertEqual(len(project.get_service('another').containers()), 0) - - def test_port(self): - self.base_dir = 'tests/fixtures/ports-composefile' - self.dispatch(['up', '-d'], None) - container = self.project.get_service('simple').get_container() - - def get_port(number): - result = self.dispatch(['port', 'simple', str(number)]) - return result.stdout.rstrip() - - self.assertEqual(get_port(3000), container.get_local_port(3000)) - self.assertEqual(get_port(3001), "0.0.0.0:49152") - self.assertEqual(get_port(3002), "0.0.0.0:49153") - - def test_port_with_scale(self): - self.base_dir = 'tests/fixtures/ports-composefile-scale' - self.dispatch(['scale', 'simple=2'], None) - containers = sorted( - self.project.containers(service_names=['simple']), - key=attrgetter('name')) - - def get_port(number, index=None): - if index is None: - result = self.dispatch(['port', 'simple', str(number)]) - else: - result = self.dispatch(['port', '--index=' + str(index), 'simple', str(number)]) - return result.stdout.rstrip() - - self.assertEqual(get_port(3000), containers[0].get_local_port(3000)) - self.assertEqual(get_port(3000, index=1), containers[0].get_local_port(3000)) - self.assertEqual(get_port(3000, index=2), containers[1].get_local_port(3000)) - self.assertEqual(get_port(3002), "") - - def test_events_json(self): - events_proc = start_process(self.base_dir, ['events', '--json']) - self.dispatch(['up', '-d']) - wait_on_condition(ContainerCountCondition(self.project, 2)) - - os.kill(events_proc.pid, signal.SIGINT) - result = wait_on_process(events_proc, returncode=1) - lines = [json.loads(line) for line in result.stdout.rstrip().split('\n')] - assert Counter(e['action'] for e in lines) == {'create': 2, 'start': 2} - - def test_events_human_readable(self): - - def has_timestamp(string): - str_iso_date, str_iso_time, container_info = string.split(' ', 2) - try: - return isinstance(datetime.datetime.strptime( - '%s %s' % (str_iso_date, str_iso_time), - '%Y-%m-%d %H:%M:%S.%f'), - datetime.datetime) - except ValueError: - return False - - events_proc = start_process(self.base_dir, ['events']) - self.dispatch(['up', '-d', 'simple']) - wait_on_condition(ContainerCountCondition(self.project, 1)) - - os.kill(events_proc.pid, signal.SIGINT) - result = wait_on_process(events_proc, returncode=1) - lines = result.stdout.rstrip().split('\n') - assert len(lines) == 2 - - container, = self.project.containers() - expected_template = ( - ' container {} {} (image=busybox:latest, ' - 'name=simplecomposefile_simple_1)') - - assert expected_template.format('create', container.id) in lines[0] - assert expected_template.format('start', container.id) in lines[1] - - assert has_timestamp(lines[0]) - - def test_env_file_relative_to_compose_file(self): - config_path = os.path.abspath('tests/fixtures/env-file/docker-compose.yml') - self.dispatch(['-f', config_path, 'up', '-d'], None) - self._project = get_project(self.base_dir, [config_path]) - - containers = self.project.containers(stopped=True) - self.assertEqual(len(containers), 1) - self.assertIn("FOO=1", containers[0].get('Config.Env')) - - @mock.patch.dict(os.environ) - def test_home_and_env_var_in_volume_path(self): - os.environ['VOLUME_NAME'] = 'my-volume' - os.environ['HOME'] = '/tmp/home-dir' - - self.base_dir = 'tests/fixtures/volume-path-interpolation' - self.dispatch(['up', '-d'], None) - - container = self.project.containers(stopped=True)[0] - actual_host_path = container.get_mount('/container-path')['Source'] - components = actual_host_path.split('/') - assert components[-2:] == ['home-dir', 'my-volume'] - - def test_up_with_default_override_file(self): - self.base_dir = 'tests/fixtures/override-files' - self.dispatch(['up', '-d'], None) - - containers = self.project.containers() - self.assertEqual(len(containers), 2) - - web, db = containers - self.assertEqual(web.human_readable_command, 'top') - self.assertEqual(db.human_readable_command, 'top') - - def test_up_with_multiple_files(self): - self.base_dir = 'tests/fixtures/override-files' - config_paths = [ - 'docker-compose.yml', - 'docker-compose.override.yml', - 'extra.yml', - - ] - self._project = get_project(self.base_dir, config_paths) - self.dispatch( - [ - '-f', config_paths[0], - '-f', config_paths[1], - '-f', config_paths[2], - 'up', '-d', - ], - None) - - containers = self.project.containers() - self.assertEqual(len(containers), 3) - - web, other, db = containers - self.assertEqual(web.human_readable_command, 'top') - self.assertTrue({'db', 'other'} <= set(get_links(web))) - self.assertEqual(db.human_readable_command, 'top') - self.assertEqual(other.human_readable_command, 'top') - - def test_up_with_extends(self): - self.base_dir = 'tests/fixtures/extends' - self.dispatch(['up', '-d'], None) - - self.assertEqual( - set([s.name for s in self.project.services]), - set(['mydb', 'myweb']), - ) - - # Sort by name so we get [db, web] - containers = sorted( - self.project.containers(stopped=True), - key=lambda c: c.name, - ) - - self.assertEqual(len(containers), 2) - web = containers[1] - - self.assertEqual( - set(get_links(web)), - set(['db', 'mydb_1', 'extends_mydb_1'])) - - expected_env = set([ - "FOO=1", - "BAR=2", - "BAZ=2", - ]) - self.assertTrue(expected_env <= set(web.get('Config.Env'))) - - def test_top_services_not_running(self): - self.base_dir = 'tests/fixtures/top' - result = self.dispatch(['top']) - assert len(result.stdout) == 0 - - def test_top_services_running(self): - self.base_dir = 'tests/fixtures/top' - self.dispatch(['up', '-d']) - result = self.dispatch(['top']) - - self.assertIn('top_service_a', result.stdout) - self.assertIn('top_service_b', result.stdout) - self.assertNotIn('top_not_a_service', result.stdout) - - def test_top_processes_running(self): - self.base_dir = 'tests/fixtures/top' - self.dispatch(['up', '-d']) - result = self.dispatch(['top']) - assert result.stdout.count("top") == 4 diff --git a/tests/fixtures/build-ctx/Dockerfile b/tests/fixtures/build-ctx/Dockerfile index dd864b83..d1ceac6b 100644 --- a/tests/fixtures/build-ctx/Dockerfile +++ b/tests/fixtures/build-ctx/Dockerfile @@ -1,3 +1,2 @@ FROM busybox:latest -LABEL com.docker.compose.test_image=true CMD echo "success" diff --git a/tests/fixtures/bundle-with-digests/docker-compose.yml b/tests/fixtures/bundle-with-digests/docker-compose.yml deleted file mode 100644 index b7013512..00000000 --- a/tests/fixtures/bundle-with-digests/docker-compose.yml +++ /dev/null @@ -1,9 +0,0 @@ - -version: '2.0' - -services: - web: - image: dockercloud/hello-world@sha256:fe79a2cfbd17eefc344fb8419420808df95a1e22d93b7f621a7399fd1e9dca1d - - redis: - image: redis@sha256:a84cb8f53a70e19f61ff2e1d5e73fb7ae62d374b2b7392de1e7d77be26ef8f7b diff --git a/tests/fixtures/default-env-file/.env b/tests/fixtures/default-env-file/.env deleted file mode 100644 index 996c886c..00000000 --- a/tests/fixtures/default-env-file/.env +++ /dev/null @@ -1,4 +0,0 @@ -IMAGE=alpine:latest -COMMAND=true -PORT1=5643 -PORT2=9999 \ No newline at end of file diff --git a/tests/fixtures/default-env-file/docker-compose.yml b/tests/fixtures/default-env-file/docker-compose.yml deleted file mode 100644 index aa8e4409..00000000 --- a/tests/fixtures/default-env-file/docker-compose.yml +++ /dev/null @@ -1,6 +0,0 @@ -web: - image: ${IMAGE} - command: ${COMMAND} - ports: - - $PORT1 - - $PORT2 diff --git a/tests/fixtures/dockerfile-with-volume/Dockerfile b/tests/fixtures/dockerfile-with-volume/Dockerfile index 0d376ec4..6e5d0a55 100644 --- a/tests/fixtures/dockerfile-with-volume/Dockerfile +++ b/tests/fixtures/dockerfile-with-volume/Dockerfile @@ -1,4 +1,3 @@ -FROM busybox:latest -LABEL com.docker.compose.test_image=true +FROM busybox VOLUME /data CMD top diff --git a/tests/fixtures/dockerfile_with_entrypoint/Dockerfile b/tests/fixtures/dockerfile_with_entrypoint/Dockerfile new file mode 100644 index 00000000..7d28d293 --- /dev/null +++ b/tests/fixtures/dockerfile_with_entrypoint/Dockerfile @@ -0,0 +1,2 @@ +FROM busybox:latest +ENTRYPOINT echo "From prebuilt entrypoint" diff --git a/tests/fixtures/simple-failing-dockerfile/docker-compose.yml b/tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml similarity index 55% rename from tests/fixtures/simple-failing-dockerfile/docker-compose.yml rename to tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml index b0357541..78631502 100644 --- a/tests/fixtures/simple-failing-dockerfile/docker-compose.yml +++ b/tests/fixtures/dockerfile_with_entrypoint/docker-compose.yml @@ -1,2 +1,2 @@ -simple: +service: build: . diff --git a/tests/fixtures/echo-services/docker-compose.yml b/tests/fixtures/echo-services/docker-compose.yml deleted file mode 100644 index 8014f3d9..00000000 --- a/tests/fixtures/echo-services/docker-compose.yml +++ /dev/null @@ -1,6 +0,0 @@ -simple: - image: busybox:latest - command: echo simple -another: - image: busybox:latest - command: echo another diff --git a/tests/fixtures/entrypoint-composefile/docker-compose.yml b/tests/fixtures/entrypoint-composefile/docker-compose.yml deleted file mode 100644 index e9880973..00000000 --- a/tests/fixtures/entrypoint-composefile/docker-compose.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: "2" -services: - test: - image: busybox - entrypoint: printf - command: default args diff --git a/tests/fixtures/entrypoint-dockerfile/Dockerfile b/tests/fixtures/entrypoint-dockerfile/Dockerfile deleted file mode 100644 index 49f4416c..00000000 --- a/tests/fixtures/entrypoint-dockerfile/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM busybox:latest -LABEL com.docker.compose.test_image=true -ENTRYPOINT ["printf"] -CMD ["default", "args"] diff --git a/tests/fixtures/entrypoint-dockerfile/docker-compose.yml b/tests/fixtures/entrypoint-dockerfile/docker-compose.yml deleted file mode 100644 index 8318e61f..00000000 --- a/tests/fixtures/entrypoint-dockerfile/docker-compose.yml +++ /dev/null @@ -1,4 +0,0 @@ -version: "2" -services: - test: - build: . diff --git a/tests/fixtures/env/resolve.env b/tests/fixtures/env/resolve.env index b4f76b29..720520d2 100644 --- a/tests/fixtures/env/resolve.env +++ b/tests/fixtures/env/resolve.env @@ -1,4 +1,4 @@ -FILE_DEF=bär +FILE_DEF=F1 FILE_DEF_EMPTY= ENV_DEF NO_DEF diff --git a/tests/fixtures/environment-interpolation/docker-compose.yml b/tests/fixtures/environment-interpolation/docker-compose.yml deleted file mode 100644 index 7ed43a81..00000000 --- a/tests/fixtures/environment-interpolation/docker-compose.yml +++ /dev/null @@ -1,17 +0,0 @@ -web: - # unbracketed name - image: $IMAGE - - # array element - ports: - - "${HOST_PORT}:8000" - - # dictionary item value - labels: - mylabel: "${LABEL_VALUE}" - - # unset value - hostname: "host-${UNSET_VALUE}" - - # escaped interpolation - command: "$${ESCAPED}" diff --git a/tests/fixtures/expose-composefile/docker-compose.yml b/tests/fixtures/expose-composefile/docker-compose.yml deleted file mode 100644 index d14a468d..00000000 --- a/tests/fixtures/expose-composefile/docker-compose.yml +++ /dev/null @@ -1,11 +0,0 @@ - -simple: - image: busybox:latest - command: top - expose: - - '3000' - - '3001/tcp' - - '3001/udp' - - '3002-3003' - - '3004-3005/tcp' - - '3006-3007/udp' diff --git a/tests/fixtures/extends/circle-1.yml b/tests/fixtures/extends/circle-1.yml index d88ea61d..a034e961 100644 --- a/tests/fixtures/extends/circle-1.yml +++ b/tests/fixtures/extends/circle-1.yml @@ -5,7 +5,7 @@ bar: web: extends: file: circle-2.yml - service: other + service: web baz: image: busybox quux: diff --git a/tests/fixtures/extends/circle-2.yml b/tests/fixtures/extends/circle-2.yml index de05bc8d..fa6ddefc 100644 --- a/tests/fixtures/extends/circle-2.yml +++ b/tests/fixtures/extends/circle-2.yml @@ -2,7 +2,7 @@ foo: image: busybox bar: image: busybox -other: +web: extends: file: circle-1.yml service: web diff --git a/tests/fixtures/extends/common-env-labels-ulimits.yml b/tests/fixtures/extends/common-env-labels-ulimits.yml deleted file mode 100644 index 09efb4e7..00000000 --- a/tests/fixtures/extends/common-env-labels-ulimits.yml +++ /dev/null @@ -1,13 +0,0 @@ -web: - extends: - file: common.yml - service: web - environment: - - FOO=2 - - BAZ=3 - labels: ['label=one'] - ulimits: - nproc: 65535 - memlock: - soft: 1024 - hard: 2048 diff --git a/tests/fixtures/extends/common.yml b/tests/fixtures/extends/common.yml index b2d86aa4..358ef5bc 100644 --- a/tests/fixtures/extends/common.yml +++ b/tests/fixtures/extends/common.yml @@ -1,7 +1,6 @@ web: image: busybox command: /bin/true - net: host environment: - FOO=1 - BAR=1 diff --git a/tests/fixtures/extends/docker-compose.yml b/tests/fixtures/extends/docker-compose.yml index 8e37d404..c51be49e 100644 --- a/tests/fixtures/extends/docker-compose.yml +++ b/tests/fixtures/extends/docker-compose.yml @@ -11,7 +11,6 @@ myweb: BAR: "2" # add BAZ BAZ: "2" - net: bridge mydb: image: busybox command: top diff --git a/tests/fixtures/extends/healthcheck-1.yml b/tests/fixtures/extends/healthcheck-1.yml deleted file mode 100644 index 4c311e62..00000000 --- a/tests/fixtures/extends/healthcheck-1.yml +++ /dev/null @@ -1,9 +0,0 @@ -version: '2.1' -services: - demo: - image: foobar:latest - healthcheck: - test: ["CMD", "/health.sh"] - interval: 10s - timeout: 5s - retries: 36 diff --git a/tests/fixtures/extends/healthcheck-2.yml b/tests/fixtures/extends/healthcheck-2.yml deleted file mode 100644 index 11bc9f09..00000000 --- a/tests/fixtures/extends/healthcheck-2.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: '2.1' -services: - demo: - extends: - file: healthcheck-1.yml - service: demo diff --git a/tests/fixtures/extends/invalid-links.yml b/tests/fixtures/extends/invalid-links.yml deleted file mode 100644 index cea740cb..00000000 --- a/tests/fixtures/extends/invalid-links.yml +++ /dev/null @@ -1,11 +0,0 @@ -mydb: - build: '.' -myweb: - build: '.' - extends: - service: web - command: top -web: - build: '.' - links: - - "mydb:db" diff --git a/tests/fixtures/extends/invalid-net-v2.yml b/tests/fixtures/extends/invalid-net-v2.yml deleted file mode 100644 index 7ba714e8..00000000 --- a/tests/fixtures/extends/invalid-net-v2.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: "2" -services: - myweb: - build: '.' - extends: - service: web - command: top - web: - build: '.' - network_mode: "service:net" - net: - build: '.' diff --git a/tests/fixtures/extends/invalid-net.yml b/tests/fixtures/extends/invalid-net.yml deleted file mode 100644 index fbcd020b..00000000 --- a/tests/fixtures/extends/invalid-net.yml +++ /dev/null @@ -1,8 +0,0 @@ -myweb: - build: '.' - extends: - service: web - command: top -web: - build: '.' - net: "container:db" diff --git a/tests/fixtures/extends/invalid-volumes.yml b/tests/fixtures/extends/invalid-volumes.yml deleted file mode 100644 index 3db0118e..00000000 --- a/tests/fixtures/extends/invalid-volumes.yml +++ /dev/null @@ -1,9 +0,0 @@ -myweb: - build: '.' - extends: - service: web - command: top -web: - build: '.' - volumes_from: - - "db" diff --git a/tests/fixtures/extends/no-file-specified.yml b/tests/fixtures/extends/no-file-specified.yml deleted file mode 100644 index 40e43c4b..00000000 --- a/tests/fixtures/extends/no-file-specified.yml +++ /dev/null @@ -1,9 +0,0 @@ -myweb: - extends: - service: web - environment: - - "BAR=1" -web: - image: busybox - environment: - - "BAZ=3" diff --git a/tests/fixtures/extends/nonexistent-path-base.yml b/tests/fixtures/extends/nonexistent-path-base.yml index 4e6c82b0..1cf9a304 100644 --- a/tests/fixtures/extends/nonexistent-path-base.yml +++ b/tests/fixtures/extends/nonexistent-path-base.yml @@ -3,4 +3,4 @@ dnebase: command: /bin/true environment: - FOO=1 - - BAR=1 + - BAR=1 \ No newline at end of file diff --git a/tests/fixtures/extends/nonexistent-path-child.yml b/tests/fixtures/extends/nonexistent-path-child.yml index d3b732f2..aab11459 100644 --- a/tests/fixtures/extends/nonexistent-path-child.yml +++ b/tests/fixtures/extends/nonexistent-path-child.yml @@ -5,4 +5,4 @@ dnechild: image: busybox command: /bin/true environment: - - BAR=2 + - BAR=2 \ No newline at end of file diff --git a/tests/fixtures/extends/nonexistent-service.yml b/tests/fixtures/extends/nonexistent-service.yml deleted file mode 100644 index e9e17f1b..00000000 --- a/tests/fixtures/extends/nonexistent-service.yml +++ /dev/null @@ -1,4 +0,0 @@ -web: - image: busybox - extends: - service: foo diff --git a/tests/fixtures/extends/service-with-invalid-schema.yml b/tests/fixtures/extends/service-with-invalid-schema.yml deleted file mode 100644 index 00c36647..00000000 --- a/tests/fixtures/extends/service-with-invalid-schema.yml +++ /dev/null @@ -1,4 +0,0 @@ -myweb: - extends: - file: valid-composite-extends.yml - service: web diff --git a/tests/fixtures/extends/service-with-valid-composite-extends.yml b/tests/fixtures/extends/service-with-valid-composite-extends.yml deleted file mode 100644 index 6c419ed0..00000000 --- a/tests/fixtures/extends/service-with-valid-composite-extends.yml +++ /dev/null @@ -1,5 +0,0 @@ -myweb: - build: '.' - extends: - file: 'valid-composite-extends.yml' - service: web diff --git a/tests/fixtures/extends/specify-file-as-self.yml b/tests/fixtures/extends/specify-file-as-self.yml deleted file mode 100644 index c24f10bc..00000000 --- a/tests/fixtures/extends/specify-file-as-self.yml +++ /dev/null @@ -1,17 +0,0 @@ -myweb: - extends: - file: specify-file-as-self.yml - service: web - environment: - - "BAR=1" -web: - extends: - file: specify-file-as-self.yml - service: otherweb - image: busybox - environment: - - "BAZ=3" -otherweb: - image: busybox - environment: - - "YEP=1" diff --git a/tests/fixtures/extends/valid-common-config.yml b/tests/fixtures/extends/valid-common-config.yml deleted file mode 100644 index d8f13e7a..00000000 --- a/tests/fixtures/extends/valid-common-config.yml +++ /dev/null @@ -1,6 +0,0 @@ -myweb: - build: '.' - extends: - file: valid-common.yml - service: common-config - command: top diff --git a/tests/fixtures/extends/valid-common.yml b/tests/fixtures/extends/valid-common.yml deleted file mode 100644 index 07ad68e3..00000000 --- a/tests/fixtures/extends/valid-common.yml +++ /dev/null @@ -1,3 +0,0 @@ -common-config: - environment: - - FOO=1 diff --git a/tests/fixtures/extends/valid-composite-extends.yml b/tests/fixtures/extends/valid-composite-extends.yml deleted file mode 100644 index 8816c3f3..00000000 --- a/tests/fixtures/extends/valid-composite-extends.yml +++ /dev/null @@ -1,2 +0,0 @@ -web: - command: top diff --git a/tests/fixtures/extends/valid-interpolation-2.yml b/tests/fixtures/extends/valid-interpolation-2.yml deleted file mode 100644 index cb7bd93f..00000000 --- a/tests/fixtures/extends/valid-interpolation-2.yml +++ /dev/null @@ -1,3 +0,0 @@ -web: - build: '.' - hostname: "host-${HOSTNAME_VALUE}" diff --git a/tests/fixtures/extends/valid-interpolation.yml b/tests/fixtures/extends/valid-interpolation.yml deleted file mode 100644 index 68e8740f..00000000 --- a/tests/fixtures/extends/valid-interpolation.yml +++ /dev/null @@ -1,5 +0,0 @@ -myweb: - extends: - service: web - file: valid-interpolation-2.yml - command: top diff --git a/tests/fixtures/extends/verbose-and-shorthand.yml b/tests/fixtures/extends/verbose-and-shorthand.yml deleted file mode 100644 index d3816302..00000000 --- a/tests/fixtures/extends/verbose-and-shorthand.yml +++ /dev/null @@ -1,15 +0,0 @@ -base: - image: busybox - environment: - - "BAR=1" - -verbose: - extends: - service: base - environment: - - "FOO=1" - -shorthand: - extends: base - environment: - - "FOO=2" diff --git a/tests/fixtures/healthcheck/docker-compose.yml b/tests/fixtures/healthcheck/docker-compose.yml deleted file mode 100644 index 2c45b8d8..00000000 --- a/tests/fixtures/healthcheck/docker-compose.yml +++ /dev/null @@ -1,24 +0,0 @@ -version: "3" -services: - passes: - image: busybox - command: top - healthcheck: - test: "/bin/true" - interval: 1s - timeout: 30m - retries: 1 - - fails: - image: busybox - command: top - healthcheck: - test: ["CMD", "/bin/false"] - interval: 2.5s - retries: 2 - - disabled: - image: busybox - command: top - healthcheck: - disable: true diff --git a/tests/fixtures/invalid-composefile/invalid.yml b/tests/fixtures/invalid-composefile/invalid.yml deleted file mode 100644 index 0e74be44..00000000 --- a/tests/fixtures/invalid-composefile/invalid.yml +++ /dev/null @@ -1,5 +0,0 @@ - -notaservice: oops - -web: - image: 'alpine:edge' diff --git a/tests/fixtures/logging-composefile-legacy/docker-compose.yml b/tests/fixtures/logging-composefile-legacy/docker-compose.yml deleted file mode 100644 index ee994107..00000000 --- a/tests/fixtures/logging-composefile-legacy/docker-compose.yml +++ /dev/null @@ -1,10 +0,0 @@ -simple: - image: busybox:latest - command: top - log_driver: "none" -another: - image: busybox:latest - command: top - log_driver: "json-file" - log_opt: - max-size: "10m" diff --git a/tests/fixtures/logging-composefile/docker-compose.yml b/tests/fixtures/logging-composefile/docker-compose.yml deleted file mode 100644 index 466d13e5..00000000 --- a/tests/fixtures/logging-composefile/docker-compose.yml +++ /dev/null @@ -1,14 +0,0 @@ -version: "2" -services: - simple: - image: busybox:latest - command: top - logging: - driver: "none" - another: - image: busybox:latest - command: top - logging: - driver: "json-file" - options: - max-size: "10m" diff --git a/tests/fixtures/logs-composefile/docker-compose.yml b/tests/fixtures/logs-composefile/docker-compose.yml deleted file mode 100644 index b719c91e..00000000 --- a/tests/fixtures/logs-composefile/docker-compose.yml +++ /dev/null @@ -1,6 +0,0 @@ -simple: - image: busybox:latest - command: sh -c "echo hello && tail -f /dev/null" -another: - image: busybox:latest - command: sh -c "echo test" diff --git a/tests/fixtures/logs-tail-composefile/docker-compose.yml b/tests/fixtures/logs-tail-composefile/docker-compose.yml deleted file mode 100644 index 80d8feae..00000000 --- a/tests/fixtures/logs-tail-composefile/docker-compose.yml +++ /dev/null @@ -1,3 +0,0 @@ -simple: - image: busybox:latest - command: sh -c "echo a && echo b && echo c && echo d" diff --git a/tests/fixtures/longer-filename-composefile/docker-compose.yaml b/tests/fixtures/longer-filename-composefile/docker-compose.yaml index a4eba2d0..b55a9e12 100644 --- a/tests/fixtures/longer-filename-composefile/docker-compose.yaml +++ b/tests/fixtures/longer-filename-composefile/docker-compose.yaml @@ -1,3 +1,3 @@ definedinyamlnotyml: image: busybox:latest - command: top + command: top \ No newline at end of file diff --git a/tests/fixtures/net-container/docker-compose.yml b/tests/fixtures/net-container/docker-compose.yml deleted file mode 100644 index b5506e0e..00000000 --- a/tests/fixtures/net-container/docker-compose.yml +++ /dev/null @@ -1,7 +0,0 @@ -foo: - image: busybox - command: top - net: "container:bar" -bar: - image: busybox - command: top diff --git a/tests/fixtures/net-container/v2-invalid.yml b/tests/fixtures/net-container/v2-invalid.yml deleted file mode 100644 index 9b846295..00000000 --- a/tests/fixtures/net-container/v2-invalid.yml +++ /dev/null @@ -1,10 +0,0 @@ -version: "2" - -services: - foo: - image: busybox - command: top - bar: - image: busybox - command: top - net: "container:foo" diff --git a/tests/fixtures/networks/bridge.yml b/tests/fixtures/networks/bridge.yml deleted file mode 100644 index 9fa7db82..00000000 --- a/tests/fixtures/networks/bridge.yml +++ /dev/null @@ -1,9 +0,0 @@ -version: "2" - -services: - web: - image: busybox - command: top - networks: - - bridge - - default diff --git a/tests/fixtures/networks/default-network-config.yml b/tests/fixtures/networks/default-network-config.yml deleted file mode 100644 index 4bd0989b..00000000 --- a/tests/fixtures/networks/default-network-config.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: "2" -services: - simple: - image: busybox:latest - command: top - another: - image: busybox:latest - command: top -networks: - default: - driver: bridge - driver_opts: - "com.docker.network.bridge.enable_icc": "false" diff --git a/tests/fixtures/networks/docker-compose.yml b/tests/fixtures/networks/docker-compose.yml deleted file mode 100644 index c11fa682..00000000 --- a/tests/fixtures/networks/docker-compose.yml +++ /dev/null @@ -1,21 +0,0 @@ -version: "2" - -services: - web: - image: busybox - command: top - networks: ["front"] - app: - image: busybox - command: top - networks: ["front", "back"] - links: - - "db:database" - db: - image: busybox - command: top - networks: ["back"] - -networks: - front: {} - back: {} diff --git a/tests/fixtures/networks/external-default.yml b/tests/fixtures/networks/external-default.yml deleted file mode 100644 index 5c9426b8..00000000 --- a/tests/fixtures/networks/external-default.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: "2" -services: - simple: - image: busybox:latest - command: top - another: - image: busybox:latest - command: top -networks: - default: - external: - name: composetest_external_network diff --git a/tests/fixtures/networks/external-networks.yml b/tests/fixtures/networks/external-networks.yml deleted file mode 100644 index db75b780..00000000 --- a/tests/fixtures/networks/external-networks.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: "2" - -services: - web: - image: busybox - command: top - networks: - - networks_foo - - bar - -networks: - networks_foo: - external: true - bar: - external: - name: networks_bar diff --git a/tests/fixtures/networks/missing-network.yml b/tests/fixtures/networks/missing-network.yml deleted file mode 100644 index 41012535..00000000 --- a/tests/fixtures/networks/missing-network.yml +++ /dev/null @@ -1,10 +0,0 @@ -version: "2" - -services: - web: - image: busybox - command: top - networks: ["foo"] - -networks: - bar: {} diff --git a/tests/fixtures/networks/network-aliases.yml b/tests/fixtures/networks/network-aliases.yml deleted file mode 100644 index 8cf7d5af..00000000 --- a/tests/fixtures/networks/network-aliases.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: "2" - -services: - web: - image: busybox - command: top - networks: - front: - aliases: - - forward_facing - - ahead - back: - -networks: - front: {} - back: {} diff --git a/tests/fixtures/networks/network-internal.yml b/tests/fixtures/networks/network-internal.yml deleted file mode 100755 index 1fa339b1..00000000 --- a/tests/fixtures/networks/network-internal.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: "2" - -services: - web: - image: busybox - command: top - networks: - - internal - -networks: - internal: - driver: bridge - internal: True diff --git a/tests/fixtures/networks/network-label.yml b/tests/fixtures/networks/network-label.yml deleted file mode 100644 index fdb24f65..00000000 --- a/tests/fixtures/networks/network-label.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: "2.1" - -services: - web: - image: busybox - command: top - networks: - - network_with_label - -networks: - network_with_label: - labels: - - "label_key=label_val" diff --git a/tests/fixtures/networks/network-mode.yml b/tests/fixtures/networks/network-mode.yml deleted file mode 100644 index e4d070b4..00000000 --- a/tests/fixtures/networks/network-mode.yml +++ /dev/null @@ -1,27 +0,0 @@ -version: "2" - -services: - bridge: - image: busybox - command: top - network_mode: bridge - - service: - image: busybox - command: top - network_mode: "service:bridge" - - container: - image: busybox - command: top - network_mode: "container:composetest_network_mode_container" - - host: - image: busybox - command: top - network_mode: host - - none: - image: busybox - command: top - network_mode: none diff --git a/tests/fixtures/networks/network-static-addresses.yml b/tests/fixtures/networks/network-static-addresses.yml deleted file mode 100755 index f820ff6a..00000000 --- a/tests/fixtures/networks/network-static-addresses.yml +++ /dev/null @@ -1,23 +0,0 @@ -version: "2" - -services: - web: - image: busybox - command: top - networks: - static_test: - ipv4_address: 172.16.100.100 - ipv6_address: fe80::1001:100 - -networks: - static_test: - driver: bridge - driver_opts: - com.docker.network.enable_ipv6: "true" - ipam: - driver: default - config: - - subnet: 172.16.100.0/24 - gateway: 172.16.100.1 - - subnet: fe80::/64 - gateway: fe80::1001:1 diff --git a/tests/fixtures/no-links-composefile/docker-compose.yml b/tests/fixtures/no-links-composefile/docker-compose.yml deleted file mode 100644 index 75a6a085..00000000 --- a/tests/fixtures/no-links-composefile/docker-compose.yml +++ /dev/null @@ -1,9 +0,0 @@ -db: - image: busybox:latest - command: top -web: - image: busybox:latest - command: top -console: - image: busybox:latest - command: top diff --git a/tests/fixtures/no-services/docker-compose.yml b/tests/fixtures/no-services/docker-compose.yml deleted file mode 100644 index 6e76ec0c..00000000 --- a/tests/fixtures/no-services/docker-compose.yml +++ /dev/null @@ -1,5 +0,0 @@ -version: "2" - -networks: - foo: {} - bar: {} diff --git a/tests/fixtures/override-files/docker-compose.override.yml b/tests/fixtures/override-files/docker-compose.override.yml deleted file mode 100644 index a03d3d6f..00000000 --- a/tests/fixtures/override-files/docker-compose.override.yml +++ /dev/null @@ -1,6 +0,0 @@ - -web: - command: "top" - -db: - command: "top" diff --git a/tests/fixtures/override-files/docker-compose.yml b/tests/fixtures/override-files/docker-compose.yml deleted file mode 100644 index 8eb43ddb..00000000 --- a/tests/fixtures/override-files/docker-compose.yml +++ /dev/null @@ -1,10 +0,0 @@ - -web: - image: busybox:latest - command: "sleep 200" - links: - - db - -db: - image: busybox:latest - command: "sleep 200" diff --git a/tests/fixtures/override-files/extra.yml b/tests/fixtures/override-files/extra.yml deleted file mode 100644 index 7b3ade9c..00000000 --- a/tests/fixtures/override-files/extra.yml +++ /dev/null @@ -1,9 +0,0 @@ - -web: - links: - - db - - other - -other: - image: busybox:latest - command: "top" diff --git a/tests/fixtures/ports-composefile/docker-compose.yml b/tests/fixtures/ports-composefile/docker-compose.yml index c213068d..9496ee08 100644 --- a/tests/fixtures/ports-composefile/docker-compose.yml +++ b/tests/fixtures/ports-composefile/docker-compose.yml @@ -5,4 +5,3 @@ simple: ports: - '3000' - '49152:3001' - - '49153-49154:3002-3003' diff --git a/tests/fixtures/restart/docker-compose.yml b/tests/fixtures/restart/docker-compose.yml deleted file mode 100644 index ecfdfbf5..00000000 --- a/tests/fixtures/restart/docker-compose.yml +++ /dev/null @@ -1,17 +0,0 @@ -version: "2" -services: - never: - image: busybox - restart: "no" - always: - image: busybox - restart: always - on-failure: - image: busybox - restart: on-failure - on-failure-5: - image: busybox - restart: "on-failure:5" - restart-null: - image: busybox - restart: "" diff --git a/tests/fixtures/run-workdir/docker-compose.yml b/tests/fixtures/run-workdir/docker-compose.yml deleted file mode 100644 index dc3ea86a..00000000 --- a/tests/fixtures/run-workdir/docker-compose.yml +++ /dev/null @@ -1,4 +0,0 @@ -service: - image: busybox:latest - working_dir: /etc - command: /bin/true diff --git a/tests/fixtures/secrets/default b/tests/fixtures/secrets/default deleted file mode 100644 index f9dc2014..00000000 --- a/tests/fixtures/secrets/default +++ /dev/null @@ -1 +0,0 @@ -This is the secret diff --git a/tests/fixtures/simple-composefile/digest.yml b/tests/fixtures/simple-composefile/digest.yml deleted file mode 100644 index 08f1d993..00000000 --- a/tests/fixtures/simple-composefile/digest.yml +++ /dev/null @@ -1,6 +0,0 @@ -simple: - image: busybox:latest - command: top -digest: - image: busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d - command: top diff --git a/tests/fixtures/simple-composefile/ignore-pull-failures.yml b/tests/fixtures/simple-composefile/ignore-pull-failures.yml deleted file mode 100644 index a28f7922..00000000 --- a/tests/fixtures/simple-composefile/ignore-pull-failures.yml +++ /dev/null @@ -1,6 +0,0 @@ -simple: - image: busybox:latest - command: top -another: - image: nonexisting-image:latest - command: top diff --git a/tests/fixtures/simple-dockerfile/Dockerfile b/tests/fixtures/simple-dockerfile/Dockerfile index dd864b83..d1ceac6b 100644 --- a/tests/fixtures/simple-dockerfile/Dockerfile +++ b/tests/fixtures/simple-dockerfile/Dockerfile @@ -1,3 +1,2 @@ FROM busybox:latest -LABEL com.docker.compose.test_image=true CMD echo "success" diff --git a/tests/fixtures/simple-failing-dockerfile/Dockerfile b/tests/fixtures/simple-failing-dockerfile/Dockerfile deleted file mode 100644 index c2d06b16..00000000 --- a/tests/fixtures/simple-failing-dockerfile/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM busybox:latest -LABEL com.docker.compose.test_image=true -LABEL com.docker.compose.test_failing_image=true -# With the following label the container wil be cleaned up automatically -# Must be kept in sync with LABEL_PROJECT from compose/const.py -LABEL com.docker.compose.project=composetest -RUN exit 1 diff --git a/tests/fixtures/sleeps-composefile/docker-compose.yml b/tests/fixtures/sleeps-composefile/docker-compose.yml deleted file mode 100644 index 7c8d84f8..00000000 --- a/tests/fixtures/sleeps-composefile/docker-compose.yml +++ /dev/null @@ -1,10 +0,0 @@ - -version: "2" - -services: - simple: - image: busybox:latest - command: sleep 200 - another: - image: busybox:latest - command: sleep 200 diff --git a/tests/fixtures/stop-signal-composefile/docker-compose.yml b/tests/fixtures/stop-signal-composefile/docker-compose.yml deleted file mode 100644 index 04f58aa9..00000000 --- a/tests/fixtures/stop-signal-composefile/docker-compose.yml +++ /dev/null @@ -1,10 +0,0 @@ -simple: - image: busybox:latest - command: - - sh - - '-c' - - | - trap 'exit 0' SIGINT - trap 'exit 1' SIGTERM - while true; do :; done - stop_signal: SIGINT diff --git a/tests/fixtures/tls/ca.pem b/tests/fixtures/tls/ca.pem deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/fixtures/tls/cert.pem b/tests/fixtures/tls/cert.pem deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/fixtures/tls/key.key b/tests/fixtures/tls/key.key deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/fixtures/top/docker-compose.yml b/tests/fixtures/top/docker-compose.yml deleted file mode 100644 index d632a836..00000000 --- a/tests/fixtures/top/docker-compose.yml +++ /dev/null @@ -1,6 +0,0 @@ -service_a: - image: busybox:latest - command: top -service_b: - image: busybox:latest - command: top diff --git a/tests/fixtures/unicode-environment/docker-compose.yml b/tests/fixtures/unicode-environment/docker-compose.yml deleted file mode 100644 index a41af4f0..00000000 --- a/tests/fixtures/unicode-environment/docker-compose.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: '2' -services: - simple: - image: busybox:latest - command: sh -c 'echo $$FOO' - environment: - FOO: ${BAR} diff --git a/tests/fixtures/v1-config/docker-compose.yml b/tests/fixtures/v1-config/docker-compose.yml deleted file mode 100644 index 8646c4ed..00000000 --- a/tests/fixtures/v1-config/docker-compose.yml +++ /dev/null @@ -1,10 +0,0 @@ -net: - image: busybox -volume: - image: busybox - volumes: - - /data -app: - image: busybox - net: "container:net" - volumes_from: ["volume"] diff --git a/tests/fixtures/v2-dependencies/docker-compose.yml b/tests/fixtures/v2-dependencies/docker-compose.yml deleted file mode 100644 index 2e14b94b..00000000 --- a/tests/fixtures/v2-dependencies/docker-compose.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: "2.0" -services: - db: - image: busybox:latest - command: top - web: - image: busybox:latest - command: top - depends_on: - - db - console: - image: busybox:latest - command: top diff --git a/tests/fixtures/v2-full/Dockerfile b/tests/fixtures/v2-full/Dockerfile deleted file mode 100644 index 51ed0d90..00000000 --- a/tests/fixtures/v2-full/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ - -FROM busybox:latest -RUN echo something -CMD top diff --git a/tests/fixtures/v2-full/docker-compose.yml b/tests/fixtures/v2-full/docker-compose.yml deleted file mode 100644 index a973dd0c..00000000 --- a/tests/fixtures/v2-full/docker-compose.yml +++ /dev/null @@ -1,24 +0,0 @@ - -version: "2" - -volumes: - data: - driver: local - -networks: - front: {} - -services: - web: - build: . - networks: - - front - - default - volumes_from: - - other - - other: - image: busybox:latest - command: top - volumes: - - /data diff --git a/tests/fixtures/v2-simple/docker-compose.yml b/tests/fixtures/v2-simple/docker-compose.yml deleted file mode 100644 index c99ae02f..00000000 --- a/tests/fixtures/v2-simple/docker-compose.yml +++ /dev/null @@ -1,8 +0,0 @@ -version: "2" -services: - simple: - image: busybox:latest - command: top - another: - image: busybox:latest - command: top diff --git a/tests/fixtures/v2-simple/links-invalid.yml b/tests/fixtures/v2-simple/links-invalid.yml deleted file mode 100644 index 481aa404..00000000 --- a/tests/fixtures/v2-simple/links-invalid.yml +++ /dev/null @@ -1,10 +0,0 @@ -version: "2" -services: - simple: - image: busybox:latest - command: top - links: - - another - another: - image: busybox:latest - command: top diff --git a/tests/fixtures/v3-full/docker-compose.yml b/tests/fixtures/v3-full/docker-compose.yml deleted file mode 100644 index a1661ab9..00000000 --- a/tests/fixtures/v3-full/docker-compose.yml +++ /dev/null @@ -1,41 +0,0 @@ -version: "3" -services: - web: - image: busybox - - deploy: - mode: replicated - replicas: 6 - labels: [FOO=BAR] - update_config: - parallelism: 3 - delay: 10s - failure_action: continue - monitor: 60s - max_failure_ratio: 0.3 - resources: - limits: - cpus: '0.001' - memory: 50M - reservations: - cpus: '0.0001' - memory: 20M - restart_policy: - condition: on_failure - delay: 5s - max_attempts: 3 - window: 120s - placement: - constraints: [node=foo] - - healthcheck: - test: cat /etc/passwd - interval: 10s - timeout: 1s - retries: 5 - - stop_grace_period: 20s -volumes: - foobar: - labels: - com.docker.compose.test: 'true' diff --git a/tests/fixtures/volume-path-interpolation/docker-compose.yml b/tests/fixtures/volume-path-interpolation/docker-compose.yml deleted file mode 100644 index 6d4e236a..00000000 --- a/tests/fixtures/volume-path-interpolation/docker-compose.yml +++ /dev/null @@ -1,5 +0,0 @@ -test: - image: busybox - command: top - volumes: - - "~/${VOLUME_NAME}:/container-path" diff --git a/tests/fixtures/volumes-from-container/docker-compose.yml b/tests/fixtures/volumes-from-container/docker-compose.yml deleted file mode 100644 index 495fcaae..00000000 --- a/tests/fixtures/volumes-from-container/docker-compose.yml +++ /dev/null @@ -1,5 +0,0 @@ -version: "2" -services: - test: - image: busybox - volumes_from: ["container:composetest_data_container"] diff --git a/tests/fixtures/volumes/docker-compose.yml b/tests/fixtures/volumes/docker-compose.yml deleted file mode 100644 index da711ac4..00000000 --- a/tests/fixtures/volumes/docker-compose.yml +++ /dev/null @@ -1,2 +0,0 @@ -version: '2.1' -services: {} diff --git a/tests/fixtures/volumes/external-volumes.yml b/tests/fixtures/volumes/external-volumes.yml deleted file mode 100644 index 05c6c484..00000000 --- a/tests/fixtures/volumes/external-volumes.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: "2.1" - -services: - web: - image: busybox - command: top - volumes: - - foo:/var/lib/ - - bar:/etc/ - -volumes: - foo: - external: true - bar: - external: - name: some_bar diff --git a/tests/fixtures/volumes/volume-label.yml b/tests/fixtures/volumes/volume-label.yml deleted file mode 100644 index a5f33a5a..00000000 --- a/tests/fixtures/volumes/volume-label.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: "2.1" - -services: - web: - image: busybox - command: top - volumes: - - volume_with_label:/data - -volumes: - volume_with_label: - labels: - - "label_key=label_val" diff --git a/tests/helpers.py b/tests/helpers.py deleted file mode 100644 index 4b422a6a..00000000 --- a/tests/helpers.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -from compose.config.config import ConfigDetails -from compose.config.config import ConfigFile -from compose.config.config import load - - -def build_config(contents, **kwargs): - return load(build_config_details(contents, **kwargs)) - - -def build_config_details(contents, working_dir='working_dir', filename='filename.yml'): - return ConfigDetails( - working_dir, - [ConfigFile(filename, contents)], - ) diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py new file mode 100644 index 00000000..2d1f1f76 --- /dev/null +++ b/tests/integration/cli_test.py @@ -0,0 +1,495 @@ +from __future__ import absolute_import +from operator import attrgetter +import sys +import os +import shlex + +from six import StringIO +from mock import patch + +from .testcases import DockerClientTestCase +from compose.cli.main import TopLevelCommand + + +class CLITestCase(DockerClientTestCase): + def setUp(self): + super(CLITestCase, self).setUp() + self.old_sys_exit = sys.exit + sys.exit = lambda code=0: None + self.command = TopLevelCommand() + self.command.base_dir = 'tests/fixtures/simple-composefile' + + def tearDown(self): + sys.exit = self.old_sys_exit + self.project.kill() + self.project.remove_stopped() + for container in self.project.containers(stopped=True, one_off=True): + container.remove(force=True) + + @property + def project(self): + # Hack: allow project to be overridden. This needs refactoring so that + # the project object is built exactly once, by the command object, and + # accessed by the test case object. + if hasattr(self, '_project'): + return self._project + + return self.command.get_project(self.command.get_config_path()) + + def test_help(self): + old_base_dir = self.command.base_dir + self.command.base_dir = 'tests/fixtures/no-composefile' + with self.assertRaises(SystemExit) as exc_context: + self.command.dispatch(['help', 'up'], None) + self.assertIn('Usage: up [options] [SERVICE...]', str(exc_context.exception)) + # self.project.kill() fails during teardown + # unless there is a composefile. + self.command.base_dir = old_base_dir + + # TODO: address the "Inappropriate ioctl for device" warnings in test output + @patch('sys.stdout', new_callable=StringIO) + def test_ps(self, mock_stdout): + self.project.get_service('simple').create_container() + self.command.dispatch(['ps'], None) + self.assertIn('simplecomposefile_simple_1', mock_stdout.getvalue()) + + @patch('sys.stdout', new_callable=StringIO) + def test_ps_default_composefile(self, mock_stdout): + self.command.base_dir = 'tests/fixtures/multiple-composefiles' + self.command.dispatch(['up', '-d'], None) + self.command.dispatch(['ps'], None) + + output = mock_stdout.getvalue() + self.assertIn('multiplecomposefiles_simple_1', output) + self.assertIn('multiplecomposefiles_another_1', output) + self.assertNotIn('multiplecomposefiles_yetanother_1', output) + + @patch('sys.stdout', new_callable=StringIO) + def test_ps_alternate_composefile(self, mock_stdout): + config_path = os.path.abspath( + 'tests/fixtures/multiple-composefiles/compose2.yml') + self._project = self.command.get_project(config_path) + + self.command.base_dir = 'tests/fixtures/multiple-composefiles' + self.command.dispatch(['-f', 'compose2.yml', 'up', '-d'], None) + self.command.dispatch(['-f', 'compose2.yml', 'ps'], None) + + output = mock_stdout.getvalue() + self.assertNotIn('multiplecomposefiles_simple_1', output) + self.assertNotIn('multiplecomposefiles_another_1', output) + self.assertIn('multiplecomposefiles_yetanother_1', output) + + @patch('compose.service.log') + def test_pull(self, mock_logging): + self.command.dispatch(['pull'], None) + mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') + mock_logging.info.assert_any_call('Pulling another (busybox:latest)...') + + @patch('sys.stdout', new_callable=StringIO) + def test_build_no_cache(self, mock_stdout): + self.command.base_dir = 'tests/fixtures/simple-dockerfile' + self.command.dispatch(['build', 'simple'], None) + + mock_stdout.truncate(0) + cache_indicator = 'Using cache' + self.command.dispatch(['build', 'simple'], None) + output = mock_stdout.getvalue() + self.assertIn(cache_indicator, output) + + mock_stdout.truncate(0) + self.command.dispatch(['build', '--no-cache', 'simple'], None) + output = mock_stdout.getvalue() + self.assertNotIn(cache_indicator, output) + + def test_up(self): + self.command.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + another = self.project.get_service('another') + self.assertEqual(len(service.containers()), 1) + self.assertEqual(len(another.containers()), 1) + + # Ensure containers don't have stdin and stdout connected in -d mode + config = service.containers()[0].inspect()['Config'] + self.assertFalse(config['AttachStderr']) + self.assertFalse(config['AttachStdout']) + self.assertFalse(config['AttachStdin']) + + def test_up_with_links(self): + self.command.base_dir = 'tests/fixtures/links-composefile' + self.command.dispatch(['up', '-d', 'web'], None) + web = self.project.get_service('web') + db = self.project.get_service('db') + console = self.project.get_service('console') + self.assertEqual(len(web.containers()), 1) + self.assertEqual(len(db.containers()), 1) + self.assertEqual(len(console.containers()), 0) + + def test_up_with_no_deps(self): + self.command.base_dir = 'tests/fixtures/links-composefile' + self.command.dispatch(['up', '-d', '--no-deps', 'web'], None) + web = self.project.get_service('web') + db = self.project.get_service('db') + console = self.project.get_service('console') + self.assertEqual(len(web.containers()), 1) + self.assertEqual(len(db.containers()), 0) + self.assertEqual(len(console.containers()), 0) + + def test_up_with_recreate(self): + self.command.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.assertEqual(len(service.containers()), 1) + + old_ids = [c.id for c in service.containers()] + + self.command.dispatch(['up', '-d'], None) + self.assertEqual(len(service.containers()), 1) + + new_ids = [c.id for c in service.containers()] + + self.assertNotEqual(old_ids, new_ids) + + def test_up_with_keep_old(self): + self.command.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.assertEqual(len(service.containers()), 1) + + old_ids = [c.id for c in service.containers()] + + self.command.dispatch(['up', '-d', '--no-recreate'], None) + self.assertEqual(len(service.containers()), 1) + + new_ids = [c.id for c in service.containers()] + + self.assertEqual(old_ids, new_ids) + + @patch('dockerpty.start') + def test_run_service_without_links(self, mock_stdout): + self.command.base_dir = 'tests/fixtures/links-composefile' + self.command.dispatch(['run', 'console', '/bin/true'], None) + self.assertEqual(len(self.project.containers()), 0) + + # Ensure stdin/out was open + container = self.project.containers(stopped=True, one_off=True)[0] + config = container.inspect()['Config'] + self.assertTrue(config['AttachStderr']) + self.assertTrue(config['AttachStdout']) + self.assertTrue(config['AttachStdin']) + + @patch('dockerpty.start') + def test_run_service_with_links(self, __): + self.command.base_dir = 'tests/fixtures/links-composefile' + self.command.dispatch(['run', 'web', '/bin/true'], None) + db = self.project.get_service('db') + console = self.project.get_service('console') + self.assertEqual(len(db.containers()), 1) + self.assertEqual(len(console.containers()), 0) + + @patch('dockerpty.start') + def test_run_with_no_deps(self, __): + self.command.base_dir = 'tests/fixtures/links-composefile' + self.command.dispatch(['run', '--no-deps', 'web', '/bin/true'], None) + db = self.project.get_service('db') + self.assertEqual(len(db.containers()), 0) + + @patch('dockerpty.start') + def test_run_does_not_recreate_linked_containers(self, __): + self.command.base_dir = 'tests/fixtures/links-composefile' + self.command.dispatch(['up', '-d', 'db'], None) + db = self.project.get_service('db') + self.assertEqual(len(db.containers()), 1) + + old_ids = [c.id for c in db.containers()] + + self.command.dispatch(['run', 'web', '/bin/true'], None) + self.assertEqual(len(db.containers()), 1) + + new_ids = [c.id for c in db.containers()] + + self.assertEqual(old_ids, new_ids) + + @patch('dockerpty.start') + def test_run_without_command(self, __): + self.command.base_dir = 'tests/fixtures/commands-composefile' + self.check_build('tests/fixtures/simple-dockerfile', tag='composetest_test') + + for c in self.project.containers(stopped=True, one_off=True): + c.remove() + + self.command.dispatch(['run', 'implicit'], None) + service = self.project.get_service('implicit') + containers = service.containers(stopped=True, one_off=True) + self.assertEqual( + [c.human_readable_command for c in containers], + [u'/bin/sh -c echo "success"'], + ) + + self.command.dispatch(['run', 'explicit'], None) + service = self.project.get_service('explicit') + containers = service.containers(stopped=True, one_off=True) + self.assertEqual( + [c.human_readable_command for c in containers], + [u'/bin/true'], + ) + + @patch('dockerpty.start') + def test_run_service_with_entrypoint_overridden(self, _): + self.command.base_dir = 'tests/fixtures/dockerfile_with_entrypoint' + name = 'service' + self.command.dispatch( + ['run', '--entrypoint', '/bin/echo', name, 'helloworld'], + None + ) + service = self.project.get_service(name) + container = service.containers(stopped=True, one_off=True)[0] + self.assertEqual( + shlex.split(container.human_readable_command), + [u'/bin/echo', u'helloworld'], + ) + + @patch('dockerpty.start') + def test_run_service_with_user_overridden(self, _): + self.command.base_dir = 'tests/fixtures/user-composefile' + name = 'service' + user = 'sshd' + args = ['run', '--user={}'.format(user), name] + self.command.dispatch(args, None) + service = self.project.get_service(name) + container = service.containers(stopped=True, one_off=True)[0] + self.assertEqual(user, container.get('Config.User')) + + @patch('dockerpty.start') + def test_run_service_with_user_overridden_short_form(self, _): + self.command.base_dir = 'tests/fixtures/user-composefile' + name = 'service' + user = 'sshd' + args = ['run', '-u', user, name] + self.command.dispatch(args, None) + service = self.project.get_service(name) + container = service.containers(stopped=True, one_off=True)[0] + self.assertEqual(user, container.get('Config.User')) + + @patch('dockerpty.start') + def test_run_service_with_environement_overridden(self, _): + name = 'service' + self.command.base_dir = 'tests/fixtures/environment-composefile' + self.command.dispatch( + ['run', '-e', 'foo=notbar', '-e', 'allo=moto=bobo', + '-e', 'alpha=beta', name], + None + ) + service = self.project.get_service(name) + container = service.containers(stopped=True, one_off=True)[0] + # env overriden + self.assertEqual('notbar', container.environment['foo']) + # keep environement from yaml + self.assertEqual('world', container.environment['hello']) + # added option from command line + self.assertEqual('beta', container.environment['alpha']) + # make sure a value with a = don't crash out + self.assertEqual('moto=bobo', container.environment['allo']) + + @patch('dockerpty.start') + def test_run_service_without_map_ports(self, __): + # create one off container + self.command.base_dir = 'tests/fixtures/ports-composefile' + self.command.dispatch(['run', '-d', 'simple'], None) + container = self.project.get_service('simple').containers(one_off=True)[0] + + # get port information + port_random = container.get_local_port(3000) + port_assigned = container.get_local_port(3001) + + # close all one off containers we just created + container.stop() + + # check the ports + self.assertEqual(port_random, None) + self.assertEqual(port_assigned, None) + + @patch('dockerpty.start') + def test_run_service_with_map_ports(self, __): + + # create one off container + self.command.base_dir = 'tests/fixtures/ports-composefile' + self.command.dispatch(['run', '-d', '--service-ports', 'simple'], None) + container = self.project.get_service('simple').containers(one_off=True)[0] + + # get port information + port_random = container.get_local_port(3000) + port_assigned = container.get_local_port(3001) + + # close all one off containers we just created + container.stop() + + # check the ports + self.assertNotEqual(port_random, None) + self.assertIn("0.0.0.0", port_random) + self.assertEqual(port_assigned, "0.0.0.0:49152") + + def test_rm(self): + service = self.project.get_service('simple') + service.create_container() + service.kill() + self.assertEqual(len(service.containers(stopped=True)), 1) + self.command.dispatch(['rm', '--force'], None) + self.assertEqual(len(service.containers(stopped=True)), 0) + service = self.project.get_service('simple') + service.create_container() + service.kill() + self.assertEqual(len(service.containers(stopped=True)), 1) + self.command.dispatch(['rm', '-f'], None) + self.assertEqual(len(service.containers(stopped=True)), 0) + + def test_stop(self): + self.command.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.assertEqual(len(service.containers()), 1) + self.assertTrue(service.containers()[0].is_running) + + self.command.dispatch(['stop', '-t', '1'], None) + + self.assertEqual(len(service.containers(stopped=True)), 1) + self.assertFalse(service.containers(stopped=True)[0].is_running) + + def test_kill(self): + self.command.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.assertEqual(len(service.containers()), 1) + self.assertTrue(service.containers()[0].is_running) + + self.command.dispatch(['kill'], None) + + self.assertEqual(len(service.containers(stopped=True)), 1) + self.assertFalse(service.containers(stopped=True)[0].is_running) + + def test_kill_signal_sigstop(self): + self.command.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.assertEqual(len(service.containers()), 1) + self.assertTrue(service.containers()[0].is_running) + + self.command.dispatch(['kill', '-s', 'SIGSTOP'], None) + + self.assertEqual(len(service.containers()), 1) + # The container is still running. It has only been paused + self.assertTrue(service.containers()[0].is_running) + + def test_kill_stopped_service(self): + self.command.dispatch(['up', '-d'], None) + service = self.project.get_service('simple') + self.command.dispatch(['kill', '-s', 'SIGSTOP'], None) + self.assertTrue(service.containers()[0].is_running) + + self.command.dispatch(['kill', '-s', 'SIGKILL'], None) + + self.assertEqual(len(service.containers(stopped=True)), 1) + self.assertFalse(service.containers(stopped=True)[0].is_running) + + def test_restart(self): + service = self.project.get_service('simple') + container = service.create_container() + service.start_container(container) + started_at = container.dictionary['State']['StartedAt'] + self.command.dispatch(['restart', '-t', '1'], None) + container.inspect() + self.assertNotEqual( + container.dictionary['State']['FinishedAt'], + '0001-01-01T00:00:00Z', + ) + self.assertNotEqual( + container.dictionary['State']['StartedAt'], + started_at, + ) + + def test_scale(self): + project = self.project + + self.command.scale(project, {'SERVICE=NUM': ['simple=1']}) + self.assertEqual(len(project.get_service('simple').containers()), 1) + + self.command.scale(project, {'SERVICE=NUM': ['simple=3', 'another=2']}) + self.assertEqual(len(project.get_service('simple').containers()), 3) + self.assertEqual(len(project.get_service('another').containers()), 2) + + self.command.scale(project, {'SERVICE=NUM': ['simple=1', 'another=1']}) + self.assertEqual(len(project.get_service('simple').containers()), 1) + self.assertEqual(len(project.get_service('another').containers()), 1) + + self.command.scale(project, {'SERVICE=NUM': ['simple=1', 'another=1']}) + self.assertEqual(len(project.get_service('simple').containers()), 1) + self.assertEqual(len(project.get_service('another').containers()), 1) + + self.command.scale(project, {'SERVICE=NUM': ['simple=0', 'another=0']}) + self.assertEqual(len(project.get_service('simple').containers()), 0) + self.assertEqual(len(project.get_service('another').containers()), 0) + + def test_port(self): + self.command.base_dir = 'tests/fixtures/ports-composefile' + self.command.dispatch(['up', '-d'], None) + container = self.project.get_service('simple').get_container() + + @patch('sys.stdout', new_callable=StringIO) + def get_port(number, mock_stdout): + self.command.dispatch(['port', 'simple', str(number)], None) + return mock_stdout.getvalue().rstrip() + + self.assertEqual(get_port(3000), container.get_local_port(3000)) + self.assertEqual(get_port(3001), "0.0.0.0:49152") + self.assertEqual(get_port(3002), "") + + def test_port_with_scale(self): + + self.command.base_dir = 'tests/fixtures/ports-composefile-scale' + self.command.dispatch(['scale', 'simple=2'], None) + containers = sorted( + self.project.containers(service_names=['simple']), + key=attrgetter('name')) + + @patch('sys.stdout', new_callable=StringIO) + def get_port(number, mock_stdout, index=None): + if index is None: + self.command.dispatch(['port', 'simple', str(number)], None) + else: + self.command.dispatch(['port', '--index=' + str(index), 'simple', str(number)], None) + return mock_stdout.getvalue().rstrip() + + self.assertEqual(get_port(3000), containers[0].get_local_port(3000)) + self.assertEqual(get_port(3000, index=1), containers[0].get_local_port(3000)) + self.assertEqual(get_port(3000, index=2), containers[1].get_local_port(3000)) + self.assertEqual(get_port(3002), "") + + def test_env_file_relative_to_compose_file(self): + config_path = os.path.abspath('tests/fixtures/env-file/docker-compose.yml') + self.command.dispatch(['-f', config_path, 'up', '-d'], None) + self._project = self.command.get_project(config_path) + + containers = self.project.containers(stopped=True) + self.assertEqual(len(containers), 1) + self.assertIn("FOO=1", containers[0].get('Config.Env')) + + def test_up_with_extends(self): + self.command.base_dir = 'tests/fixtures/extends' + self.command.dispatch(['up', '-d'], None) + + self.assertEqual( + set([s.name for s in self.project.services]), + set(['mydb', 'myweb']), + ) + + # Sort by name so we get [db, web] + containers = sorted( + self.project.containers(stopped=True), + key=lambda c: c.name, + ) + + self.assertEqual(len(containers), 2) + web = containers[1] + + self.assertEqual(set(web.links()), set(['db', 'mydb_1', 'extends_mydb_1'])) + + expected_env = set([ + "FOO=1", + "BAR=2", + "BAZ=2", + ]) + self.assertTrue(expected_env <= set(web.get('Config.Env'))) diff --git a/tests/integration/legacy_test.py b/tests/integration/legacy_test.py new file mode 100644 index 00000000..6c52b68d --- /dev/null +++ b/tests/integration/legacy_test.py @@ -0,0 +1,57 @@ +from compose import legacy +from compose.project import Project +from .testcases import DockerClientTestCase + + +class ProjectTest(DockerClientTestCase): + + def setUp(self): + super(ProjectTest, self).setUp() + + db = self.create_service('db') + web = self.create_service('web', links=[(db, 'db')]) + nginx = self.create_service('nginx', links=[(web, 'web')]) + + self.services = [db, web, nginx] + self.project = Project('composetest', self.services, self.client) + + # Create a legacy container for each service + for service in self.services: + service.ensure_image_exists() + container = self.client.create_container( + name='{}_{}_1'.format(self.project.name, service.name), + **service.options + ) + self.client.start(container) + + # Create a single one-off legacy container + self.client.create_container( + name='{}_{}_run_1'.format(self.project.name, self.services[0].name), + **self.services[0].options + ) + + def get_legacy_containers(self, **kwargs): + return list(legacy.get_legacy_containers( + self.client, + self.project.name, + [s.name for s in self.services], + **kwargs + )) + + def test_get_legacy_container_names(self): + self.assertEqual(len(self.get_legacy_containers()), len(self.services)) + + def test_get_legacy_container_names_one_off(self): + self.assertEqual(len(self.get_legacy_containers(stopped=True, one_off=True)), 1) + + def test_migration_to_labels(self): + with self.assertRaises(legacy.LegacyContainersError) as cm: + self.assertEqual(self.project.containers(stopped=True), []) + + self.assertEqual( + set(cm.exception.names), + set(['composetest_db_1', 'composetest_web_1', 'composetest_nginx_1']), + ) + + legacy.migrate_project_to_labels(self.project) + self.assertEqual(len(self.project.containers(stopped=True)), len(self.services)) diff --git a/tests/integration/network_test.py b/tests/integration/network_test.py deleted file mode 100644 index 2ff610fb..00000000 --- a/tests/integration/network_test.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -from .testcases import DockerClientTestCase -from compose.const import LABEL_NETWORK -from compose.const import LABEL_PROJECT -from compose.network import Network - - -class NetworkTest(DockerClientTestCase): - def test_network_default_labels(self): - net = Network(self.client, 'composetest', 'foonet') - net.ensure() - net_data = net.inspect() - labels = net_data['Labels'] - assert labels[LABEL_NETWORK] == net.name - assert labels[LABEL_PROJECT] == net.project diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py index 28762cd2..2976af82 100644 --- a/tests/integration/project_test.py +++ b/tests/integration/project_test.py @@ -1,44 +1,8 @@ -from __future__ import absolute_import from __future__ import unicode_literals - -import os.path -import random - -import py -import pytest -from docker.errors import NotFound - -from .. import mock -from ..helpers import build_config as load_config -from .testcases import DockerClientTestCase -from compose.config import config -from compose.config import ConfigurationError -from compose.config import types -from compose.config.config import V2_0 -from compose.config.config import V2_1 -from compose.config.config import V3_1 -from compose.config.types import VolumeFromSpec -from compose.config.types import VolumeSpec -from compose.const import LABEL_PROJECT -from compose.const import LABEL_SERVICE -from compose.container import Container -from compose.errors import HealthCheckFailed -from compose.errors import NoHealthCheckConfigured +from compose import config from compose.project import Project -from compose.project import ProjectError -from compose.service import ConvergenceStrategy -from tests.integration.testcases import v2_1_only -from tests.integration.testcases import v2_only -from tests.integration.testcases import v3_only - - -def build_config(**kwargs): - return config.Config( - version=kwargs.get('version'), - services=kwargs.get('services'), - volumes=kwargs.get('volumes'), - networks=kwargs.get('networks'), - secrets=kwargs.get('secrets')) +from compose.container import Container +from .testcases import DockerClientTestCase class ProjectTest(DockerClientTestCase): @@ -65,39 +29,25 @@ class ProjectTest(DockerClientTestCase): [c.name for c in containers], ['composetest_web_1']) - def test_containers_with_extra_service(self): - web = self.create_service('web') - web_1 = web.create_container() - - db = self.create_service('db') - db_1 = db.create_container() - - self.create_service('extra').create_container() - - project = Project('composetest', [web, db], self.client) - self.assertEqual( - set(project.containers(stopped=True)), - set([web_1, db_1]), - ) - def test_volumes_from_service(self): - project = Project.from_config( + service_dicts = config.from_dictionary({ + 'data': { + 'image': 'busybox:latest', + 'volumes': ['/var/data'], + }, + 'db': { + 'image': 'busybox:latest', + 'volumes_from': ['data'], + }, + }, working_dir='.') + project = Project.from_dicts( name='composetest', - config_data=load_config({ - 'data': { - 'image': 'busybox:latest', - 'volumes': ['/var/data'], - }, - 'db': { - 'image': 'busybox:latest', - 'volumes_from': ['data'], - }, - }), + service_dicts=service_dicts, client=self.client, ) db = project.get_service('db') data = project.get_service('data') - self.assertEqual(db.volumes_from, [VolumeFromSpec(data, 'rw', 'service')]) + self.assertEqual(db.volumes_from, [data]) def test_volumes_from_container(self): data_container = Container.create( @@ -105,11 +55,10 @@ class ProjectTest(DockerClientTestCase): image='busybox:latest', volumes=['/var/data'], name='composetest_data_container', - labels={LABEL_PROJECT: 'composetest'}, ) - project = Project.from_config( + project = Project.from_dicts( name='composetest', - config_data=load_config({ + service_dicts=config.from_dictionary({ 'db': { 'image': 'busybox:latest', 'volumes_from': ['composetest_data_container'], @@ -118,76 +67,15 @@ class ProjectTest(DockerClientTestCase): client=self.client, ) db = project.get_service('db') - self.assertEqual(db._get_volumes_from(), [data_container.id + ':rw']) + self.assertEqual(db.volumes_from, [data_container]) - @v2_only() - def test_network_mode_from_service(self): - project = Project.from_config( + project.kill() + project.remove_stopped() + + def test_net_from_service(self): + project = Project.from_dicts( name='composetest', - client=self.client, - config_data=load_config({ - 'version': V2_0, - 'services': { - 'net': { - 'image': 'busybox:latest', - 'command': ["top"] - }, - 'web': { - 'image': 'busybox:latest', - 'network_mode': 'service:net', - 'command': ["top"] - }, - }, - }), - ) - - project.up() - - web = project.get_service('web') - net = project.get_service('net') - self.assertEqual(web.network_mode.mode, 'container:' + net.containers()[0].id) - - @v2_only() - def test_network_mode_from_container(self): - def get_project(): - return Project.from_config( - name='composetest', - config_data=load_config({ - 'version': V2_0, - 'services': { - 'web': { - 'image': 'busybox:latest', - 'network_mode': 'container:composetest_net_container' - }, - }, - }), - client=self.client, - ) - - with pytest.raises(ConfigurationError) as excinfo: - get_project() - - assert "container 'composetest_net_container' which does not exist" in excinfo.exconly() - - net_container = Container.create( - self.client, - image='busybox:latest', - name='composetest_net_container', - command='top', - labels={LABEL_PROJECT: 'composetest'}, - ) - net_container.start() - - project = get_project() - project.up() - - web = project.get_service('web') - self.assertEqual(web.network_mode.mode, 'container:' + net_container.id) - - def test_net_from_service_v1(self): - project = Project.from_config( - name='composetest', - config_data=load_config({ + service_dicts=config.from_dictionary({ 'net': { 'image': 'busybox:latest', 'command': ["top"] @@ -205,42 +93,40 @@ class ProjectTest(DockerClientTestCase): web = project.get_service('web') net = project.get_service('net') - self.assertEqual(web.network_mode.mode, 'container:' + net.containers()[0].id) + self.assertEqual(web._get_net(), 'container:' + net.containers()[0].id) - def test_net_from_container_v1(self): - def get_project(): - return Project.from_config( - name='composetest', - config_data=load_config({ - 'web': { - 'image': 'busybox:latest', - 'net': 'container:composetest_net_container' - }, - }), - client=self.client, - ) - - with pytest.raises(ConfigurationError) as excinfo: - get_project() - - assert "container 'composetest_net_container' which does not exist" in excinfo.exconly() + project.kill() + project.remove_stopped() + def test_net_from_container(self): net_container = Container.create( self.client, image='busybox:latest', name='composetest_net_container', - command='top', - labels={LABEL_PROJECT: 'composetest'}, + command='top' ) net_container.start() - project = get_project() + project = Project.from_dicts( + name='composetest', + service_dicts=config.from_dictionary({ + 'web': { + 'image': 'busybox:latest', + 'net': 'container:composetest_net_container' + }, + }), + client=self.client, + ) + project.up() web = project.get_service('web') - self.assertEqual(web.network_mode.mode, 'container:' + net_container.id) + self.assertEqual(web._get_net(), 'container:' + net_container.id) - def test_start_pause_unpause_stop_kill_remove(self): + project.kill() + project.remove_stopped() + + def test_start_stop_kill_remove(self): web = self.create_service('web') db = self.create_service('db') project = Project('composetest', [web, db], self.client) @@ -255,30 +141,10 @@ class ProjectTest(DockerClientTestCase): db_container = db.create_container() project.start(service_names=['web']) - self.assertEqual( - set(c.name for c in project.containers()), - set([web_container_1.name, web_container_2.name])) + self.assertEqual(set(c.name for c in project.containers()), set([web_container_1.name, web_container_2.name])) project.start() - self.assertEqual( - set(c.name for c in project.containers()), - set([web_container_1.name, web_container_2.name, db_container.name])) - - project.pause(service_names=['web']) - self.assertEqual( - set([c.name for c in project.containers() if c.is_paused]), - set([web_container_1.name, web_container_2.name])) - - project.pause() - self.assertEqual( - set([c.name for c in project.containers() if c.is_paused]), - set([web_container_1.name, web_container_2.name, db_container.name])) - - project.unpause(service_names=['db']) - self.assertEqual(len([c.name for c in project.containers() if c.is_paused]), 2) - - project.unpause() - self.assertEqual(len([c.name for c in project.containers() if c.is_paused]), 0) + self.assertEqual(set(c.name for c in project.containers()), set([web_container_1.name, web_container_2.name, db_container.name])) project.stop(service_names=['web'], timeout=1) self.assertEqual(set(c.name for c in project.containers()), set([db_container.name])) @@ -293,74 +159,9 @@ class ProjectTest(DockerClientTestCase): project.remove_stopped() self.assertEqual(len(project.containers(stopped=True)), 0) - def test_create(self): - web = self.create_service('web') - db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) - project = Project('composetest', [web, db], self.client) - - project.create(['db']) - self.assertEqual(len(project.containers()), 0) - self.assertEqual(len(project.containers(stopped=True)), 1) - self.assertEqual(len(db.containers()), 0) - self.assertEqual(len(db.containers(stopped=True)), 1) - self.assertEqual(len(web.containers(stopped=True)), 0) - - def test_create_twice(self): - web = self.create_service('web') - db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) - project = Project('composetest', [web, db], self.client) - - project.create(['db', 'web']) - project.create(['db', 'web']) - self.assertEqual(len(project.containers()), 0) - self.assertEqual(len(project.containers(stopped=True)), 2) - self.assertEqual(len(db.containers()), 0) - self.assertEqual(len(db.containers(stopped=True)), 1) - self.assertEqual(len(web.containers()), 0) - self.assertEqual(len(web.containers(stopped=True)), 1) - - def test_create_with_links(self): - db = self.create_service('db') - web = self.create_service('web', links=[(db, 'db')]) - project = Project('composetest', [db, web], self.client) - - project.create(['web']) - self.assertEqual(len(project.containers()), 0) - self.assertEqual(len(project.containers(stopped=True)), 2) - self.assertEqual(len(db.containers()), 0) - self.assertEqual(len(db.containers(stopped=True)), 1) - self.assertEqual(len(web.containers()), 0) - self.assertEqual(len(web.containers(stopped=True)), 1) - - def test_create_strategy_always(self): - db = self.create_service('db') - project = Project('composetest', [db], self.client) - project.create(['db']) - old_id = project.containers(stopped=True)[0].id - - project.create(['db'], strategy=ConvergenceStrategy.always) - self.assertEqual(len(project.containers()), 0) - self.assertEqual(len(project.containers(stopped=True)), 1) - - db_container = project.containers(stopped=True)[0] - self.assertNotEqual(db_container.id, old_id) - - def test_create_strategy_never(self): - db = self.create_service('db') - project = Project('composetest', [db], self.client) - project.create(['db']) - old_id = project.containers(stopped=True)[0].id - - project.create(['db'], strategy=ConvergenceStrategy.never) - self.assertEqual(len(project.containers()), 0) - self.assertEqual(len(project.containers(stopped=True)), 1) - - db_container = project.containers(stopped=True)[0] - self.assertEqual(db_container.id, old_id) - def test_project_up(self): web = self.create_service('web') - db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) + db = self.create_service('db', volumes=['/var/db']) project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -370,6 +171,9 @@ class ProjectTest(DockerClientTestCase): self.assertEqual(len(db.containers()), 1) self.assertEqual(len(web.containers()), 0) + project.kill() + project.remove_stopped() + def test_project_up_starts_uncreated_services(self): db = self.create_service('db') web = self.create_service('web', links=[(db, 'db')]) @@ -382,9 +186,9 @@ class ProjectTest(DockerClientTestCase): self.assertEqual(len(db.containers()), 1) self.assertEqual(len(web.containers()), 1) - def test_recreate_preserves_volumes(self): + def test_project_up_recreates_containers(self): web = self.create_service('web') - db = self.create_service('db', volumes=[VolumeSpec.parse('/etc')]) + db = self.create_service('db', volumes=['/etc']) project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -394,16 +198,19 @@ class ProjectTest(DockerClientTestCase): old_db_id = project.containers()[0].id db_volume_path = project.containers()[0].get('Volumes./etc') - project.up(strategy=ConvergenceStrategy.always) + project.up() self.assertEqual(len(project.containers()), 2) db_container = [c for c in project.containers() if 'db' in c.name][0] self.assertNotEqual(db_container.id, old_db_id) self.assertEqual(db_container.get('Volumes./etc'), db_volume_path) + project.kill() + project.remove_stopped() + def test_project_up_with_no_recreate_running(self): web = self.create_service('web') - db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) + db = self.create_service('db', volumes=['/var/db']) project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -411,21 +218,22 @@ class ProjectTest(DockerClientTestCase): project.up(['db']) self.assertEqual(len(project.containers()), 1) old_db_id = project.containers()[0].id - container, = project.containers() - db_volume_path = container.get_mount('/var/db')['Source'] + db_volume_path = project.containers()[0].inspect()['Volumes']['/var/db'] - project.up(strategy=ConvergenceStrategy.never) + project.up(allow_recreate=False) self.assertEqual(len(project.containers()), 2) db_container = [c for c in project.containers() if 'db' in c.name][0] self.assertEqual(db_container.id, old_db_id) - self.assertEqual( - db_container.get_mount('/var/db')['Source'], - db_volume_path) + self.assertEqual(db_container.inspect()['Volumes']['/var/db'], + db_volume_path) + + project.kill() + project.remove_stopped() def test_project_up_with_no_recreate_stopped(self): web = self.create_service('web') - db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) + db = self.create_service('db', volumes=['/var/db']) project = Project('composetest', [web, db], self.client) project.start() self.assertEqual(len(project.containers()), 0) @@ -436,11 +244,10 @@ class ProjectTest(DockerClientTestCase): old_containers = project.containers(stopped=True) self.assertEqual(len(old_containers), 1) - old_container, = old_containers - old_db_id = old_container.id - db_volume_path = old_container.get_mount('/var/db')['Source'] + old_db_id = old_containers[0].id + db_volume_path = old_containers[0].inspect()['Volumes']['/var/db'] - project.up(strategy=ConvergenceStrategy.never) + project.up(allow_recreate=False) new_containers = project.containers(stopped=True) self.assertEqual(len(new_containers), 2) @@ -448,9 +255,11 @@ class ProjectTest(DockerClientTestCase): db_container = [c for c in new_containers if 'db' in c.name][0] self.assertEqual(db_container.id, old_db_id) - self.assertEqual( - db_container.get_mount('/var/db')['Source'], - db_volume_path) + self.assertEqual(db_container.inspect()['Volumes']['/var/db'], + db_volume_path) + + project.kill() + project.remove_stopped() def test_project_up_without_all_services(self): console = self.create_service('console') @@ -464,9 +273,12 @@ class ProjectTest(DockerClientTestCase): self.assertEqual(len(db.containers()), 1) self.assertEqual(len(console.containers()), 1) + project.kill() + project.remove_stopped() + def test_project_up_starts_links(self): console = self.create_service('console') - db = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) + db = self.create_service('db', volumes=['/var/db']) web = self.create_service('web', links=[(db, 'db')]) project = Project('composetest', [web, db, console], self.client) @@ -479,10 +291,13 @@ class ProjectTest(DockerClientTestCase): self.assertEqual(len(db.containers()), 1) self.assertEqual(len(console.containers()), 0) + project.kill() + project.remove_stopped() + def test_project_up_starts_depends(self): - project = Project.from_config( + project = Project.from_dicts( name='composetest', - config_data=load_config({ + service_dicts=config.from_dictionary({ 'console': { 'image': 'busybox:latest', 'command': ["top"], @@ -514,10 +329,13 @@ class ProjectTest(DockerClientTestCase): self.assertEqual(len(project.get_service('data').containers()), 1) self.assertEqual(len(project.get_service('console').containers()), 0) + project.kill() + project.remove_stopped() + def test_project_up_with_no_deps(self): - project = Project.from_config( + project = Project.from_dicts( name='composetest', - config_data=load_config({ + service_dicts=config.from_dictionary({ 'console': { 'image': 'busybox:latest', 'command': ["top"], @@ -550,6 +368,9 @@ class ProjectTest(DockerClientTestCase): self.assertEqual(len(project.get_service('data').containers(stopped=True)), 1) self.assertEqual(len(project.get_service('console').containers()), 0) + project.kill() + project.remove_stopped() + def test_unscale_after_restart(self): web = self.create_service('web') project = Project('composetest', [web], self.client) @@ -574,973 +395,5 @@ class ProjectTest(DockerClientTestCase): project.up() service = project.get_service('web') self.assertEqual(len(service.containers()), 1) - - @v2_only() - def test_project_up_networks(self): - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'command': 'top', - 'networks': { - 'foo': None, - 'bar': None, - 'baz': {'aliases': ['extra']}, - }, - }], - networks={ - 'foo': {'driver': 'bridge'}, - 'bar': {'driver': None}, - 'baz': {}, - }, - ) - - project = Project.from_config( - client=self.client, - name='composetest', - config_data=config_data, - ) - project.up() - - containers = project.containers() - assert len(containers) == 1 - container, = containers - - for net_name in ['foo', 'bar', 'baz']: - full_net_name = 'composetest_{}'.format(net_name) - network_data = self.client.inspect_network(full_net_name) - assert network_data['Name'] == full_net_name - - aliases_key = 'NetworkSettings.Networks.{net}.Aliases' - assert 'web' in container.get(aliases_key.format(net='composetest_foo')) - assert 'web' in container.get(aliases_key.format(net='composetest_baz')) - assert 'extra' in container.get(aliases_key.format(net='composetest_baz')) - - foo_data = self.client.inspect_network('composetest_foo') - assert foo_data['Driver'] == 'bridge' - - @v2_only() - def test_up_with_ipam_config(self): - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'networks': {'front': None}, - }], - networks={ - 'front': { - 'driver': 'bridge', - 'driver_opts': { - "com.docker.network.bridge.enable_icc": "false", - }, - 'ipam': { - 'driver': 'default', - 'config': [{ - "subnet": "172.28.0.0/16", - "ip_range": "172.28.5.0/24", - "gateway": "172.28.5.254", - "aux_addresses": { - "a": "172.28.1.5", - "b": "172.28.1.6", - "c": "172.28.1.7", - }, - }], - }, - }, - }, - ) - - project = Project.from_config( - client=self.client, - name='composetest', - config_data=config_data, - ) - project.up() - - network = self.client.networks(names=['composetest_front'])[0] - - assert network['Options'] == { - "com.docker.network.bridge.enable_icc": "false" - } - - assert network['IPAM'] == { - 'Driver': 'default', - 'Options': None, - 'Config': [{ - 'Subnet': "172.28.0.0/16", - 'IPRange': "172.28.5.0/24", - 'Gateway': "172.28.5.254", - 'AuxiliaryAddresses': { - 'a': '172.28.1.5', - 'b': '172.28.1.6', - 'c': '172.28.1.7', - }, - }], - } - - @v2_only() - def test_up_with_network_static_addresses(self): - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'command': 'top', - 'networks': { - 'static_test': { - 'ipv4_address': '172.16.100.100', - 'ipv6_address': 'fe80::1001:102' - } - }, - }], - networks={ - 'static_test': { - 'driver': 'bridge', - 'driver_opts': { - "com.docker.network.enable_ipv6": "true", - }, - 'ipam': { - 'driver': 'default', - 'config': [ - {"subnet": "172.16.100.0/24", - "gateway": "172.16.100.1"}, - {"subnet": "fe80::/64", - "gateway": "fe80::1001:1"} - ] - } - } - } - ) - project = Project.from_config( - client=self.client, - name='composetest', - config_data=config_data, - ) - project.up(detached=True) - - network = self.client.networks(names=['static_test'])[0] - service_container = project.get_service('web').containers()[0] - - assert network['Options'] == { - "com.docker.network.enable_ipv6": "true" - } - - IPAMConfig = (service_container.inspect().get('NetworkSettings', {}). - get('Networks', {}).get('composetest_static_test', {}). - get('IPAMConfig', {})) - assert IPAMConfig.get('IPv4Address') == '172.16.100.100' - assert IPAMConfig.get('IPv6Address') == 'fe80::1001:102' - - @v2_1_only() - def test_up_with_enable_ipv6(self): - self.require_api_version('1.23') - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'command': 'top', - 'networks': { - 'static_test': { - 'ipv6_address': 'fe80::1001:102' - } - }, - }], - networks={ - 'static_test': { - 'driver': 'bridge', - 'enable_ipv6': True, - 'ipam': { - 'driver': 'default', - 'config': [ - {"subnet": "fe80::/64", - "gateway": "fe80::1001:1"} - ] - } - } - } - ) - project = Project.from_config( - client=self.client, - name='composetest', - config_data=config_data, - ) - project.up(detached=True) - network = self.client.networks(names=['static_test'])[0] - service_container = project.get_service('web').containers()[0] - - assert network['EnableIPv6'] is True - ipam_config = (service_container.inspect().get('NetworkSettings', {}). - get('Networks', {}).get('composetest_static_test', {}). - get('IPAMConfig', {})) - assert ipam_config.get('IPv6Address') == 'fe80::1001:102' - - @v2_only() - def test_up_with_network_static_addresses_missing_subnet(self): - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'networks': { - 'static_test': { - 'ipv4_address': '172.16.100.100', - 'ipv6_address': 'fe80::1001:101' - } - }, - }], - networks={ - 'static_test': { - 'driver': 'bridge', - 'driver_opts': { - "com.docker.network.enable_ipv6": "true", - }, - 'ipam': { - 'driver': 'default', - }, - }, - }, - ) - - project = Project.from_config( - client=self.client, - name='composetest', - config_data=config_data, - ) - - with self.assertRaises(ProjectError): - project.up() - - @v2_1_only() - def test_up_with_network_link_local_ips(self): - config_data = build_config( - version=V2_1, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'networks': { - 'linklocaltest': { - 'link_local_ips': ['169.254.8.8'] - } - } - }], - networks={ - 'linklocaltest': {'driver': 'bridge'} - } - ) - project = Project.from_config( - client=self.client, - name='composetest', - config_data=config_data - ) - project.up(detached=True) - - service_container = project.get_service('web').containers(stopped=True)[0] - ipam_config = service_container.inspect().get( - 'NetworkSettings', {} - ).get( - 'Networks', {} - ).get( - 'composetest_linklocaltest', {} - ).get('IPAMConfig', {}) - assert 'LinkLocalIPs' in ipam_config - assert ipam_config['LinkLocalIPs'] == ['169.254.8.8'] - - @v2_1_only() - def test_up_with_isolation(self): - self.require_api_version('1.24') - config_data = build_config( - version=V2_1, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'isolation': 'default' - }], - ) - project = Project.from_config( - client=self.client, - name='composetest', - config_data=config_data - ) - project.up(detached=True) - service_container = project.get_service('web').containers(stopped=True)[0] - assert service_container.inspect()['HostConfig']['Isolation'] == 'default' - - @v2_1_only() - def test_up_with_invalid_isolation(self): - self.require_api_version('1.24') - config_data = build_config( - version=V2_1, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'isolation': 'foobar' - }], - ) - project = Project.from_config( - client=self.client, - name='composetest', - config_data=config_data - ) - with self.assertRaises(ProjectError): - project.up() - - @v2_only() - def test_project_up_with_network_internal(self): - self.require_api_version('1.23') - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'networks': {'internal': None}, - }], - networks={ - 'internal': {'driver': 'bridge', 'internal': True}, - }, - ) - - project = Project.from_config( - client=self.client, - name='composetest', - config_data=config_data, - ) - project.up() - - network = self.client.networks(names=['composetest_internal'])[0] - - assert network['Internal'] is True - - @v2_1_only() - def test_project_up_with_network_label(self): - self.require_api_version('1.23') - - network_name = 'network_with_label' - - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'networks': {network_name: None} - }], - networks={ - network_name: {'labels': {'label_key': 'label_val'}} - } - ) - - project = Project.from_config( - client=self.client, - name='composetest', - config_data=config_data - ) - - project.up() - - networks = [ - n for n in self.client.networks() - if n['Name'].startswith('composetest_') - ] - - assert [n['Name'] for n in networks] == ['composetest_{}'.format(network_name)] - assert 'label_key' in networks[0]['Labels'] - assert networks[0]['Labels']['label_key'] == 'label_val' - - @v2_only() - def test_project_up_volumes(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) - full_vol_name = 'composetest_{0}'.format(vol_name) - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'command': 'top' - }], - volumes={vol_name: {'driver': 'local'}}, - ) - - project = Project.from_config( - name='composetest', - config_data=config_data, client=self.client - ) - project.up() - self.assertEqual(len(project.containers()), 1) - - volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) - self.assertEqual(volume_data['Driver'], 'local') - - @v2_1_only() - def test_project_up_with_volume_labels(self): - self.require_api_version('1.23') - - volume_name = 'volume_with_label' - - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'volumes': [VolumeSpec.parse('{}:/data'.format(volume_name))] - }], - volumes={ - volume_name: { - 'labels': { - 'label_key': 'label_val' - } - } - }, - ) - - project = Project.from_config( - client=self.client, - name='composetest', - config_data=config_data, - ) - - project.up() - - volumes = [ - v for v in self.client.volumes().get('Volumes', []) - if v['Name'].startswith('composetest_') - ] - - assert [v['Name'] for v in volumes] == ['composetest_{}'.format(volume_name)] - - assert 'label_key' in volumes[0]['Labels'] - assert volumes[0]['Labels']['label_key'] == 'label_val' - - @v2_only() - def test_project_up_logging_with_multiple_files(self): - base_file = config.ConfigFile( - 'base.yml', - { - 'version': V2_0, - 'services': { - 'simple': {'image': 'busybox:latest', 'command': 'top'}, - 'another': { - 'image': 'busybox:latest', - 'command': 'top', - 'logging': { - 'driver': "json-file", - 'options': { - 'max-size': "10m" - } - } - } - } - - }) - override_file = config.ConfigFile( - 'override.yml', - { - 'version': V2_0, - 'services': { - 'another': { - 'logging': { - 'driver': "none" - } - } - } - - }) - details = config.ConfigDetails('.', [base_file, override_file]) - - tmpdir = py.test.ensuretemp('logging_test') - self.addCleanup(tmpdir.remove) - with tmpdir.as_cwd(): - config_data = config.load(details) - project = Project.from_config( - name='composetest', config_data=config_data, client=self.client - ) - project.up() - containers = project.containers() - self.assertEqual(len(containers), 2) - - another = project.get_service('another').containers()[0] - log_config = another.get('HostConfig.LogConfig') - self.assertTrue(log_config) - self.assertEqual(log_config.get('Type'), 'none') - - @v2_only() - def test_project_up_port_mappings_with_multiple_files(self): - base_file = config.ConfigFile( - 'base.yml', - { - 'version': V2_0, - 'services': { - 'simple': { - 'image': 'busybox:latest', - 'command': 'top', - 'ports': ['1234:1234'] - }, - }, - - }) - override_file = config.ConfigFile( - 'override.yml', - { - 'version': V2_0, - 'services': { - 'simple': { - 'ports': ['1234:1234'] - } - } - - }) - details = config.ConfigDetails('.', [base_file, override_file]) - - config_data = config.load(details) - project = Project.from_config( - name='composetest', config_data=config_data, client=self.client - ) - project.up() - containers = project.containers() - self.assertEqual(len(containers), 1) - - @v2_only() - def test_initialize_volumes(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) - full_vol_name = 'composetest_{0}'.format(vol_name) - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'command': 'top' - }], - volumes={vol_name: {}}, - ) - - project = Project.from_config( - name='composetest', - config_data=config_data, client=self.client - ) - project.volumes.initialize() - - volume_data = self.client.inspect_volume(full_vol_name) - assert volume_data['Name'] == full_vol_name - assert volume_data['Driver'] == 'local' - - @v2_only() - def test_project_up_implicit_volume_driver(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) - full_vol_name = 'composetest_{0}'.format(vol_name) - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'command': 'top' - }], - volumes={vol_name: {}}, - ) - - project = Project.from_config( - name='composetest', - config_data=config_data, client=self.client - ) - project.up() - - volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) - self.assertEqual(volume_data['Driver'], 'local') - - @v3_only() - def test_project_up_with_secrets(self): - create_host_file(self.client, os.path.abspath('tests/fixtures/secrets/default')) - - config_data = build_config( - version=V3_1, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'command': 'cat /run/secrets/special', - 'secrets': [ - types.ServiceSecret.parse({'source': 'super', 'target': 'special'}), - ], - }], - secrets={ - 'super': { - 'file': os.path.abspath('tests/fixtures/secrets/default'), - }, - }, - ) - - project = Project.from_config( - client=self.client, - name='composetest', - config_data=config_data, - ) - project.up() - project.stop() - - containers = project.containers(stopped=True) - assert len(containers) == 1 - container, = containers - - output = container.logs() - assert output == b"This is the secret\n" - - @v2_only() - def test_initialize_volumes_invalid_volume_driver(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) - - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'command': 'top' - }], - volumes={vol_name: {'driver': 'foobar'}}, - ) - - project = Project.from_config( - name='composetest', - config_data=config_data, client=self.client - ) - with self.assertRaises(config.ConfigurationError): - project.volumes.initialize() - - @v2_only() - def test_initialize_volumes_updated_driver(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) - full_vol_name = 'composetest_{0}'.format(vol_name) - - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'command': 'top' - }], - volumes={vol_name: {'driver': 'local'}}, - ) - project = Project.from_config( - name='composetest', - config_data=config_data, client=self.client - ) - project.volumes.initialize() - - volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) - self.assertEqual(volume_data['Driver'], 'local') - - config_data = config_data._replace( - volumes={vol_name: {'driver': 'smb'}} - ) - project = Project.from_config( - name='composetest', - config_data=config_data, - client=self.client - ) - with self.assertRaises(config.ConfigurationError) as e: - project.volumes.initialize() - assert 'Configuration for volume {0} specifies driver smb'.format( - vol_name - ) in str(e.exception) - - @v2_only() - def test_initialize_volumes_updated_blank_driver(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) - full_vol_name = 'composetest_{0}'.format(vol_name) - - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'command': 'top' - }], - volumes={vol_name: {'driver': 'local'}}, - ) - project = Project.from_config( - name='composetest', - config_data=config_data, client=self.client - ) - project.volumes.initialize() - - volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) - self.assertEqual(volume_data['Driver'], 'local') - - config_data = config_data._replace( - volumes={vol_name: {}} - ) - project = Project.from_config( - name='composetest', - config_data=config_data, - client=self.client - ) - project.volumes.initialize() - volume_data = self.client.inspect_volume(full_vol_name) - self.assertEqual(volume_data['Name'], full_vol_name) - self.assertEqual(volume_data['Driver'], 'local') - - @v2_only() - def test_initialize_volumes_external_volumes(self): - # Use composetest_ prefix so it gets garbage-collected in tearDown() - vol_name = 'composetest_{0:x}'.format(random.getrandbits(32)) - full_vol_name = 'composetest_{0}'.format(vol_name) - self.client.create_volume(vol_name) - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'command': 'top' - }], - volumes={ - vol_name: {'external': True, 'external_name': vol_name} - }, - ) - project = Project.from_config( - name='composetest', - config_data=config_data, client=self.client - ) - project.volumes.initialize() - - with self.assertRaises(NotFound): - self.client.inspect_volume(full_vol_name) - - @v2_only() - def test_initialize_volumes_inexistent_external_volume(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) - - config_data = build_config( - version=V2_0, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - 'command': 'top' - }], - volumes={ - vol_name: {'external': True, 'external_name': vol_name} - }, - ) - project = Project.from_config( - name='composetest', - config_data=config_data, client=self.client - ) - with self.assertRaises(config.ConfigurationError) as e: - project.volumes.initialize() - assert 'Volume {0} declared as external'.format( - vol_name - ) in str(e.exception) - - @v2_only() - def test_project_up_named_volumes_in_binds(self): - vol_name = '{0:x}'.format(random.getrandbits(32)) - full_vol_name = 'composetest_{0}'.format(vol_name) - - base_file = config.ConfigFile( - 'base.yml', - { - 'version': V2_0, - 'services': { - 'simple': { - 'image': 'busybox:latest', - 'command': 'top', - 'volumes': ['{0}:/data'.format(vol_name)] - }, - }, - 'volumes': { - vol_name: {'driver': 'local'} - } - - }) - config_details = config.ConfigDetails('.', [base_file]) - config_data = config.load(config_details) - project = Project.from_config( - name='composetest', config_data=config_data, client=self.client - ) - service = project.services[0] - self.assertEqual(service.name, 'simple') - volumes = service.options.get('volumes') - self.assertEqual(len(volumes), 1) - self.assertEqual(volumes[0].external, full_vol_name) - project.up() - engine_volumes = self.client.volumes()['Volumes'] - container = service.get_container() - assert [mount['Name'] for mount in container.get('Mounts')] == [full_vol_name] - assert next((v for v in engine_volumes if v['Name'] == vol_name), None) is None - - def test_project_up_orphans(self): - config_dict = { - 'service1': { - 'image': 'busybox:latest', - 'command': 'top', - } - } - - config_data = load_config(config_dict) - project = Project.from_config( - name='composetest', config_data=config_data, client=self.client - ) - project.up() - config_dict['service2'] = config_dict['service1'] - del config_dict['service1'] - - config_data = load_config(config_dict) - project = Project.from_config( - name='composetest', config_data=config_data, client=self.client - ) - with mock.patch('compose.project.log') as mock_log: - project.up() - - mock_log.warning.assert_called_once_with(mock.ANY) - - assert len([ - ctnr for ctnr in project._labeled_containers() - if ctnr.labels.get(LABEL_SERVICE) == 'service1' - ]) == 1 - - project.up(remove_orphans=True) - - assert len([ - ctnr for ctnr in project._labeled_containers() - if ctnr.labels.get(LABEL_SERVICE) == 'service1' - ]) == 0 - - @v2_1_only() - def test_project_up_healthy_dependency(self): - config_dict = { - 'version': '2.1', - 'services': { - 'svc1': { - 'image': 'busybox:latest', - 'command': 'top', - 'healthcheck': { - 'test': 'exit 0', - 'retries': 1, - 'timeout': '10s', - 'interval': '0.1s' - }, - }, - 'svc2': { - 'image': 'busybox:latest', - 'command': 'top', - 'depends_on': { - 'svc1': {'condition': 'service_healthy'}, - } - } - } - } - config_data = load_config(config_dict) - project = Project.from_config( - name='composetest', config_data=config_data, client=self.client - ) - project.up() - containers = project.containers() - assert len(containers) == 2 - - svc1 = project.get_service('svc1') - svc2 = project.get_service('svc2') - assert 'svc1' in svc2.get_dependency_names() - assert svc1.is_healthy() - - @v2_1_only() - def test_project_up_unhealthy_dependency(self): - config_dict = { - 'version': '2.1', - 'services': { - 'svc1': { - 'image': 'busybox:latest', - 'command': 'top', - 'healthcheck': { - 'test': 'exit 1', - 'retries': 1, - 'timeout': '10s', - 'interval': '0.1s' - }, - }, - 'svc2': { - 'image': 'busybox:latest', - 'command': 'top', - 'depends_on': { - 'svc1': {'condition': 'service_healthy'}, - } - } - } - } - config_data = load_config(config_dict) - project = Project.from_config( - name='composetest', config_data=config_data, client=self.client - ) - with pytest.raises(ProjectError): - project.up() - containers = project.containers() - assert len(containers) == 1 - - svc1 = project.get_service('svc1') - svc2 = project.get_service('svc2') - assert 'svc1' in svc2.get_dependency_names() - with pytest.raises(HealthCheckFailed): - svc1.is_healthy() - - @v2_1_only() - def test_project_up_no_healthcheck_dependency(self): - config_dict = { - 'version': '2.1', - 'services': { - 'svc1': { - 'image': 'busybox:latest', - 'command': 'top', - 'healthcheck': { - 'disable': True - }, - }, - 'svc2': { - 'image': 'busybox:latest', - 'command': 'top', - 'depends_on': { - 'svc1': {'condition': 'service_healthy'}, - } - } - } - } - config_data = load_config(config_dict) - project = Project.from_config( - name='composetest', config_data=config_data, client=self.client - ) - with pytest.raises(ProjectError): - project.up() - containers = project.containers() - assert len(containers) == 1 - - svc1 = project.get_service('svc1') - svc2 = project.get_service('svc2') - assert 'svc1' in svc2.get_dependency_names() - with pytest.raises(NoHealthCheckConfigured): - svc1.is_healthy() - - -def create_host_file(client, filename): - dirname = os.path.dirname(filename) - - with open(filename, 'r') as fh: - content = fh.read() - - container = client.create_container( - 'busybox:latest', - ['sh', '-c', 'echo -n "{}" > {}'.format(content, filename)], - volumes={dirname: {}}, - host_config=client.create_host_config( - binds={dirname: {'bind': dirname, 'ro': False}}, - network_mode='none', - ), - ) - try: - client.start(container) - exitcode = client.wait(container) - - if exitcode != 0: - output = client.logs(container) - raise Exception( - "Container exited with code {}:\n{}".format(exitcode, output)) - finally: - client.remove_container(container, force=True) + project.kill() + project.remove_stopped() diff --git a/tests/integration/resilience_test.py b/tests/integration/resilience_test.py index 2a2d1b56..8229e9d3 100644 --- a/tests/integration/resilience_test.py +++ b/tests/integration/resilience_test.py @@ -1,52 +1,32 @@ -from __future__ import absolute_import from __future__ import unicode_literals +from __future__ import absolute_import + +import mock -from .. import mock -from .testcases import DockerClientTestCase -from compose.config.types import VolumeSpec from compose.project import Project -from compose.service import ConvergenceStrategy +from .testcases import DockerClientTestCase class ResilienceTest(DockerClientTestCase): - def setUp(self): - self.db = self.create_service( - 'db', - volumes=[VolumeSpec.parse('/var/db')], - command='top') - self.project = Project('composetest', [self.db], self.client) + def test_recreate_fails(self): + db = self.create_service('db', volumes=['/var/db'], command='top') + project = Project('composetest', [db], self.client) - container = self.db.create_container() - self.db.start_container(container) - self.host_path = container.get_mount('/var/db')['Source'] + container = db.create_container() + db.start_container(container) + host_path = container.get('Volumes')['/var/db'] - def tearDown(self): - del self.project - del self.db - super(ResilienceTest, self).tearDown() + project.up() + container = db.containers()[0] + self.assertEqual(container.get('Volumes')['/var/db'], host_path) - def test_successful_recreate(self): - self.project.up(strategy=ConvergenceStrategy.always) - container = self.db.containers()[0] - self.assertEqual(container.get_mount('/var/db')['Source'], self.host_path) - - def test_create_failure(self): with mock.patch('compose.service.Service.create_container', crash): with self.assertRaises(Crash): - self.project.up(strategy=ConvergenceStrategy.always) + project.up() - self.project.up() - container = self.db.containers()[0] - self.assertEqual(container.get_mount('/var/db')['Source'], self.host_path) - - def test_start_failure(self): - with mock.patch('compose.service.Service.start_container', crash): - with self.assertRaises(Crash): - self.project.up(strategy=ConvergenceStrategy.always) - - self.project.up() - container = self.db.containers()[0] - self.assertEqual(container.get_mount('/var/db')['Source'], self.host_path) + project.up() + container = db.containers()[0] + self.assertEqual(container.get('Volumes')['/var/db'], host_path) class Crash(Exception): diff --git a/tests/integration/service_test.py b/tests/integration/service_test.py index 09758eee..32de5fa4 100644 --- a/tests/integration/service_test.py +++ b/tests/integration/service_test.py @@ -1,37 +1,29 @@ -from __future__ import absolute_import from __future__ import unicode_literals - +from __future__ import absolute_import import os -import shutil -import tempfile from os import path +import mock -import pytest -from docker.errors import APIError -from six import StringIO -from six import text_type +import tempfile +import shutil +import six -from .. import mock -from .testcases import DockerClientTestCase -from .testcases import get_links -from .testcases import pull_busybox from compose import __version__ -from compose.config.types import VolumeFromSpec -from compose.config.types import VolumeSpec -from compose.const import LABEL_CONFIG_HASH -from compose.const import LABEL_CONTAINER_NUMBER -from compose.const import LABEL_ONE_OFF -from compose.const import LABEL_PROJECT -from compose.const import LABEL_SERVICE -from compose.const import LABEL_VERSION +from compose.const import ( + LABEL_CONTAINER_NUMBER, + LABEL_ONE_OFF, + LABEL_PROJECT, + LABEL_SERVICE, + LABEL_VERSION, +) +from compose.service import ( + ConfigError, + Service, + build_extra_hosts, +) from compose.container import Container -from compose.project import OneOffFilter -from compose.service import ConvergencePlan -from compose.service import ConvergenceStrategy -from compose.service import NetworkMode -from compose.service import Service -from tests.integration.testcases import v2_1_only -from tests.integration.testcases import v2_only +from docker.errors import APIError +from .testcases import DockerClientTestCase def create_and_start_container(service, **override_options): @@ -64,13 +56,49 @@ class ServiceTest(DockerClientTestCase): db = self.create_service('db') container = db.create_container(one_off=True) self.assertEqual(db.containers(stopped=True), []) - self.assertEqual(db.containers(one_off=OneOffFilter.only, stopped=True), [container]) + self.assertEqual(db.containers(one_off=True, stopped=True), [container]) def test_project_is_added_to_container_name(self): service = self.create_service('web') create_and_start_container(service) self.assertEqual(service.containers()[0].name, 'composetest_web_1') + def test_start_stop(self): + service = self.create_service('scalingtest') + self.assertEqual(len(service.containers(stopped=True)), 0) + + service.create_container() + self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) + + service.start() + self.assertEqual(len(service.containers()), 1) + self.assertEqual(len(service.containers(stopped=True)), 1) + + service.stop(timeout=1) + self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) + + service.stop(timeout=1) + self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) + + def test_kill_remove(self): + service = self.create_service('scalingtest') + + create_and_start_container(service) + self.assertEqual(len(service.containers()), 1) + + service.remove_stopped() + self.assertEqual(len(service.containers()), 1) + + service.kill() + self.assertEqual(len(service.containers()), 0) + self.assertEqual(len(service.containers(stopped=True)), 1) + + service.remove_stopped() + self.assertEqual(len(service.containers(stopped=True)), 0) + def test_create_container_with_one_off(self): db = self.create_service('db') container = db.create_container(one_off=True) @@ -83,35 +111,47 @@ class ServiceTest(DockerClientTestCase): self.assertEqual(container.name, 'composetest_db_run_1') def test_create_container_with_unspecified_volume(self): - service = self.create_service('db', volumes=[VolumeSpec.parse('/var/db')]) + service = self.create_service('db', volumes=['/var/db']) container = service.create_container() service.start_container(container) - assert container.get_mount('/var/db') - - def test_create_container_with_volume_driver(self): - service = self.create_service('db', volume_driver='foodriver') - container = service.create_container() - service.start_container(container) - self.assertEqual('foodriver', container.get('HostConfig.VolumeDriver')) + self.assertIn('/var/db', container.get('Volumes')) def test_create_container_with_cpu_shares(self): service = self.create_service('db', cpu_shares=73) container = service.create_container() service.start_container(container) - self.assertEqual(container.get('HostConfig.CpuShares'), 73) + self.assertEqual(container.inspect()['Config']['CpuShares'], 73) - def test_create_container_with_cpu_quota(self): - service = self.create_service('db', cpu_quota=40000) - container = service.create_container() - container.start() - self.assertEqual(container.get('HostConfig.CpuQuota'), 40000) + def test_build_extra_hosts(self): + # string + self.assertRaises(ConfigError, lambda: build_extra_hosts("www.example.com: 192.168.0.17")) - def test_create_container_with_shm_size(self): - self.require_api_version('1.22') - service = self.create_service('db', shm_size=67108864) - container = service.create_container() - service.start_container(container) - self.assertEqual(container.get('HostConfig.ShmSize'), 67108864) + # list of strings + self.assertEqual(build_extra_hosts( + ["www.example.com:192.168.0.17"]), + {'www.example.com': '192.168.0.17'}) + self.assertEqual(build_extra_hosts( + ["www.example.com: 192.168.0.17"]), + {'www.example.com': '192.168.0.17'}) + self.assertEqual(build_extra_hosts( + ["www.example.com: 192.168.0.17", + "static.example.com:192.168.0.19", + "api.example.com: 192.168.0.18"]), + {'www.example.com': '192.168.0.17', + 'static.example.com': '192.168.0.19', + 'api.example.com': '192.168.0.18'}) + + # list of dictionaries + self.assertRaises(ConfigError, lambda: build_extra_hosts( + [{'www.example.com': '192.168.0.17'}, + {'api.example.com': '192.168.0.18'}])) + + # dictionaries + self.assertEqual(build_extra_hosts( + {'www.example.com': '192.168.0.17', + 'api.example.com': '192.168.0.18'}), + {'www.example.com': '192.168.0.17', + 'api.example.com': '192.168.0.18'}) def test_create_container_with_extra_hosts_list(self): extra_hosts = ['somehost:162.242.195.82', 'otherhost:50.31.209.229'] @@ -120,6 +160,16 @@ class ServiceTest(DockerClientTestCase): service.start_container(container) self.assertEqual(set(container.get('HostConfig.ExtraHosts')), set(extra_hosts)) + def test_create_container_with_extra_hosts_string(self): + extra_hosts = 'somehost:162.242.195.82' + service = self.create_service('db', extra_hosts=extra_hosts) + self.assertRaises(ConfigError, lambda: service.create_container()) + + def test_create_container_with_extra_hosts_list_of_dicts(self): + extra_hosts = [{'somehost': '162.242.195.82'}, {'otherhost': '50.31.209.229'}] + service = self.create_service('db', extra_hosts=extra_hosts) + self.assertRaises(ConfigError, lambda: service.create_container()) + def test_create_container_with_extra_hosts_dicts(self): extra_hosts = {'somehost': '162.242.195.82', 'otherhost': '50.31.209.229'} extra_hosts_list = ['somehost:162.242.195.82', 'otherhost:50.31.209.229'] @@ -132,14 +182,14 @@ class ServiceTest(DockerClientTestCase): service = self.create_service('db', cpuset='0') container = service.create_container() service.start_container(container) - self.assertEqual(container.get('HostConfig.CpusetCpus'), '0') + self.assertEqual(container.inspect()['Config']['Cpuset'], '0') def test_create_container_with_read_only_root_fs(self): read_only = True service = self.create_service('db', read_only=read_only) container = service.create_container() service.start_container(container) - assert container.get('HostConfig.ReadonlyRootfs') == read_only + self.assertEqual(container.get('HostConfig.ReadonlyRootfs'), read_only, container.get('HostConfig')) def test_create_container_with_security_opt(self): security_opt = ['label:disable'] @@ -148,102 +198,57 @@ class ServiceTest(DockerClientTestCase): service.start_container(container) self.assertEqual(set(container.get('HostConfig.SecurityOpt')), set(security_opt)) - def test_create_container_with_mac_address(self): - service = self.create_service('db', mac_address='02:42:ac:11:65:43') - container = service.create_container() - service.start_container(container) - self.assertEqual(container.inspect()['Config']['MacAddress'], '02:42:ac:11:65:43') - def test_create_container_with_specified_volume(self): host_path = '/tmp/host-path' container_path = '/container-path' - service = self.create_service( - 'db', - volumes=[VolumeSpec(host_path, container_path, 'rw')]) + service = self.create_service('db', volumes=['%s:%s' % (host_path, container_path)]) container = service.create_container() service.start_container(container) - assert container.get_mount(container_path) + + volumes = container.inspect()['Volumes'] + self.assertIn(container_path, volumes) # Match the last component ("host-path"), because boot2docker symlinks /tmp - actual_host_path = container.get_mount(container_path)['Source'] - + actual_host_path = volumes[container_path] self.assertTrue(path.basename(actual_host_path) == path.basename(host_path), msg=("Last component differs: %s, %s" % (actual_host_path, host_path))) - def test_recreate_preserves_volume_with_trailing_slash(self): - """When the Compose file specifies a trailing slash in the container path, make - sure we copy the volume over when recreating. - """ - service = self.create_service('data', volumes=[VolumeSpec.parse('/data/')]) - old_container = create_and_start_container(service) - volume_path = old_container.get_mount('/data')['Source'] + @mock.patch.dict(os.environ) + def test_create_container_with_home_and_env_var_in_volume_path(self): + os.environ['VOLUME_NAME'] = 'my-volume' + os.environ['HOME'] = '/tmp/home-dir' + expected_host_path = os.path.join(os.environ['HOME'], os.environ['VOLUME_NAME']) - new_container = service.recreate_container(old_container) - self.assertEqual(new_container.get_mount('/data')['Source'], volume_path) + host_path = '~/${VOLUME_NAME}' + container_path = '/container-path' - def test_duplicate_volume_trailing_slash(self): - """ - When an image specifies a volume, and the Compose file specifies a host path - but adds a trailing slash, make sure that we don't create duplicate binds. - """ - host_path = '/tmp/data' - container_path = '/data' - volumes = [VolumeSpec.parse('{}:{}/'.format(host_path, container_path))] + service = self.create_service('db', volumes=['%s:%s' % (host_path, container_path)]) + container = service.create_container() + service.start_container(container) - tmp_container = self.client.create_container( - 'busybox', 'true', - volumes={container_path: {}}, - labels={'com.docker.compose.test_image': 'true'}, - ) - image = self.client.commit(tmp_container)['Id'] - - service = self.create_service('db', image=image, volumes=volumes) - old_container = create_and_start_container(service) - - self.assertEqual( - old_container.get('Config.Volumes'), - {container_path: {}}, - ) - - service = self.create_service('db', image=image, volumes=volumes) - new_container = service.recreate_container(old_container) - - self.assertEqual( - new_container.get('Config.Volumes'), - {container_path: {}}, - ) - - self.assertEqual(service.containers(stopped=False), [new_container]) + actual_host_path = container.get('Volumes')[container_path] + components = actual_host_path.split('/') + self.assertTrue(components[-2:] == ['home-dir', 'my-volume'], + msg="Last two components differ: %s, %s" % (actual_host_path, expected_host_path)) def test_create_container_with_volumes_from(self): volume_service = self.create_service('data') volume_container_1 = volume_service.create_container() - volume_container_2 = Container.create( - self.client, - image='busybox:latest', - command=["top"], - labels={LABEL_PROJECT: 'composetest'}, - ) - host_service = self.create_service( - 'host', - volumes_from=[ - VolumeFromSpec(volume_service, 'rw', 'service'), - VolumeFromSpec(volume_container_2, 'rw', 'container') - ] - ) + volume_container_2 = Container.create(self.client, image='busybox:latest', command=["top"]) + host_service = self.create_service('host', volumes_from=[volume_service, volume_container_2]) host_container = host_service.create_container() host_service.start_container(host_container) - self.assertIn(volume_container_1.id + ':rw', + self.assertIn(volume_container_1.id, host_container.get('HostConfig.VolumesFrom')) - self.assertIn(volume_container_2.id + ':rw', + self.assertIn(volume_container_2.id, host_container.get('HostConfig.VolumesFrom')) - def test_execute_convergence_plan_recreate(self): + def test_converge(self): service = self.create_service( 'db', environment={'FOO': '1'}, - volumes=[VolumeSpec.parse('/etc')], + volumes=['/etc'], entrypoint=['top'], command=['-d', '1'] ) @@ -254,19 +259,18 @@ class ServiceTest(DockerClientTestCase): self.assertEqual(old_container.name, 'composetest_db_1') service.start_container(old_container) old_container.inspect() # reload volume data - volume_path = old_container.get_mount('/etc')['Source'] + volume_path = old_container.get('Volumes')['/etc'] num_containers_before = len(self.client.containers(all=True)) service.options['environment']['FOO'] = '2' - new_container, = service.execute_convergence_plan( - ConvergencePlan('recreate', [old_container])) + new_container = service.converge()[0] self.assertEqual(new_container.get('Config.Entrypoint'), ['top']) self.assertEqual(new_container.get('Config.Cmd'), ['-d', '1']) self.assertIn('FOO=2', new_container.get('Config.Env')) self.assertEqual(new_container.name, 'composetest_db_1') - self.assertEqual(new_container.get_mount('/etc')['Source'], volume_path) + self.assertEqual(new_container.get('Volumes')['/etc'], volume_path) self.assertIn( 'affinity:container==%s' % old_container.id, new_container.get('Config.Env')) @@ -277,151 +281,34 @@ class ServiceTest(DockerClientTestCase): self.client.inspect_container, old_container.id) - def test_execute_convergence_plan_recreate_twice(self): - service = self.create_service( - 'db', - volumes=[VolumeSpec.parse('/etc')], - entrypoint=['top'], - command=['-d', '1']) - - orig_container = service.create_container() - service.start_container(orig_container) - - orig_container.inspect() # reload volume data - volume_path = orig_container.get_mount('/etc')['Source'] - - # Do this twice to reproduce the bug - for _ in range(2): - new_container, = service.execute_convergence_plan( - ConvergencePlan('recreate', [orig_container])) - - assert new_container.get_mount('/etc')['Source'] == volume_path - assert ('affinity:container==%s' % orig_container.id in - new_container.get('Config.Env')) - - orig_container = new_container - - def test_execute_convergence_plan_when_containers_are_stopped(self): + def test_converge_when_containers_are_stopped(self): service = self.create_service( 'db', environment={'FOO': '1'}, - volumes=[VolumeSpec.parse('/var/db')], + volumes=['/var/db'], entrypoint=['top'], command=['-d', '1'] ) service.create_container() + self.assertEqual(len(service.containers(stopped=True)), 1) + service.converge() + self.assertEqual(len(service.containers(stopped=True)), 1) - containers = service.containers(stopped=True) - self.assertEqual(len(containers), 1) - container, = containers - self.assertFalse(container.is_running) - - service.execute_convergence_plan(ConvergencePlan('start', [container])) - - containers = service.containers() - self.assertEqual(len(containers), 1) - container.inspect() - self.assertEqual(container, containers[0]) - self.assertTrue(container.is_running) - - def test_execute_convergence_plan_with_image_declared_volume(self): + def test_converge_with_image_declared_volume(self): service = Service( project='composetest', name='db', client=self.client, - build={'context': 'tests/fixtures/dockerfile-with-volume'}, + build='tests/fixtures/dockerfile-with-volume', ) old_container = create_and_start_container(service) - self.assertEqual( - [mount['Destination'] for mount in old_container.get('Mounts')], ['/data'] - ) - volume_path = old_container.get_mount('/data')['Source'] + self.assertEqual(old_container.get('Volumes').keys(), ['/data']) + volume_path = old_container.get('Volumes')['/data'] - new_container, = service.execute_convergence_plan( - ConvergencePlan('recreate', [old_container])) - - self.assertEqual( - [mount['Destination'] for mount in new_container.get('Mounts')], - ['/data'] - ) - self.assertEqual(new_container.get_mount('/data')['Source'], volume_path) - - def test_execute_convergence_plan_when_image_volume_masks_config(self): - service = self.create_service( - 'db', - build={'context': 'tests/fixtures/dockerfile-with-volume'}, - ) - - old_container = create_and_start_container(service) - self.assertEqual( - [mount['Destination'] for mount in old_container.get('Mounts')], - ['/data'] - ) - volume_path = old_container.get_mount('/data')['Source'] - - service.options['volumes'] = [VolumeSpec.parse('/tmp:/data')] - - with mock.patch('compose.service.log') as mock_log: - new_container, = service.execute_convergence_plan( - ConvergencePlan('recreate', [old_container])) - - mock_log.warn.assert_called_once_with(mock.ANY) - _, args, kwargs = mock_log.warn.mock_calls[0] - self.assertIn( - "Service \"db\" is using volume \"/data\" from the previous container", - args[0]) - - self.assertEqual( - [mount['Destination'] for mount in new_container.get('Mounts')], - ['/data'] - ) - self.assertEqual(new_container.get_mount('/data')['Source'], volume_path) - - def test_execute_convergence_plan_when_host_volume_is_removed(self): - host_path = '/tmp/host-path' - service = self.create_service( - 'db', - build={'context': 'tests/fixtures/dockerfile-with-volume'}, - volumes=[VolumeSpec(host_path, '/data', 'rw')]) - - old_container = create_and_start_container(service) - assert ( - [mount['Destination'] for mount in old_container.get('Mounts')] == - ['/data'] - ) - service.options['volumes'] = [] - - with mock.patch('compose.service.log', autospec=True) as mock_log: - new_container, = service.execute_convergence_plan( - ConvergencePlan('recreate', [old_container])) - - assert not mock_log.warn.called - assert ( - [mount['Destination'] for mount in new_container.get('Mounts')] == - ['/data'] - ) - assert new_container.get_mount('/data')['Source'] != host_path - - def test_execute_convergence_plan_without_start(self): - service = self.create_service( - 'db', - build={'context': 'tests/fixtures/dockerfile-with-volume'} - ) - - containers = service.execute_convergence_plan(ConvergencePlan('create', []), start=False) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) - - containers = service.execute_convergence_plan( - ConvergencePlan('recreate', containers), - start=False) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) - - service.execute_convergence_plan(ConvergencePlan('start', containers), start=False) - self.assertEqual(len(service.containers()), 0) - self.assertEqual(len(service.containers(stopped=True)), 1) + new_container = service.converge()[0] + self.assertEqual(new_container.get('Volumes').keys(), ['/data']) + self.assertEqual(new_container.get('Volumes')['/data'], volume_path) def test_start_container_passes_through_options(self): db = self.create_service('db') @@ -442,7 +329,7 @@ class ServiceTest(DockerClientTestCase): create_and_start_container(web) self.assertEqual( - set(get_links(web.containers()[0])), + set(web.containers()[0].links()), set([ 'composetest_db_1', 'db_1', 'composetest_db_2', 'db_2', @@ -458,7 +345,7 @@ class ServiceTest(DockerClientTestCase): create_and_start_container(web) self.assertEqual( - set(get_links(web.containers()[0])), + set(web.containers()[0].links()), set([ 'composetest_db_1', 'db_1', 'composetest_db_2', 'db_2', @@ -476,7 +363,7 @@ class ServiceTest(DockerClientTestCase): create_and_start_container(web) self.assertEqual( - set(get_links(web.containers()[0])), + set(web.containers()[0].links()), set([ 'composetest_db_1', 'composetest_db_2', @@ -490,7 +377,7 @@ class ServiceTest(DockerClientTestCase): create_and_start_container(db) c = create_and_start_container(db) - self.assertEqual(set(get_links(c)), set([])) + self.assertEqual(set(c.links()), set([])) def test_start_one_off_container_creates_links_to_its_own_service(self): db = self.create_service('db') @@ -498,10 +385,10 @@ class ServiceTest(DockerClientTestCase): create_and_start_container(db) create_and_start_container(db) - c = create_and_start_container(db, one_off=OneOffFilter.only) + c = create_and_start_container(db, one_off=True) self.assertEqual( - set(get_links(c)), + set(c.links()), set([ 'composetest_db_1', 'db_1', 'composetest_db_2', 'db_2', @@ -512,25 +399,25 @@ class ServiceTest(DockerClientTestCase): service = Service( name='test', client=self.client, - build={'context': 'tests/fixtures/simple-dockerfile'}, + build='tests/fixtures/simple-dockerfile', project='composetest', ) container = create_and_start_container(service) container.wait() - self.assertIn(b'success', container.logs()) + self.assertIn('success', container.logs()) self.assertEqual(len(self.client.images(name='composetest_test')), 1) def test_start_container_uses_tagged_image_if_it_exists(self): - self.check_build('tests/fixtures/simple-dockerfile', tag='composetest_test') + self.client.build('tests/fixtures/simple-dockerfile', tag='composetest_test') service = Service( name='test', client=self.client, - build={'context': 'this/does/not/exist/and/will/throw/error'}, + build='this/does/not/exist/and/will/throw/error', project='composetest', ) container = create_and_start_container(service) container.wait() - self.assertIn(b'success', container.logs()) + self.assertIn('success', container.logs()) def test_start_container_creates_ports(self): service = self.create_service('web', ports=[8000]) @@ -545,8 +432,8 @@ class ServiceTest(DockerClientTestCase): with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: f.write("FROM busybox\n") - self.create_service('web', build={'context': base_dir}).build() - assert self.client.inspect_image('composetest_web') + self.create_service('web', build=base_dir).build() + self.assertEqual(len(self.client.images(name='composetest_web')), 1) def test_build_non_ascii_filename(self): base_dir = tempfile.mkdtemp() @@ -555,57 +442,24 @@ class ServiceTest(DockerClientTestCase): with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: f.write("FROM busybox\n") - with open(os.path.join(base_dir.encode('utf8'), b'foo\xE2bar'), 'w') as f: + with open(os.path.join(base_dir, b'foo\xE2bar'), 'w') as f: f.write("hello world\n") - self.create_service('web', build={'context': text_type(base_dir)}).build() - assert self.client.inspect_image('composetest_web') + self.create_service('web', build=six.text_type(base_dir)).build() + self.assertEqual(len(self.client.images(name='composetest_web')), 1) - def test_build_with_image_name(self): - base_dir = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, base_dir) - - with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: - f.write("FROM busybox\n") - - image_name = 'examples/composetest:latest' - self.addCleanup(self.client.remove_image, image_name) - self.create_service('web', build={'context': base_dir}, image=image_name).build() - assert self.client.inspect_image(image_name) - - def test_build_with_git_url(self): - build_url = "https://github.com/dnephin/docker-build-from-url.git" - service = self.create_service('buildwithurl', build={'context': build_url}) - self.addCleanup(self.client.remove_image, service.image_name) - service.build() - assert service.image() - - def test_build_with_build_args(self): - base_dir = tempfile.mkdtemp() - self.addCleanup(shutil.rmtree, base_dir) - - with open(os.path.join(base_dir, 'Dockerfile'), 'w') as f: - f.write("FROM busybox\n") - f.write("ARG build_version\n") - - service = self.create_service('buildwithargs', - build={'context': text_type(base_dir), - 'args': {"build_version": "1"}}) - service.build() - assert service.image() - - def test_start_container_stays_unprivileged(self): + def test_start_container_stays_unpriviliged(self): service = self.create_service('web') container = create_and_start_container(service).inspect() self.assertEqual(container['HostConfig']['Privileged'], False) - def test_start_container_becomes_privileged(self): + def test_start_container_becomes_priviliged(self): service = self.create_service('web', privileged=True) container = create_and_start_container(service).inspect() self.assertEqual(container['HostConfig']['Privileged'], True) def test_expose_does_not_publish_ports(self): - service = self.create_service('web', expose=["8000"]) + service = self.create_service('web', expose=[8000]) container = create_and_start_container(service).inspect() self.assertEqual(container['NetworkSettings']['Ports'], {'8000/tcp': None}) @@ -648,10 +502,8 @@ class ServiceTest(DockerClientTestCase): }) def test_create_with_image_id(self): - # Get image id for the current busybox:latest - pull_busybox(self.client) - image_id = self.client.inspect_image('busybox:latest')['Id'][:12] - service = self.create_service('foo', image=image_id) + # Image id for the current busybox:latest + service = self.create_service('foo', image='8c2e06607696') service.create_container() def test_scale(self): @@ -673,163 +525,26 @@ class ServiceTest(DockerClientTestCase): service.scale(0) self.assertEqual(len(service.containers()), 0) - def test_scale_with_stopped_containers(self): - """ - Given there are some stopped containers and scale is called with a - desired number that is the same as the number of stopped containers, - test that those containers are restarted and not removed/recreated. - """ - service = self.create_service('web') - next_number = service._next_container_number() - valid_numbers = [next_number, next_number + 1] - service.create_container(number=next_number) - service.create_container(number=next_number + 1) - - with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: - service.scale(2) - for container in service.containers(): - self.assertTrue(container.is_running) - self.assertTrue(container.number in valid_numbers) - - captured_output = mock_stderr.getvalue() - self.assertNotIn('Creating', captured_output) - self.assertIn('Starting', captured_output) - - def test_scale_with_stopped_containers_and_needing_creation(self): - """ - Given there are some stopped containers and scale is called with a - desired number that is greater than the number of stopped containers, - test that those containers are restarted and required number are created. - """ - service = self.create_service('web') - next_number = service._next_container_number() - service.create_container(number=next_number, quiet=True) - - for container in service.containers(): - self.assertFalse(container.is_running) - - with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: - service.scale(2) - - self.assertEqual(len(service.containers()), 2) - for container in service.containers(): - self.assertTrue(container.is_running) - - captured_output = mock_stderr.getvalue() - self.assertIn('Creating', captured_output) - self.assertIn('Starting', captured_output) - - def test_scale_with_api_error(self): - """Test that when scaling if the API returns an error, that error is handled - and the remaining threads continue. - """ - service = self.create_service('web') - next_number = service._next_container_number() - service.create_container(number=next_number, quiet=True) - - with mock.patch( - 'compose.container.Container.create', - side_effect=APIError( - message="testing", - response={}, - explanation="Boom")): - - with mock.patch('sys.stderr', new_callable=StringIO) as mock_stderr: - service.scale(3) - - self.assertEqual(len(service.containers()), 1) - self.assertTrue(service.containers()[0].is_running) - self.assertIn( - "ERROR: for composetest_web_2 Cannot create container for service web: Boom", - mock_stderr.getvalue() - ) - - def test_scale_with_unexpected_exception(self): - """Test that when scaling if the API returns an error, that is not of type - APIError, that error is re-raised. - """ - service = self.create_service('web') - next_number = service._next_container_number() - service.create_container(number=next_number, quiet=True) - - with mock.patch( - 'compose.container.Container.create', - side_effect=ValueError("BOOM") - ): - with self.assertRaises(ValueError): - service.scale(3) - - self.assertEqual(len(service.containers()), 1) - self.assertTrue(service.containers()[0].is_running) - - @mock.patch('compose.service.log') - def test_scale_with_desired_number_already_achieved(self, mock_log): - """ - Test that calling scale with a desired number that is equal to the - number of containers already running results in no change. - """ - service = self.create_service('web') - next_number = service._next_container_number() - container = service.create_container(number=next_number, quiet=True) - container.start() - - container.inspect() - assert container.is_running - assert len(service.containers()) == 1 - - service.scale(1) - assert len(service.containers()) == 1 - container.inspect() - assert container.is_running - - captured_output = mock_log.info.call_args[0] - assert 'Desired container number already achieved' in captured_output - - @mock.patch('compose.service.log') - def test_scale_with_custom_container_name_outputs_warning(self, mock_log): - """Test that calling scale on a service that has a custom container name - results in warning output. - """ - service = self.create_service('app', container_name='custom-container') - self.assertEqual(service.custom_container_name, 'custom-container') - - service.scale(3) - - captured_output = mock_log.warn.call_args[0][0] - - self.assertEqual(len(service.containers()), 1) - self.assertIn( - "Remove the custom name to scale the service.", - captured_output - ) - def test_scale_sets_ports(self): service = self.create_service('web', ports=['8000']) service.scale(2) containers = service.containers() self.assertEqual(len(containers), 2) for container in containers: - self.assertEqual( - list(container.get('HostConfig.PortBindings')), - ['8000/tcp']) - - def test_scale_with_immediate_exit(self): - service = self.create_service('web', image='busybox', command='true') - service.scale(2) - assert len(service.containers(stopped=True)) == 2 + self.assertEqual(list(container.inspect()['HostConfig']['PortBindings'].keys()), ['8000/tcp']) def test_network_mode_none(self): - service = self.create_service('web', network_mode=NetworkMode('none')) + service = self.create_service('web', net='none') container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.NetworkMode'), 'none') def test_network_mode_bridged(self): - service = self.create_service('web', network_mode=NetworkMode('bridge')) + service = self.create_service('web', net='bridge') container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.NetworkMode'), 'bridge') def test_network_mode_host(self): - service = self.create_service('web', network_mode=NetworkMode('host')) + service = self.create_service('web', net='host') container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.NetworkMode'), 'host') @@ -843,56 +558,28 @@ class ServiceTest(DockerClientTestCase): container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.PidMode'), 'host') - @v2_1_only() - def test_userns_mode_none_defined(self): - service = self.create_service('web', userns_mode=None) - container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.UsernsMode'), '') - - @v2_1_only() - def test_userns_mode_host(self): - service = self.create_service('web', userns_mode='host') - container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.UsernsMode'), 'host') - def test_dns_no_value(self): service = self.create_service('web') container = create_and_start_container(service) self.assertIsNone(container.get('HostConfig.Dns')) + def test_dns_single_value(self): + service = self.create_service('web', dns='8.8.8.8') + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.Dns'), ['8.8.8.8']) + def test_dns_list(self): service = self.create_service('web', dns=['8.8.8.8', '9.9.9.9']) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.Dns'), ['8.8.8.8', '9.9.9.9']) - def test_mem_swappiness(self): - service = self.create_service('web', mem_swappiness=11) - container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.MemorySwappiness'), 11) - def test_restart_always_value(self): - service = self.create_service('web', restart={'Name': 'always'}) + service = self.create_service('web', restart='always') container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.RestartPolicy.Name'), 'always') - def test_oom_score_adj_value(self): - service = self.create_service('web', oom_score_adj=500) - container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.OomScoreAdj'), 500) - - def test_group_add_value(self): - service = self.create_service('web', group_add=["root", "1"]) - container = create_and_start_container(service) - - host_container_groupadd = container.get('HostConfig.GroupAdd') - self.assertTrue("root" in host_container_groupadd) - self.assertTrue("1" in host_container_groupadd) - def test_restart_on_failure_value(self): - service = self.create_service('web', restart={ - 'Name': 'on-failure', - 'MaximumRetryCount': 5 - }) + service = self.create_service('web', restart='on-failure:5') container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.RestartPolicy.Name'), 'on-failure') self.assertEqual(container.get('HostConfig.RestartPolicy.MaximumRetryCount'), 5) @@ -907,43 +594,36 @@ class ServiceTest(DockerClientTestCase): container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.CapDrop'), ['SYS_ADMIN', 'NET_ADMIN']) - def test_dns_search(self): + def test_dns_search_no_value(self): + service = self.create_service('web') + container = create_and_start_container(service) + self.assertIsNone(container.get('HostConfig.DnsSearch')) + + def test_dns_search_single_value(self): + service = self.create_service('web', dns_search='example.com') + container = create_and_start_container(service) + self.assertEqual(container.get('HostConfig.DnsSearch'), ['example.com']) + + def test_dns_search_list(self): service = self.create_service('web', dns_search=['dc1.example.com', 'dc2.example.com']) container = create_and_start_container(service) self.assertEqual(container.get('HostConfig.DnsSearch'), ['dc1.example.com', 'dc2.example.com']) - @v2_only() - def test_tmpfs(self): - service = self.create_service('web', tmpfs=['/run']) - container = create_and_start_container(service) - self.assertEqual(container.get('HostConfig.Tmpfs'), {'/run': ''}) - def test_working_dir_param(self): service = self.create_service('container', working_dir='/working/dir/sample') container = service.create_container() self.assertEqual(container.get('Config.WorkingDir'), '/working/dir/sample') def test_split_env(self): - service = self.create_service( - 'web', - environment=['NORMAL=F1', 'CONTAINS_EQUALS=F=2', 'TRAILING_EQUALS=']) + service = self.create_service('web', environment=['NORMAL=F1', 'CONTAINS_EQUALS=F=2', 'TRAILING_EQUALS=']) env = create_and_start_container(service).environment for k, v in {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''}.items(): self.assertEqual(env[k], v) def test_env_from_file_combined_with_env(self): - service = self.create_service( - 'web', - environment=['ONE=1', 'TWO=2', 'THREE=3'], - env_file=['tests/fixtures/env/one.env', 'tests/fixtures/env/two.env']) + service = self.create_service('web', environment=['ONE=1', 'TWO=2', 'THREE=3'], env_file=['tests/fixtures/env/one.env', 'tests/fixtures/env/two.env']) env = create_and_start_container(service).environment - for k, v in { - 'ONE': '1', - 'TWO': '2', - 'THREE': '3', - 'FOO': 'baz', - 'DOO': 'dah' - }.items(): + for k, v in {'ONE': '1', 'TWO': '2', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}.items(): self.assertEqual(env[k], v) @mock.patch.dict(os.environ) @@ -951,31 +631,11 @@ class ServiceTest(DockerClientTestCase): os.environ['FILE_DEF'] = 'E1' os.environ['FILE_DEF_EMPTY'] = 'E2' os.environ['ENV_DEF'] = 'E3' - service = self.create_service( - 'web', - environment={ - 'FILE_DEF': 'F1', - 'FILE_DEF_EMPTY': '', - 'ENV_DEF': None, - 'NO_DEF': None - } - ) + service = self.create_service('web', environment={'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': None, 'NO_DEF': None}) env = create_and_start_container(service).environment - for k, v in { - 'FILE_DEF': 'F1', - 'FILE_DEF_EMPTY': '', - 'ENV_DEF': 'E3', - 'NO_DEF': None - }.items(): + for k, v in {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}.items(): self.assertEqual(env[k], v) - def test_with_high_enough_api_version_we_get_default_network_mode(self): - # TODO: remove this test once minimum docker version is 1.8.x - with mock.patch.object(self.client, '_version', '1.20'): - service = self.create_service('web') - service_config = service._get_container_host_config({}) - self.assertEquals(service_config['NetworkMode'], 'default') - def test_labels(self): labels_dict = { 'com.example.description': "Accounting webapp", @@ -997,36 +657,27 @@ class ServiceTest(DockerClientTestCase): for pair in expected.items(): self.assertIn(pair, labels) - def test_empty_labels(self): - labels_dict = {'foo': '', 'bar': ''} - service = self.create_service('web', labels=labels_dict) + service.kill() + service.remove_stopped() + + labels_list = ["%s=%s" % pair for pair in labels_dict.items()] + + service = self.create_service('web', labels=labels_list) labels = create_and_start_container(service).labels.items() - for name in labels_dict: + for pair in expected.items(): + self.assertIn(pair, labels) + + def test_empty_labels(self): + labels_list = ['foo', 'bar'] + + service = self.create_service('web', labels=labels_list) + labels = create_and_start_container(service).labels.items() + for name in labels_list: self.assertIn((name, ''), labels) - def test_stop_signal(self): - stop_signal = 'SIGINT' - service = self.create_service('web', stop_signal=stop_signal) - container = create_and_start_container(service) - self.assertEqual(container.stop_signal, stop_signal) - - def test_custom_container_name(self): - service = self.create_service('web', container_name='my-web-container') - self.assertEqual(service.custom_container_name, 'my-web-container') - - container = create_and_start_container(service) - self.assertEqual(container.name, 'my-web-container') - - one_off_container = service.create_container(one_off=True) - self.assertNotEqual(one_off_container.name, 'my-web-container') - - @pytest.mark.skipif(True, reason="Broken on 1.11.0rc1") def test_log_drive_invalid(self): - service = self.create_service('web', logging={'driver': 'xxx'}) - expected_error_msg = "logger: no log driver named 'xxx' is registered" - - with self.assertRaisesRegexp(APIError, expected_error_msg): - create_and_start_container(service) + service = self.create_service('web', log_driver='xxx') + self.assertRaises(ValueError, lambda: create_and_start_container(service)) def test_log_drive_empty_default_jsonfile(self): service = self.create_service('web') @@ -1036,7 +687,7 @@ class ServiceTest(DockerClientTestCase): self.assertFalse(log_config['Config']) def test_log_drive_none(self): - service = self.create_service('web', logging={'driver': 'none'}) + service = self.create_service('web', log_driver='none') log_config = create_and_start_container(service).log_config self.assertEqual('none', log_config['Type']) @@ -1054,51 +705,3 @@ class ServiceTest(DockerClientTestCase): self.assertEqual(1, len(device_config)) self.assertDictEqual(device_dict, device_config[0]) - - def test_duplicate_containers(self): - service = self.create_service('web') - - options = service._get_container_create_options({}, 1) - original = Container.create(service.client, **options) - - self.assertEqual(set(service.containers(stopped=True)), set([original])) - self.assertEqual(set(service.duplicate_containers()), set()) - - options['name'] = 'temporary_container_name' - duplicate = Container.create(service.client, **options) - - self.assertEqual(set(service.containers(stopped=True)), set([original, duplicate])) - self.assertEqual(set(service.duplicate_containers()), set([duplicate])) - - -def converge(service, strategy=ConvergenceStrategy.changed): - """Create a converge plan from a strategy and execute the plan.""" - plan = service.convergence_plan(strategy) - return service.execute_convergence_plan(plan, timeout=1) - - -class ConfigHashTest(DockerClientTestCase): - def test_no_config_hash_when_one_off(self): - web = self.create_service('web') - container = web.create_container(one_off=True) - self.assertNotIn(LABEL_CONFIG_HASH, container.labels) - - def test_no_config_hash_when_overriding_options(self): - web = self.create_service('web') - container = web.create_container(environment={'FOO': '1'}) - self.assertNotIn(LABEL_CONFIG_HASH, container.labels) - - def test_config_hash_with_custom_labels(self): - web = self.create_service('web', labels={'foo': '1'}) - container = converge(web)[0] - self.assertIn(LABEL_CONFIG_HASH, container.labels) - self.assertIn('foo', container.labels) - - def test_config_hash_sticks_around(self): - web = self.create_service('web', command=["top"]) - container = converge(web)[0] - self.assertIn(LABEL_CONFIG_HASH, container.labels) - - web = self.create_service('web', command=["top", "-d", "1"]) - container = converge(web)[0] - self.assertIn(LABEL_CONFIG_HASH, container.labels) diff --git a/tests/integration/state_test.py b/tests/integration/state_test.py index 07b28e78..7a7d2b58 100644 --- a/tests/integration/state_test.py +++ b/tests/integration/state_test.py @@ -1,36 +1,30 @@ -""" -Integration tests which cover state convergence (aka smart recreate) performed -by `docker-compose up`. -""" -from __future__ import absolute_import from __future__ import unicode_literals +import tempfile +import shutil +import os -import py +from compose import config +from compose.project import Project +from compose.const import LABEL_CONFIG_HASH from .testcases import DockerClientTestCase -from .testcases import get_links -from compose.config import config -from compose.project import Project -from compose.service import ConvergenceStrategy class ProjectTestCase(DockerClientTestCase): def run_up(self, cfg, **kwargs): - kwargs.setdefault('timeout', 1) - kwargs.setdefault('detached', True) + if 'smart_recreate' not in kwargs: + kwargs['smart_recreate'] = True project = self.make_project(cfg) project.up(**kwargs) return set(project.containers(stopped=True)) def make_project(self, cfg): - details = config.ConfigDetails( - 'working_dir', - [config.ConfigFile(None, cfg)]) - return Project.from_config( + return Project.from_dicts( name='composetest', client=self.client, - config_data=config.load(details)) + service_dicts=config.from_dictionary(cfg), + ) class BasicProjectTest(ProjectTestCase): @@ -38,8 +32,8 @@ class BasicProjectTest(ProjectTestCase): super(BasicProjectTest, self).setUp() self.cfg = { - 'db': {'image': 'busybox:latest', 'command': 'top'}, - 'web': {'image': 'busybox:latest', 'command': 'top'}, + 'db': {'image': 'busybox:latest'}, + 'web': {'image': 'busybox:latest'}, } def test_no_change(self): @@ -154,50 +148,15 @@ class ProjectWithDependenciesTest(ProjectTestCase): old_containers = self.run_up(self.cfg) self.cfg['db']['environment'] = {'NEW_VAR': '1'} - new_containers = self.run_up( - self.cfg, - strategy=ConvergenceStrategy.never) + new_containers = self.run_up(self.cfg, allow_recreate=False) self.assertEqual(new_containers - old_containers, set()) - def test_service_removed_while_down(self): - next_cfg = { - 'web': { - 'image': 'busybox:latest', - 'command': 'tail -f /dev/null', - }, - 'nginx': self.cfg['nginx'], - } - - containers = self.run_up(self.cfg) - self.assertEqual(len(containers), 3) - - project = self.make_project(self.cfg) - project.stop(timeout=1) - - containers = self.run_up(next_cfg) - self.assertEqual(len(containers), 2) - - def test_service_recreated_when_dependency_created(self): - containers = self.run_up(self.cfg, service_names=['web'], start_deps=False) - self.assertEqual(len(containers), 1) - - containers = self.run_up(self.cfg) - self.assertEqual(len(containers), 3) - - web, = [c for c in containers if c.service == 'web'] - nginx, = [c for c in containers if c.service == 'nginx'] - - self.assertEqual(set(get_links(web)), {'composetest_db_1', 'db', 'db_1'}) - self.assertEqual(set(get_links(nginx)), {'composetest_web_1', 'web', 'web_1'}) - class ServiceStateTest(DockerClientTestCase): - """Test cases for Service.convergence_plan.""" - def test_trigger_create(self): web = self.create_service('web') - self.assertEqual(('create', []), web.convergence_plan()) + self.assertEqual(('create', []), web.convergence_plan(smart_recreate=True)) def test_trigger_noop(self): web = self.create_service('web') @@ -205,7 +164,7 @@ class ServiceStateTest(DockerClientTestCase): web.start() web = self.create_service('web') - self.assertEqual(('noop', [container]), web.convergence_plan()) + self.assertEqual(('noop', [container]), web.convergence_plan(smart_recreate=True)) def test_trigger_start(self): options = dict(command=["top"]) @@ -219,9 +178,10 @@ class ServiceStateTest(DockerClientTestCase): self.assertEqual([c.is_running for c in containers], [False, True]) + web = self.create_service('web', **options) self.assertEqual( ('start', containers[0:1]), - web.convergence_plan(), + web.convergence_plan(smart_recreate=True), ) def test_trigger_recreate_with_config_change(self): @@ -229,14 +189,7 @@ class ServiceStateTest(DockerClientTestCase): container = web.create_container() web = self.create_service('web', command=["top", "-d", "1"]) - self.assertEqual(('recreate', [container]), web.convergence_plan()) - - def test_trigger_recreate_with_nonexistent_image_tag(self): - web = self.create_service('web', image="busybox:latest") - container = web.create_container() - - web = self.create_service('web', image="nonexistent-image") - self.assertEqual(('recreate', [container]), web.convergence_plan()) + self.assertEqual(('recreate', [container]), web.convergence_plan(smart_recreate=True)) def test_trigger_recreate_with_image_change(self): repo = 'composetest_myimage' @@ -245,49 +198,66 @@ class ServiceStateTest(DockerClientTestCase): image_id = self.client.images(name='busybox')[0]['Id'] self.client.tag(image_id, repository=repo, tag=tag) - self.addCleanup(self.client.remove_image, image) - web = self.create_service('web', image=image) - container = web.create_container() + try: + web = self.create_service('web', image=image) + container = web.create_container() - # update the image - c = self.client.create_container(image, ['touch', '/hello.txt']) - self.client.commit(c, repository=repo, tag=tag) - self.client.remove_container(c) + # update the image + c = self.client.create_container(image, ['touch', '/hello.txt']) + self.client.commit(c, repository=repo, tag=tag) + self.client.remove_container(c) - web = self.create_service('web', image=image) - self.assertEqual(('recreate', [container]), web.convergence_plan()) + web = self.create_service('web', image=image) + self.assertEqual(('recreate', [container]), web.convergence_plan(smart_recreate=True)) + + finally: + self.client.remove_image(image) def test_trigger_recreate_with_build(self): - context = py.test.ensuretemp('test_trigger_recreate_with_build') - self.addCleanup(context.remove) + context = tempfile.mkdtemp() - base_image = "FROM busybox\nLABEL com.docker.compose.test_image=true\n" - dockerfile = context.join('Dockerfile') - dockerfile.write(base_image) + try: + dockerfile = os.path.join(context, 'Dockerfile') - web = self.create_service('web', build={'context': str(context)}) - container = web.create_container() + with open(dockerfile, 'w') as f: + f.write('FROM busybox\n') - dockerfile.write(base_image + 'CMD echo hello world\n') - web.build() + web = self.create_service('web', build=context) + container = web.create_container() - web = self.create_service('web', build={'context': str(context)}) - self.assertEqual(('recreate', [container]), web.convergence_plan()) + with open(dockerfile, 'w') as f: + f.write('FROM busybox\nCMD echo hello world\n') + web.build() - def test_image_changed_to_build(self): - context = py.test.ensuretemp('test_image_changed_to_build') - self.addCleanup(context.remove) - context.join('Dockerfile').write(""" - FROM busybox - LABEL com.docker.compose.test_image=true - """) + web = self.create_service('web', build=context) + self.assertEqual(('recreate', [container]), web.convergence_plan(smart_recreate=True)) + finally: + shutil.rmtree(context) - web = self.create_service('web', image='busybox') - container = web.create_container() - web = self.create_service('web', build={'context': str(context)}) - plan = web.convergence_plan() - self.assertEqual(('recreate', [container]), plan) - containers = web.execute_convergence_plan(plan) - self.assertEqual(len(containers), 1) +class ConfigHashTest(DockerClientTestCase): + def test_no_config_hash_when_one_off(self): + web = self.create_service('web') + container = web.create_container(one_off=True) + self.assertNotIn(LABEL_CONFIG_HASH, container.labels) + + def test_no_config_hash_when_overriding_options(self): + web = self.create_service('web') + container = web.create_container(environment={'FOO': '1'}) + self.assertNotIn(LABEL_CONFIG_HASH, container.labels) + + def test_config_hash_with_custom_labels(self): + web = self.create_service('web', labels={'foo': '1'}) + container = web.converge()[0] + self.assertIn(LABEL_CONFIG_HASH, container.labels) + self.assertIn('foo', container.labels) + + def test_config_hash_sticks_around(self): + web = self.create_service('web', command=["top"]) + container = web.converge()[0] + self.assertIn(LABEL_CONFIG_HASH, container.labels) + + web = self.create_service('web', command=["top", "-d", "1"]) + container = web.converge()[0] + self.assertIn(LABEL_CONFIG_HASH, container.labels) diff --git a/tests/integration/testcases.py b/tests/integration/testcases.py index efc1551b..48fcf3ef 100644 --- a/tests/integration/testcases.py +++ b/tests/integration/testcases.py @@ -1,108 +1,26 @@ -from __future__ import absolute_import from __future__ import unicode_literals - -import functools -import os - -from docker.utils import version_lt -from pytest import skip - -from .. import unittest -from compose.cli.docker_client import docker_client -from compose.config.config import resolve_environment -from compose.config.config import V1 -from compose.config.config import V2_0 -from compose.config.config import V2_1 -from compose.config.config import V3_0 -from compose.config.environment import Environment -from compose.const import API_VERSIONS -from compose.const import LABEL_PROJECT -from compose.progress_stream import stream_output +from __future__ import absolute_import from compose.service import Service - - -def pull_busybox(client): - client.pull('busybox:latest', stream=False) - - -def get_links(container): - links = container.get('HostConfig.Links') or [] - - def format_link(link): - _, alias = link.split(':') - return alias.split('/')[-1] - - return [format_link(link) for link in links] - - -def engine_max_version(): - if 'DOCKER_VERSION' not in os.environ: - return V3_0 - version = os.environ['DOCKER_VERSION'].partition('-')[0] - if version_lt(version, '1.10'): - return V1 - if version_lt(version, '1.12'): - return V2_0 - if version_lt(version, '1.13'): - return V2_1 - return V3_0 - - -def build_version_required_decorator(ignored_versions): - def decorator(f): - @functools.wraps(f) - def wrapper(self, *args, **kwargs): - max_version = engine_max_version() - if max_version in ignored_versions: - skip("Engine version %s is too low" % max_version) - return - return f(self, *args, **kwargs) - return wrapper - - return decorator - - -def v2_only(): - return build_version_required_decorator((V1,)) - - -def v2_1_only(): - return build_version_required_decorator((V1, V2_0)) - - -def v3_only(): - return build_version_required_decorator((V1, V2_0, V2_1)) +from compose.config import make_service_dict +from compose.cli.docker_client import docker_client +from compose.progress_stream import stream_output +from .. import unittest class DockerClientTestCase(unittest.TestCase): @classmethod def setUpClass(cls): - version = API_VERSIONS[engine_max_version()] - cls.client = docker_client(Environment(), version) + cls.client = docker_client() - @classmethod - def tearDownClass(cls): - del cls.client - - def tearDown(self): - for c in self.client.containers( - all=True, - filters={'label': '%s=composetest' % LABEL_PROJECT}): - self.client.remove_container(c['Id'], force=True) - - for i in self.client.images( - filters={'label': 'com.docker.compose.test_image'}): - self.client.remove_image(i) - - volumes = self.client.volumes().get('Volumes') or [] - for v in volumes: - if 'composetest_' in v['Name']: - self.client.remove_volume(v['Name']) - - networks = self.client.networks() - for n in networks: - if 'composetest_' in n['Name']: - self.client.remove_network(n['Name']) + # TODO: update to use labels in #652 + def setUp(self): + for c in self.client.containers(all=True): + if c['Names'] and 'composetest' in c['Names'][0]: + self.client.kill(c['Id']) + self.client.remove_container(c['Id']) + for i in self.client.images(): + if isinstance(i.get('Tag'), basestring) and 'composetest' in i['Tag']: + self.client.remove_image(i) def create_service(self, name, **kwargs): if 'image' not in kwargs and 'build' not in kwargs: @@ -111,20 +29,12 @@ class DockerClientTestCase(unittest.TestCase): if 'command' not in kwargs: kwargs['command'] = ["top"] - kwargs['environment'] = resolve_environment( - kwargs, Environment.from_env_file(None) + return Service( + project='composetest', + client=self.client, + **make_service_dict(name, kwargs, working_dir='.') ) - labels = dict(kwargs.setdefault('labels', {})) - labels['com.docker.compose.test-name'] = self.id() - - return Service(name, client=self.client, project='composetest', **kwargs) def check_build(self, *args, **kwargs): - kwargs.setdefault('rm', True) build_output = self.client.build(*args, **kwargs) stream_output(build_output, open('/dev/null', 'w')) - - def require_api_version(self, minimum): - api_version = self.client.version()['ApiVersion'] - if version_lt(api_version, minimum): - skip("API version is too low ({} < {})".format(api_version, minimum)) diff --git a/tests/integration/volume_test.py b/tests/integration/volume_test.py deleted file mode 100644 index add16962..00000000 --- a/tests/integration/volume_test.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -from docker.errors import DockerException - -from .testcases import DockerClientTestCase -from compose.const import LABEL_PROJECT -from compose.const import LABEL_VOLUME -from compose.volume import Volume - - -class VolumeTest(DockerClientTestCase): - def setUp(self): - self.tmp_volumes = [] - - def tearDown(self): - for volume in self.tmp_volumes: - try: - self.client.remove_volume(volume.full_name) - except DockerException: - pass - del self.tmp_volumes - super(VolumeTest, self).tearDown() - - def create_volume(self, name, driver=None, opts=None, external=None): - if external and isinstance(external, bool): - external = name - vol = Volume( - self.client, 'composetest', name, driver=driver, driver_opts=opts, - external_name=external - ) - self.tmp_volumes.append(vol) - return vol - - def test_create_volume(self): - vol = self.create_volume('volume01') - vol.create() - info = self.client.inspect_volume(vol.full_name) - assert info['Name'] == vol.full_name - - def test_recreate_existing_volume(self): - vol = self.create_volume('volume01') - - vol.create() - info = self.client.inspect_volume(vol.full_name) - assert info['Name'] == vol.full_name - - vol.create() - info = self.client.inspect_volume(vol.full_name) - assert info['Name'] == vol.full_name - - def test_inspect_volume(self): - vol = self.create_volume('volume01') - vol.create() - info = vol.inspect() - assert info['Name'] == vol.full_name - - def test_remove_volume(self): - vol = Volume(self.client, 'composetest', 'volume01') - vol.create() - vol.remove() - volumes = self.client.volumes()['Volumes'] - assert len([v for v in volumes if v['Name'] == vol.full_name]) == 0 - - def test_external_volume(self): - vol = self.create_volume('composetest_volume_ext', external=True) - assert vol.external is True - assert vol.full_name == vol.name - vol.create() - info = vol.inspect() - assert info['Name'] == vol.name - - def test_external_aliased_volume(self): - alias_name = 'composetest_alias01' - vol = self.create_volume('volume01', external=alias_name) - assert vol.external is True - assert vol.full_name == alias_name - vol.create() - info = vol.inspect() - assert info['Name'] == alias_name - - def test_exists(self): - vol = self.create_volume('volume01') - assert vol.exists() is False - vol.create() - assert vol.exists() is True - - def test_exists_external(self): - vol = self.create_volume('volume01', external=True) - assert vol.exists() is False - vol.create() - assert vol.exists() is True - - def test_exists_external_aliased(self): - vol = self.create_volume('volume01', external='composetest_alias01') - assert vol.exists() is False - vol.create() - assert vol.exists() is True - - def test_volume_default_labels(self): - vol = self.create_volume('volume01') - vol.create() - vol_data = vol.inspect() - labels = vol_data['Labels'] - assert labels[LABEL_VOLUME] == vol.name - assert labels[LABEL_PROJECT] == vol.project diff --git a/tests/unit/bundle_test.py b/tests/unit/bundle_test.py deleted file mode 100644 index 21bdb31b..00000000 --- a/tests/unit/bundle_test.py +++ /dev/null @@ -1,219 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import docker -import mock -import pytest - -from compose import bundle -from compose import service -from compose.cli.errors import UserError -from compose.config.config import Config - - -@pytest.fixture -def mock_service(): - return mock.create_autospec( - service.Service, - client=mock.create_autospec(docker.APIClient), - options={}) - - -def test_get_image_digest_exists(mock_service): - mock_service.options['image'] = 'abcd' - mock_service.image.return_value = {'RepoDigests': ['digest1']} - digest = bundle.get_image_digest(mock_service) - assert digest == 'digest1' - - -def test_get_image_digest_image_uses_digest(mock_service): - mock_service.options['image'] = image_id = 'redis@sha256:digest' - - digest = bundle.get_image_digest(mock_service) - assert digest == image_id - assert not mock_service.image.called - - -def test_get_image_digest_no_image(mock_service): - with pytest.raises(UserError) as exc: - bundle.get_image_digest(service.Service(name='theservice')) - - assert "doesn't define an image tag" in exc.exconly() - - -def test_push_image_with_saved_digest(mock_service): - mock_service.options['build'] = '.' - mock_service.options['image'] = image_id = 'abcd' - mock_service.push.return_value = expected = 'sha256:thedigest' - mock_service.image.return_value = {'RepoDigests': ['digest1']} - - digest = bundle.push_image(mock_service) - assert digest == image_id + '@' + expected - - mock_service.push.assert_called_once_with() - assert not mock_service.client.push.called - - -def test_push_image(mock_service): - mock_service.options['build'] = '.' - mock_service.options['image'] = image_id = 'abcd' - mock_service.push.return_value = expected = 'sha256:thedigest' - mock_service.image.return_value = {'RepoDigests': []} - - digest = bundle.push_image(mock_service) - assert digest == image_id + '@' + expected - - mock_service.push.assert_called_once_with() - mock_service.client.pull.assert_called_once_with(digest) - - -def test_to_bundle(): - image_digests = {'a': 'aaaa', 'b': 'bbbb'} - services = [ - {'name': 'a', 'build': '.', }, - {'name': 'b', 'build': './b'}, - ] - config = Config( - version=2, - services=services, - volumes={'special': {}}, - networks={'extra': {}}, - secrets={}) - - with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: - output = bundle.to_bundle(config, image_digests) - - assert mock_log.mock_calls == [ - mock.call("Unsupported top level key 'networks' - ignoring"), - mock.call("Unsupported top level key 'volumes' - ignoring"), - ] - - assert output == { - 'Version': '0.1', - 'Services': { - 'a': {'Image': 'aaaa', 'Networks': ['default']}, - 'b': {'Image': 'bbbb', 'Networks': ['default']}, - } - } - - -def test_convert_service_to_bundle(): - name = 'theservice' - image_digest = 'thedigest' - service_dict = { - 'ports': ['80'], - 'expose': ['1234'], - 'networks': {'extra': {}}, - 'command': 'foo', - 'entrypoint': 'entry', - 'environment': {'BAZ': 'ENV'}, - 'build': '.', - 'working_dir': '/tmp', - 'user': 'root', - 'labels': {'FOO': 'LABEL'}, - 'privileged': True, - } - - with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: - config = bundle.convert_service_to_bundle(name, service_dict, image_digest) - - mock_log.assert_called_once_with( - "Unsupported key 'privileged' in services.theservice - ignoring") - - assert config == { - 'Image': image_digest, - 'Ports': [ - {'Protocol': 'tcp', 'Port': 80}, - {'Protocol': 'tcp', 'Port': 1234}, - ], - 'Networks': ['extra'], - 'Command': ['entry', 'foo'], - 'Env': ['BAZ=ENV'], - 'WorkingDir': '/tmp', - 'User': 'root', - 'Labels': {'FOO': 'LABEL'}, - } - - -def test_set_command_and_args_none(): - config = {} - bundle.set_command_and_args(config, [], []) - assert config == {} - - -def test_set_command_and_args_from_command(): - config = {} - bundle.set_command_and_args(config, [], "echo ok") - assert config == {'Args': ['echo', 'ok']} - - -def test_set_command_and_args_from_entrypoint(): - config = {} - bundle.set_command_and_args(config, "echo entry", []) - assert config == {'Command': ['echo', 'entry']} - - -def test_set_command_and_args_from_both(): - config = {} - bundle.set_command_and_args(config, "echo entry", ["extra", "arg"]) - assert config == {'Command': ['echo', 'entry', "extra", "arg"]} - - -def test_make_service_networks_default(): - name = 'theservice' - service_dict = {} - - with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: - networks = bundle.make_service_networks(name, service_dict) - - assert not mock_log.called - assert networks == ['default'] - - -def test_make_service_networks(): - name = 'theservice' - service_dict = { - 'networks': { - 'foo': { - 'aliases': ['one', 'two'], - }, - 'bar': {} - }, - } - - with mock.patch('compose.bundle.log.warn', autospec=True) as mock_log: - networks = bundle.make_service_networks(name, service_dict) - - mock_log.assert_called_once_with( - "Unsupported key 'aliases' in services.theservice.networks.foo - ignoring") - assert sorted(networks) == sorted(service_dict['networks']) - - -def test_make_port_specs(): - service_dict = { - 'expose': ['80', '500/udp'], - 'ports': [ - '400:80', - '222', - '127.0.0.1:8001:8001', - '127.0.0.1:5000-5001:3000-3001'], - } - port_specs = bundle.make_port_specs(service_dict) - assert port_specs == [ - {'Protocol': 'tcp', 'Port': 80}, - {'Protocol': 'tcp', 'Port': 222}, - {'Protocol': 'tcp', 'Port': 8001}, - {'Protocol': 'tcp', 'Port': 3000}, - {'Protocol': 'tcp', 'Port': 3001}, - {'Protocol': 'udp', 'Port': 500}, - ] - - -def test_make_port_spec_with_protocol(): - port_spec = bundle.make_port_spec("5000/udp") - assert port_spec == {'Protocol': 'udp', 'Port': 5000} - - -def test_make_port_spec_default_protocol(): - port_spec = bundle.make_port_spec("50000") - assert port_spec == {'Protocol': 'tcp', 'Port': 50000} diff --git a/tests/unit/cli/command_test.py b/tests/unit/cli/command_test.py deleted file mode 100644 index 50fc84e1..00000000 --- a/tests/unit/cli/command_test.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import os -import ssl - -import pytest - -from compose.cli.command import get_config_path_from_options -from compose.cli.command import get_tls_version -from compose.config.environment import Environment -from compose.const import IS_WINDOWS_PLATFORM -from tests import mock - - -class TestGetConfigPathFromOptions(object): - - def test_path_from_options(self): - paths = ['one.yml', 'two.yml'] - opts = {'--file': paths} - environment = Environment.from_env_file('.') - assert get_config_path_from_options('.', opts, environment) == paths - - def test_single_path_from_env(self): - with mock.patch.dict(os.environ): - os.environ['COMPOSE_FILE'] = 'one.yml' - environment = Environment.from_env_file('.') - assert get_config_path_from_options('.', {}, environment) == ['one.yml'] - - @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix separator') - def test_multiple_path_from_env(self): - with mock.patch.dict(os.environ): - os.environ['COMPOSE_FILE'] = 'one.yml:two.yml' - environment = Environment.from_env_file('.') - assert get_config_path_from_options( - '.', {}, environment - ) == ['one.yml', 'two.yml'] - - @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='windows separator') - def test_multiple_path_from_env_windows(self): - with mock.patch.dict(os.environ): - os.environ['COMPOSE_FILE'] = 'one.yml;two.yml' - environment = Environment.from_env_file('.') - assert get_config_path_from_options( - '.', {}, environment - ) == ['one.yml', 'two.yml'] - - def test_no_path(self): - environment = Environment.from_env_file('.') - assert not get_config_path_from_options('.', {}, environment) - - -class TestGetTlsVersion(object): - def test_get_tls_version_default(self): - environment = {} - assert get_tls_version(environment) is None - - @pytest.mark.skipif(not hasattr(ssl, 'PROTOCOL_TLSv1_2'), reason='TLS v1.2 unsupported') - def test_get_tls_version_upgrade(self): - environment = {'COMPOSE_TLS_VERSION': 'TLSv1_2'} - assert get_tls_version(environment) == ssl.PROTOCOL_TLSv1_2 - - def test_get_tls_version_unavailable(self): - environment = {'COMPOSE_TLS_VERSION': 'TLSv5_5'} - with mock.patch('compose.cli.command.log') as mock_log: - tls_version = get_tls_version(environment) - mock_log.warn.assert_called_once_with(mock.ANY) - assert tls_version is None diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index aaa935af..44bdbb29 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -1,159 +1,22 @@ -from __future__ import absolute_import from __future__ import unicode_literals - +from __future__ import absolute_import import os -import platform -import docker -import pytest - -import compose -from compose.cli import errors -from compose.cli.docker_client import docker_client -from compose.cli.docker_client import tls_config_from_options -from tests import mock +import mock from tests import unittest +from compose.cli import docker_client + class DockerClientTestCase(unittest.TestCase): def test_docker_client_no_home(self): with mock.patch.dict(os.environ): del os.environ['HOME'] - docker_client(os.environ) + docker_client.docker_client() - @mock.patch.dict(os.environ) def test_docker_client_with_custom_timeout(self): - os.environ['COMPOSE_HTTP_TIMEOUT'] = '123' - client = docker_client(os.environ) - assert client.timeout == 123 - - @mock.patch.dict(os.environ) - def test_custom_timeout_error(self): - os.environ['COMPOSE_HTTP_TIMEOUT'] = '123' - client = docker_client(os.environ) - - with mock.patch('compose.cli.errors.log') as fake_log: - with pytest.raises(errors.ConnectionError): - with errors.handle_connection_errors(client): - raise errors.RequestsConnectionError( - errors.ReadTimeoutError(None, None, None)) - - assert fake_log.error.call_count == 1 - assert '123' in fake_log.error.call_args[0][0] - - with mock.patch('compose.cli.errors.log') as fake_log: - with pytest.raises(errors.ConnectionError): - with errors.handle_connection_errors(client): - raise errors.ReadTimeout() - - assert fake_log.error.call_count == 1 - assert '123' in fake_log.error.call_args[0][0] - - def test_user_agent(self): - client = docker_client(os.environ) - expected = "docker-compose/{0} docker-py/{1} {2}/{3}".format( - compose.__version__, - docker.__version__, - platform.system(), - platform.release() - ) - self.assertEqual(client.headers['User-Agent'], expected) - - -class TLSConfigTestCase(unittest.TestCase): - ca_cert = 'tests/fixtures/tls/ca.pem' - client_cert = 'tests/fixtures/tls/cert.pem' - key = 'tests/fixtures/tls/key.key' - - def test_simple_tls(self): - options = {'--tls': True} - result = tls_config_from_options(options) - assert result is True - - def test_tls_ca_cert(self): - options = { - '--tlscacert': self.ca_cert, '--tlsverify': True - } - result = tls_config_from_options(options) - assert isinstance(result, docker.tls.TLSConfig) - assert result.ca_cert == options['--tlscacert'] - assert result.verify is True - - def test_tls_ca_cert_explicit(self): - options = { - '--tlscacert': self.ca_cert, '--tls': True, - '--tlsverify': True - } - result = tls_config_from_options(options) - assert isinstance(result, docker.tls.TLSConfig) - assert result.ca_cert == options['--tlscacert'] - assert result.verify is True - - def test_tls_client_cert(self): - options = { - '--tlscert': self.client_cert, '--tlskey': self.key - } - result = tls_config_from_options(options) - assert isinstance(result, docker.tls.TLSConfig) - assert result.cert == (options['--tlscert'], options['--tlskey']) - - def test_tls_client_cert_explicit(self): - options = { - '--tlscert': self.client_cert, '--tlskey': self.key, - '--tls': True - } - result = tls_config_from_options(options) - assert isinstance(result, docker.tls.TLSConfig) - assert result.cert == (options['--tlscert'], options['--tlskey']) - - def test_tls_client_and_ca(self): - options = { - '--tlscert': self.client_cert, '--tlskey': self.key, - '--tlsverify': True, '--tlscacert': self.ca_cert - } - result = tls_config_from_options(options) - assert isinstance(result, docker.tls.TLSConfig) - assert result.cert == (options['--tlscert'], options['--tlskey']) - assert result.ca_cert == options['--tlscacert'] - assert result.verify is True - - def test_tls_client_and_ca_explicit(self): - options = { - '--tlscert': self.client_cert, '--tlskey': self.key, - '--tlsverify': True, '--tlscacert': self.ca_cert, - '--tls': True - } - result = tls_config_from_options(options) - assert isinstance(result, docker.tls.TLSConfig) - assert result.cert == (options['--tlscert'], options['--tlskey']) - assert result.ca_cert == options['--tlscacert'] - assert result.verify is True - - def test_tls_client_missing_key(self): - options = {'--tlscert': self.client_cert} - with pytest.raises(docker.errors.TLSParameterError): - tls_config_from_options(options) - - options = {'--tlskey': self.key} - with pytest.raises(docker.errors.TLSParameterError): - tls_config_from_options(options) - - def test_assert_hostname_explicit_skip(self): - options = {'--tlscacert': self.ca_cert, '--skip-hostname-check': True} - result = tls_config_from_options(options) - assert isinstance(result, docker.tls.TLSConfig) - assert result.assert_hostname is False - - def test_tls_client_and_ca_quoted_paths(self): - options = { - '--tlscacert': '"{0}"'.format(self.ca_cert), - '--tlscert': '"{0}"'.format(self.client_cert), - '--tlskey': '"{0}"'.format(self.key), - '--tlsverify': True - } - result = tls_config_from_options(options) - assert isinstance(result, docker.tls.TLSConfig) - assert result.cert == (self.client_cert, self.key) - assert result.ca_cert == self.ca_cert - assert result.verify is True + with mock.patch.dict(os.environ): + os.environ['DOCKER_CLIENT_TIMEOUT'] = timeout = "300" + client = docker_client.docker_client() + self.assertEqual(client.timeout, int(timeout)) diff --git a/tests/unit/cli/errors_test.py b/tests/unit/cli/errors_test.py deleted file mode 100644 index a7b57562..00000000 --- a/tests/unit/cli/errors_test.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import pytest -from docker.errors import APIError -from requests.exceptions import ConnectionError - -from compose.cli import errors -from compose.cli.errors import handle_connection_errors -from tests import mock - - -@pytest.yield_fixture -def mock_logging(): - with mock.patch('compose.cli.errors.log', autospec=True) as mock_log: - yield mock_log - - -def patch_find_executable(side_effect): - return mock.patch( - 'compose.cli.errors.find_executable', - autospec=True, - side_effect=side_effect) - - -class TestHandleConnectionErrors(object): - - def test_generic_connection_error(self, mock_logging): - with pytest.raises(errors.ConnectionError): - with patch_find_executable(['/bin/docker', None]): - with handle_connection_errors(mock.Mock()): - raise ConnectionError() - - _, args, _ = mock_logging.error.mock_calls[0] - assert "Couldn't connect to Docker daemon" in args[0] - - def test_api_error_version_mismatch(self, mock_logging): - with pytest.raises(errors.ConnectionError): - with handle_connection_errors(mock.Mock(api_version='1.22')): - raise APIError(None, None, b"client is newer than server") - - _, args, _ = mock_logging.error.mock_calls[0] - assert "Docker Engine of version 1.10.0 or greater" in args[0] - - def test_api_error_version_other(self, mock_logging): - msg = b"Something broke!" - with pytest.raises(errors.ConnectionError): - with handle_connection_errors(mock.Mock(api_version='1.22')): - raise APIError(None, None, msg) - - mock_logging.error.assert_called_once_with(msg) diff --git a/tests/unit/cli/formatter_test.py b/tests/unit/cli/formatter_test.py deleted file mode 100644 index 4aa025e6..00000000 --- a/tests/unit/cli/formatter_test.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import logging - -from compose.cli import colors -from compose.cli.formatter import ConsoleWarningFormatter -from tests import unittest - - -MESSAGE = 'this is the message' - - -def make_log_record(level, message=None): - return logging.LogRecord('name', level, 'pathame', 0, message or MESSAGE, (), None) - - -class ConsoleWarningFormatterTestCase(unittest.TestCase): - - def setUp(self): - self.formatter = ConsoleWarningFormatter() - - def test_format_warn(self): - output = self.formatter.format(make_log_record(logging.WARN)) - expected = colors.yellow('WARNING') + ': ' - assert output == expected + MESSAGE - - def test_format_error(self): - output = self.formatter.format(make_log_record(logging.ERROR)) - expected = colors.red('ERROR') + ': ' - assert output == expected + MESSAGE - - def test_format_info(self): - output = self.formatter.format(make_log_record(logging.INFO)) - assert output == MESSAGE - - def test_format_unicode_info(self): - message = b'\xec\xa0\x95\xec\x88\x98\xec\xa0\x95' - output = self.formatter.format(make_log_record(logging.INFO, message)) - print(output) - assert output == message.decode('utf-8') - - def test_format_unicode_warn(self): - message = b'\xec\xa0\x95\xec\x88\x98\xec\xa0\x95' - output = self.formatter.format(make_log_record(logging.WARN, message)) - expected = colors.yellow('WARNING') + ': ' - assert output == '{0}{1}'.format(expected, message.decode('utf-8')) - - def test_format_unicode_error(self): - message = b'\xec\xa0\x95\xec\x88\x98\xec\xa0\x95' - output = self.formatter.format(make_log_record(logging.ERROR, message)) - expected = colors.red('ERROR') + ': ' - assert output == '{0}{1}'.format(expected, message.decode('utf-8')) diff --git a/tests/unit/cli/log_printer_test.py b/tests/unit/cli/log_printer_test.py deleted file mode 100644 index b908eb68..00000000 --- a/tests/unit/cli/log_printer_test.py +++ /dev/null @@ -1,199 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import itertools - -import pytest -import requests -import six -from docker.errors import APIError -from six.moves.queue import Queue - -from compose.cli.log_printer import build_log_generator -from compose.cli.log_printer import build_log_presenters -from compose.cli.log_printer import build_no_log_generator -from compose.cli.log_printer import consume_queue -from compose.cli.log_printer import QueueItem -from compose.cli.log_printer import wait_on_exit -from compose.cli.log_printer import watch_events -from compose.container import Container -from tests import mock - - -@pytest.fixture -def output_stream(): - output = six.StringIO() - output.flush = mock.Mock() - return output - - -@pytest.fixture -def mock_container(): - return mock.Mock(spec=Container, name_without_project='web_1') - - -class TestLogPresenter(object): - - def test_monochrome(self, mock_container): - presenters = build_log_presenters(['foo', 'bar'], True) - presenter = next(presenters) - actual = presenter.present(mock_container, "this line") - assert actual == "web_1 | this line" - - def test_polychrome(self, mock_container): - presenters = build_log_presenters(['foo', 'bar'], False) - presenter = next(presenters) - actual = presenter.present(mock_container, "this line") - assert '\033[' in actual - - -def test_wait_on_exit(): - exit_status = 3 - mock_container = mock.Mock( - spec=Container, - name='cname', - wait=mock.Mock(return_value=exit_status)) - - expected = '{} exited with code {}\n'.format(mock_container.name, exit_status) - assert expected == wait_on_exit(mock_container) - - -def test_wait_on_exit_raises(): - status_code = 500 - - def mock_wait(): - resp = requests.Response() - resp.status_code = status_code - raise APIError('Bad server', resp) - - mock_container = mock.Mock( - spec=Container, - name='cname', - wait=mock_wait - ) - - expected = 'Unexpected API error for {} (HTTP code {})\n'.format( - mock_container.name, status_code, - ) - assert expected in wait_on_exit(mock_container) - - -def test_build_no_log_generator(mock_container): - mock_container.has_api_logs = False - mock_container.log_driver = 'none' - output, = build_no_log_generator(mock_container, None) - assert "WARNING: no logs are available with the 'none' log driver\n" in output - assert "exited with code" not in output - - -class TestBuildLogGenerator(object): - - def test_no_log_stream(self, mock_container): - mock_container.log_stream = None - mock_container.logs.return_value = iter([b"hello\nworld"]) - log_args = {'follow': True} - - generator = build_log_generator(mock_container, log_args) - assert next(generator) == "hello\n" - assert next(generator) == "world" - mock_container.logs.assert_called_once_with( - stdout=True, - stderr=True, - stream=True, - **log_args) - - def test_with_log_stream(self, mock_container): - mock_container.log_stream = iter([b"hello\nworld"]) - log_args = {'follow': True} - - generator = build_log_generator(mock_container, log_args) - assert next(generator) == "hello\n" - assert next(generator) == "world" - - def test_unicode(self, output_stream): - glyph = u'\u2022\n' - mock_container.log_stream = iter([glyph.encode('utf-8')]) - - generator = build_log_generator(mock_container, {}) - assert next(generator) == glyph - - -@pytest.fixture -def thread_map(): - return {'cid': mock.Mock()} - - -@pytest.fixture -def mock_presenters(): - return itertools.cycle([mock.Mock()]) - - -class TestWatchEvents(object): - - def test_stop_event(self, thread_map, mock_presenters): - event_stream = [{'action': 'stop', 'id': 'cid'}] - watch_events(thread_map, event_stream, mock_presenters, ()) - assert not thread_map - - def test_start_event(self, thread_map, mock_presenters): - container_id = 'abcd' - event = {'action': 'start', 'id': container_id, 'container': mock.Mock()} - event_stream = [event] - thread_args = 'foo', 'bar' - - with mock.patch( - 'compose.cli.log_printer.build_thread', - autospec=True - ) as mock_build_thread: - watch_events(thread_map, event_stream, mock_presenters, thread_args) - mock_build_thread.assert_called_once_with( - event['container'], - next(mock_presenters), - *thread_args) - assert container_id in thread_map - - def test_other_event(self, thread_map, mock_presenters): - container_id = 'abcd' - event_stream = [{'action': 'create', 'id': container_id}] - watch_events(thread_map, event_stream, mock_presenters, ()) - assert container_id not in thread_map - - -class TestConsumeQueue(object): - - def test_item_is_an_exception(self): - - class Problem(Exception): - pass - - queue = Queue() - error = Problem('oops') - for item in QueueItem.new('a'), QueueItem.new('b'), QueueItem.exception(error): - queue.put(item) - - generator = consume_queue(queue, False) - assert next(generator) == 'a' - assert next(generator) == 'b' - with pytest.raises(Problem): - next(generator) - - def test_item_is_stop_without_cascade_stop(self): - queue = Queue() - for item in QueueItem.stop(), QueueItem.new('a'), QueueItem.new('b'): - queue.put(item) - - generator = consume_queue(queue, False) - assert next(generator) == 'a' - assert next(generator) == 'b' - - def test_item_is_stop_with_cascade_stop(self): - queue = Queue() - for item in QueueItem.stop(), QueueItem.new('a'), QueueItem.new('b'): - queue.put(item) - - assert list(consume_queue(queue, True)) == [] - - def test_item_is_none_when_timeout_is_hit(self): - queue = Queue() - generator = consume_queue(queue, False) - assert next(generator) is None diff --git a/tests/unit/cli/main_test.py b/tests/unit/cli/main_test.py deleted file mode 100644 index dc527880..00000000 --- a/tests/unit/cli/main_test.py +++ /dev/null @@ -1,104 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import logging - -import pytest - -from compose import container -from compose.cli.errors import UserError -from compose.cli.formatter import ConsoleWarningFormatter -from compose.cli.main import convergence_strategy_from_opts -from compose.cli.main import filter_containers_to_service_names -from compose.cli.main import setup_console_handler -from compose.service import ConvergenceStrategy -from tests import mock - - -def mock_container(service, number): - return mock.create_autospec( - container.Container, - service=service, - number=number, - name_without_project='{0}_{1}'.format(service, number)) - - -@pytest.fixture -def logging_handler(): - stream = mock.Mock() - stream.isatty.return_value = True - return logging.StreamHandler(stream=stream) - - -class TestCLIMainTestCase(object): - - def test_filter_containers_to_service_names(self): - containers = [ - mock_container('web', 1), - mock_container('web', 2), - mock_container('db', 1), - mock_container('other', 1), - mock_container('another', 1), - ] - service_names = ['web', 'db'] - actual = filter_containers_to_service_names(containers, service_names) - assert actual == containers[:3] - - def test_filter_containers_to_service_names_all(self): - containers = [ - mock_container('web', 1), - mock_container('db', 1), - mock_container('other', 1), - ] - service_names = [] - actual = filter_containers_to_service_names(containers, service_names) - assert actual == containers - - -class TestSetupConsoleHandlerTestCase(object): - - def test_with_tty_verbose(self, logging_handler): - setup_console_handler(logging_handler, True) - assert type(logging_handler.formatter) == ConsoleWarningFormatter - assert '%(name)s' in logging_handler.formatter._fmt - assert '%(funcName)s' in logging_handler.formatter._fmt - - def test_with_tty_not_verbose(self, logging_handler): - setup_console_handler(logging_handler, False) - assert type(logging_handler.formatter) == ConsoleWarningFormatter - assert '%(name)s' not in logging_handler.formatter._fmt - assert '%(funcName)s' not in logging_handler.formatter._fmt - - def test_with_not_a_tty(self, logging_handler): - logging_handler.stream.isatty.return_value = False - setup_console_handler(logging_handler, False) - assert type(logging_handler.formatter) == logging.Formatter - - -class TestConvergeStrategyFromOptsTestCase(object): - - def test_invalid_opts(self): - options = {'--force-recreate': True, '--no-recreate': True} - with pytest.raises(UserError): - convergence_strategy_from_opts(options) - - def test_always(self): - options = {'--force-recreate': True, '--no-recreate': False} - assert ( - convergence_strategy_from_opts(options) == - ConvergenceStrategy.always - ) - - def test_never(self): - options = {'--force-recreate': False, '--no-recreate': True} - assert ( - convergence_strategy_from_opts(options) == - ConvergenceStrategy.never - ) - - def test_changed(self): - options = {'--force-recreate': False, '--no-recreate': False} - assert ( - convergence_strategy_from_opts(options) == - ConvergenceStrategy.changed - ) diff --git a/tests/unit/cli/utils_test.py b/tests/unit/cli/utils_test.py deleted file mode 100644 index 066fb359..00000000 --- a/tests/unit/cli/utils_test.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import unittest - -from compose.cli.utils import unquote_path - - -class UnquotePathTest(unittest.TestCase): - def test_no_quotes(self): - assert unquote_path('hello') == 'hello' - - def test_simple_quotes(self): - assert unquote_path('"hello"') == 'hello' - - def test_uneven_quotes(self): - assert unquote_path('"hello') == '"hello' - assert unquote_path('hello"') == 'hello"' - - def test_nested_quotes(self): - assert unquote_path('""hello""') == '"hello"' - assert unquote_path('"hel"lo"') == 'hel"lo' - assert unquote_path('"hello""') == 'hello"' diff --git a/tests/unit/cli/verbose_proxy_test.py b/tests/unit/cli/verbose_proxy_test.py index f77568dc..59417bb3 100644 --- a/tests/unit/cli/verbose_proxy_test.py +++ b/tests/unit/cli/verbose_proxy_test.py @@ -1,17 +1,14 @@ -from __future__ import absolute_import from __future__ import unicode_literals - -import six +from __future__ import absolute_import +from tests import unittest from compose.cli import verbose_proxy -from tests import unittest class VerboseProxyTestCase(unittest.TestCase): def test_format_call(self): - prefix = '' if six.PY3 else 'u' - expected = "(%(p)s'arg1', True, key=%(p)s'value')" % dict(p=prefix) + expected = "(u'arg1', True, key=u'value')" actual = verbose_proxy.format_call( ("arg1", True), {'key': 'value'}) @@ -24,7 +21,7 @@ class VerboseProxyTestCase(unittest.TestCase): self.assertEqual(expected, actual) def test_format_return(self): - expected = repr({'Id': 'ok'}) + expected = "{u'Id': u'ok'}" actual = verbose_proxy.format_return({'Id': 'ok'}, 2) self.assertEqual(expected, actual) diff --git a/tests/unit/cli_test.py b/tests/unit/cli_test.py index 317650cb..3173a274 100644 --- a/tests/unit/cli_test.py +++ b/tests/unit/cli_test.py @@ -1,211 +1,206 @@ -# encoding: utf-8 -from __future__ import absolute_import from __future__ import unicode_literals - +from __future__ import absolute_import +import logging import os -import shutil import tempfile -from io import StringIO +import shutil +from .. import unittest import docker -import py -import pytest +import mock -from .. import mock -from .. import unittest -from ..helpers import build_config -from compose.cli.command import get_project -from compose.cli.command import get_project_name -from compose.cli.docopt_command import NoSuchCommand -from compose.cli.errors import UserError +from compose.cli import main from compose.cli.main import TopLevelCommand -from compose.const import IS_WINDOWS_PLATFORM -from compose.project import Project +from compose.cli.errors import ComposeFileNotFound +from compose.service import Service class CLITestCase(unittest.TestCase): - def test_default_project_name(self): - test_dir = py._path.local.LocalPath('tests/fixtures/simple-composefile') - with test_dir.as_cwd(): - project_name = get_project_name('.') + cwd = os.getcwd() + + try: + os.chdir('tests/fixtures/simple-composefile') + command = TopLevelCommand() + project_name = command.get_project_name(command.get_config_path()) self.assertEquals('simplecomposefile', project_name) + finally: + os.chdir(cwd) def test_project_name_with_explicit_base_dir(self): - base_dir = 'tests/fixtures/simple-composefile' - project_name = get_project_name(base_dir) + command = TopLevelCommand() + command.base_dir = 'tests/fixtures/simple-composefile' + project_name = command.get_project_name(command.get_config_path()) self.assertEquals('simplecomposefile', project_name) def test_project_name_with_explicit_uppercase_base_dir(self): - base_dir = 'tests/fixtures/UpperCaseDir' - project_name = get_project_name(base_dir) + command = TopLevelCommand() + command.base_dir = 'tests/fixtures/UpperCaseDir' + project_name = command.get_project_name(command.get_config_path()) self.assertEquals('uppercasedir', project_name) def test_project_name_with_explicit_project_name(self): + command = TopLevelCommand() name = 'explicit-project-name' - project_name = get_project_name(None, project_name=name) + project_name = command.get_project_name(None, project_name=name) self.assertEquals('explicitprojectname', project_name) - @mock.patch.dict(os.environ) - def test_project_name_from_environment_new_var(self): + def test_project_name_from_environment_old_var(self): + command = TopLevelCommand() name = 'namefromenv' - os.environ['COMPOSE_PROJECT_NAME'] = name - project_name = get_project_name(None) + with mock.patch.dict(os.environ): + os.environ['FIG_PROJECT_NAME'] = name + project_name = command.get_project_name(None) self.assertEquals(project_name, name) - def test_project_name_with_empty_environment_var(self): - base_dir = 'tests/fixtures/simple-composefile' + def test_project_name_from_environment_new_var(self): + command = TopLevelCommand() + name = 'namefromenv' with mock.patch.dict(os.environ): - os.environ['COMPOSE_PROJECT_NAME'] = '' - project_name = get_project_name(base_dir) - self.assertEquals('simplecomposefile', project_name) + os.environ['COMPOSE_PROJECT_NAME'] = name + project_name = command.get_project_name(None) + self.assertEquals(project_name, name) - @mock.patch.dict(os.environ) - def test_project_name_with_environment_file(self): - base_dir = tempfile.mkdtemp() - try: - name = 'namefromenvfile' - with open(os.path.join(base_dir, '.env'), 'w') as f: - f.write('COMPOSE_PROJECT_NAME={}'.format(name)) - project_name = get_project_name(base_dir) - assert project_name == name + def test_filename_check(self): + files = [ + 'docker-compose.yml', + 'docker-compose.yaml', + 'fig.yml', + 'fig.yaml', + ] - # Environment has priority over .env file - os.environ['COMPOSE_PROJECT_NAME'] = 'namefromenv' - assert get_project_name(base_dir) == os.environ['COMPOSE_PROJECT_NAME'] - finally: - shutil.rmtree(base_dir) + """Test with files placed in the basedir""" + + self.assertEqual('docker-compose.yml', get_config_filename_for_files(files[0:])) + self.assertEqual('docker-compose.yaml', get_config_filename_for_files(files[1:])) + self.assertEqual('fig.yml', get_config_filename_for_files(files[2:])) + self.assertEqual('fig.yaml', get_config_filename_for_files(files[3:])) + self.assertRaises(ComposeFileNotFound, lambda: get_config_filename_for_files([])) + + """Test with files placed in the subdir""" + + def get_config_filename_for_files_in_subdir(files): + return get_config_filename_for_files(files, subdir=True) + + self.assertEqual('docker-compose.yml', get_config_filename_for_files_in_subdir(files[0:])) + self.assertEqual('docker-compose.yaml', get_config_filename_for_files_in_subdir(files[1:])) + self.assertEqual('fig.yml', get_config_filename_for_files_in_subdir(files[2:])) + self.assertEqual('fig.yaml', get_config_filename_for_files_in_subdir(files[3:])) + self.assertRaises(ComposeFileNotFound, lambda: get_config_filename_for_files_in_subdir([])) def test_get_project(self): - base_dir = 'tests/fixtures/longer-filename-composefile' - project = get_project(base_dir) + command = TopLevelCommand() + command.base_dir = 'tests/fixtures/longer-filename-composefile' + project = command.get_project(command.get_config_path()) self.assertEqual(project.name, 'longerfilenamecomposefile') self.assertTrue(project.client) self.assertTrue(project.services) - def test_command_help(self): - with mock.patch('sys.stdout', new=StringIO()) as fake_stdout: - TopLevelCommand.help({'COMMAND': 'up'}) + def test_help(self): + command = TopLevelCommand() + with self.assertRaises(SystemExit): + command.dispatch(['-h'], None) - assert "Usage: up" in fake_stdout.getvalue() + def test_setup_logging(self): + main.setup_logging() + self.assertEqual(logging.getLogger().level, logging.DEBUG) + self.assertEqual(logging.getLogger('requests').propagate, False) - def test_command_help_nonexistent(self): - with pytest.raises(NoSuchCommand): - TopLevelCommand.help({'COMMAND': 'nonexistent'}) - - @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason="requires dockerpty") - @mock.patch('compose.cli.main.RunOperation', autospec=True) - @mock.patch('compose.cli.main.PseudoTerminal', autospec=True) - def test_run_interactive_passes_logs_false(self, mock_pseudo_terminal, mock_run_operation): - mock_client = mock.create_autospec(docker.APIClient) - project = Project.from_config( - name='composetest', + @mock.patch('compose.cli.main.dockerpty', autospec=True) + def test_run_with_environment_merged_with_options_list(self, mock_dockerpty): + command = TopLevelCommand() + mock_client = mock.create_autospec(docker.Client) + mock_project = mock.Mock() + mock_project.get_service.return_value = Service( + 'service', client=mock_client, - config_data=build_config({ - 'service': {'image': 'busybox'} - }), - ) - command = TopLevelCommand(project) + environment=['FOO=ONE', 'BAR=TWO'], + image='someimage') - with pytest.raises(SystemExit): - command.run({ - 'SERVICE': 'service', - 'COMMAND': None, - '-e': [], - '--user': None, - '--no-deps': None, - '-d': False, - '-T': None, - '--entrypoint': None, - '--service-ports': None, - '--publish': [], - '--rm': None, - '--name': None, - '--workdir': None, - }) + command.run(mock_project, { + 'SERVICE': 'service', + 'COMMAND': None, + '-e': ['BAR=NEW', 'OTHER=THREE'], + '--user': None, + '--no-deps': None, + '--allow-insecure-ssl': None, + '-d': True, + '-T': None, + '--entrypoint': None, + '--service-ports': None, + '--rm': None, + }) - _, _, call_kwargs = mock_run_operation.mock_calls[0] - assert call_kwargs['logs'] is False + _, _, call_kwargs = mock_client.create_container.mock_calls[0] + self.assertEqual( + call_kwargs['environment'], + {'FOO': 'ONE', 'BAR': 'NEW', 'OTHER': 'THREE'}) def test_run_service_with_restart_always(self): - mock_client = mock.create_autospec(docker.APIClient) - - project = Project.from_config( - name='composetest', + command = TopLevelCommand() + mock_client = mock.create_autospec(docker.Client) + mock_project = mock.Mock() + mock_project.get_service.return_value = Service( + 'service', client=mock_client, - config_data=build_config({ - 'service': { - 'image': 'busybox', - 'restart': 'always', - } - }), - ) - - command = TopLevelCommand(project) - command.run({ + restart='always', + image='someimage') + command.run(mock_project, { 'SERVICE': 'service', 'COMMAND': None, '-e': [], '--user': None, '--no-deps': None, + '--allow-insecure-ssl': None, '-d': True, '-T': None, '--entrypoint': None, '--service-ports': None, - '--publish': [], '--rm': None, - '--name': None, - '--workdir': None, }) + _, _, call_kwargs = mock_client.create_container.mock_calls[0] + self.assertEquals(call_kwargs['host_config']['RestartPolicy']['Name'], 'always') - self.assertEquals( - mock_client.create_host_config.call_args[1]['restart_policy']['Name'], - 'always' - ) - - command = TopLevelCommand(project) - command.run({ + command = TopLevelCommand() + mock_client = mock.create_autospec(docker.Client) + mock_project = mock.Mock() + mock_project.get_service.return_value = Service( + 'service', + client=mock_client, + restart='always', + image='someimage') + command.run(mock_project, { 'SERVICE': 'service', 'COMMAND': None, '-e': [], '--user': None, '--no-deps': None, + '--allow-insecure-ssl': None, '-d': True, '-T': None, '--entrypoint': None, '--service-ports': None, - '--publish': [], '--rm': True, - '--name': None, - '--workdir': None, }) + _, _, call_kwargs = mock_client.create_container.mock_calls[0] + self.assertFalse('RestartPolicy' in call_kwargs['host_config']) - self.assertFalse( - mock_client.create_host_config.call_args[1].get('restart_policy') - ) - def test_command_manual_and_service_ports_together(self): - project = Project.from_config( - name='composetest', - client=None, - config_data=build_config({ - 'service': {'image': 'busybox'}, - }), - ) - command = TopLevelCommand(project) +def get_config_filename_for_files(filenames, subdir=None): + project_dir = tempfile.mkdtemp() + try: + make_files(project_dir, filenames) + command = TopLevelCommand() + if subdir: + command.base_dir = tempfile.mkdtemp(dir=project_dir) + else: + command.base_dir = project_dir + return os.path.basename(command.get_config_path()) + finally: + shutil.rmtree(project_dir) - with self.assertRaises(UserError): - command.run({ - 'SERVICE': 'service', - 'COMMAND': None, - '-e': [], - '--user': None, - '--no-deps': None, - '-d': True, - '-T': None, - '--entrypoint': None, - '--service-ports': True, - '--publish': ['80:80'], - '--rm': None, - '--name': None, - }) + +def make_files(dirname, filenames): + for fname in filenames: + with open(os.path.join(dirname, fname), 'w') as f: + f.write('') diff --git a/tests/unit/config/__init__.py b/tests/unit/config/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py deleted file mode 100644 index c26272d9..00000000 --- a/tests/unit/config/config_test.py +++ /dev/null @@ -1,3550 +0,0 @@ -# encoding: utf-8 -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - -import os -import shutil -import tempfile -from operator import itemgetter - -import py -import pytest - -from ...helpers import build_config_details -from compose.config import config -from compose.config import types -from compose.config.config import resolve_build_args -from compose.config.config import resolve_environment -from compose.config.config import V1 -from compose.config.config import V2_0 -from compose.config.config import V2_1 -from compose.config.config import V3_0 -from compose.config.config import V3_1 -from compose.config.environment import Environment -from compose.config.errors import ConfigurationError -from compose.config.errors import VERSION_EXPLANATION -from compose.config.serialize import denormalize_service_dict -from compose.config.serialize import serialize_ns_time_value -from compose.config.types import VolumeSpec -from compose.const import IS_WINDOWS_PLATFORM -from compose.utils import nanoseconds_from_time_seconds -from tests import mock -from tests import unittest - -DEFAULT_VERSION = V2_0 - - -def make_service_dict(name, service_dict, working_dir, filename=None): - """Test helper function to construct a ServiceExtendsResolver - """ - resolver = config.ServiceExtendsResolver( - config.ServiceConfig( - working_dir=working_dir, - filename=filename, - name=name, - config=service_dict), - config.ConfigFile(filename=filename, config={}), - environment=Environment.from_env_file(working_dir) - ) - return config.process_service(resolver.run()) - - -def service_sort(services): - return sorted(services, key=itemgetter('name')) - - -def secret_sort(secrets): - return sorted(secrets, key=itemgetter('source')) - - -class ConfigTest(unittest.TestCase): - def test_load(self): - service_dicts = config.load( - build_config_details( - { - 'foo': {'image': 'busybox'}, - 'bar': {'image': 'busybox', 'environment': ['FOO=1']}, - }, - 'tests/fixtures/extends', - 'common.yml' - ) - ).services - - self.assertEqual( - service_sort(service_dicts), - service_sort([ - { - 'name': 'bar', - 'image': 'busybox', - 'environment': {'FOO': '1'}, - }, - { - 'name': 'foo', - 'image': 'busybox', - } - ]) - ) - - def test_load_v2(self): - config_data = config.load( - build_config_details({ - 'version': '2', - 'services': { - 'foo': {'image': 'busybox'}, - 'bar': {'image': 'busybox', 'environment': ['FOO=1']}, - }, - 'volumes': { - 'hello': { - 'driver': 'default', - 'driver_opts': {'beep': 'boop'} - } - }, - 'networks': { - 'default': { - 'driver': 'bridge', - 'driver_opts': {'beep': 'boop'} - }, - 'with_ipam': { - 'ipam': { - 'driver': 'default', - 'config': [ - {'subnet': '172.28.0.0/16'} - ] - } - }, - 'internal': { - 'driver': 'bridge', - 'internal': True - } - } - }, 'working_dir', 'filename.yml') - ) - service_dicts = config_data.services - volume_dict = config_data.volumes - networks_dict = config_data.networks - self.assertEqual( - service_sort(service_dicts), - service_sort([ - { - 'name': 'bar', - 'image': 'busybox', - 'environment': {'FOO': '1'}, - }, - { - 'name': 'foo', - 'image': 'busybox', - } - ]) - ) - self.assertEqual(volume_dict, { - 'hello': { - 'driver': 'default', - 'driver_opts': {'beep': 'boop'} - } - }) - self.assertEqual(networks_dict, { - 'default': { - 'driver': 'bridge', - 'driver_opts': {'beep': 'boop'} - }, - 'with_ipam': { - 'ipam': { - 'driver': 'default', - 'config': [ - {'subnet': '172.28.0.0/16'} - ] - } - }, - 'internal': { - 'driver': 'bridge', - 'internal': True - } - }) - - def test_valid_versions(self): - for version in ['2', '2.0']: - cfg = config.load(build_config_details({'version': version})) - assert cfg.version == V2_0 - - cfg = config.load(build_config_details({'version': '2.1'})) - assert cfg.version == V2_1 - - for version in ['3', '3.0']: - cfg = config.load(build_config_details({'version': version})) - assert cfg.version == V3_0 - - cfg = config.load(build_config_details({'version': '3.1'})) - assert cfg.version == V3_1 - - def test_v1_file_version(self): - cfg = config.load(build_config_details({'web': {'image': 'busybox'}})) - assert cfg.version == V1 - assert list(s['name'] for s in cfg.services) == ['web'] - - cfg = config.load(build_config_details({'version': {'image': 'busybox'}})) - assert cfg.version == V1 - assert list(s['name'] for s in cfg.services) == ['version'] - - def test_wrong_version_type(self): - for version in [None, 1, 2, 2.0]: - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - {'version': version}, - filename='filename.yml', - ) - ) - - assert 'Version in "filename.yml" is invalid - it should be a string.' \ - in excinfo.exconly() - - def test_unsupported_version(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - {'version': '2.18'}, - filename='filename.yml', - ) - ) - - assert 'Version in "filename.yml" is unsupported' in excinfo.exconly() - assert VERSION_EXPLANATION in excinfo.exconly() - - def test_version_1_is_invalid(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - { - 'version': '1', - 'web': {'image': 'busybox'}, - }, - filename='filename.yml', - ) - ) - - assert 'Version in "filename.yml" is invalid' in excinfo.exconly() - assert VERSION_EXPLANATION in excinfo.exconly() - - def test_v1_file_with_version_is_invalid(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - { - 'version': '2', - 'web': {'image': 'busybox'}, - }, - filename='filename.yml', - ) - ) - - assert 'Additional properties are not allowed' in excinfo.exconly() - assert VERSION_EXPLANATION in excinfo.exconly() - - def test_named_volume_config_empty(self): - config_details = build_config_details({ - 'version': '2', - 'services': { - 'simple': {'image': 'busybox'} - }, - 'volumes': { - 'simple': None, - 'other': {}, - } - }) - config_result = config.load(config_details) - volumes = config_result.volumes - assert 'simple' in volumes - assert volumes['simple'] == {} - assert volumes['other'] == {} - - def test_named_volume_numeric_driver_opt(self): - config_details = build_config_details({ - 'version': '2', - 'services': { - 'simple': {'image': 'busybox'} - }, - 'volumes': { - 'simple': {'driver_opts': {'size': 42}}, - } - }) - cfg = config.load(config_details) - assert cfg.volumes['simple']['driver_opts']['size'] == '42' - - def test_volume_invalid_driver_opt(self): - config_details = build_config_details({ - 'version': '2', - 'services': { - 'simple': {'image': 'busybox'} - }, - 'volumes': { - 'simple': {'driver_opts': {'size': True}}, - } - }) - with pytest.raises(ConfigurationError) as exc: - config.load(config_details) - assert 'driver_opts.size contains an invalid type' in exc.exconly() - - def test_named_volume_invalid_type_list(self): - config_details = build_config_details({ - 'version': '2', - 'services': { - 'simple': {'image': 'busybox'} - }, - 'volumes': [] - }) - with pytest.raises(ConfigurationError) as exc: - config.load(config_details) - assert "volume must be a mapping, not an array" in exc.exconly() - - def test_networks_invalid_type_list(self): - config_details = build_config_details({ - 'version': '2', - 'services': { - 'simple': {'image': 'busybox'} - }, - 'networks': [] - }) - with pytest.raises(ConfigurationError) as exc: - config.load(config_details) - assert "network must be a mapping, not an array" in exc.exconly() - - def test_load_service_with_name_version(self): - with mock.patch('compose.config.config.log') as mock_logging: - config_data = config.load( - build_config_details({ - 'version': { - 'image': 'busybox' - } - }, 'working_dir', 'filename.yml') - ) - - assert 'Unexpected type for "version" key in "filename.yml"' \ - in mock_logging.warn.call_args[0][0] - - service_dicts = config_data.services - self.assertEqual( - service_sort(service_dicts), - service_sort([ - { - 'name': 'version', - 'image': 'busybox', - } - ]) - ) - - def test_load_throws_error_when_not_dict(self): - with self.assertRaises(ConfigurationError): - config.load( - build_config_details( - {'web': 'busybox:latest'}, - 'working_dir', - 'filename.yml' - ) - ) - - def test_load_throws_error_when_not_dict_v2(self): - with self.assertRaises(ConfigurationError): - config.load( - build_config_details( - {'version': '2', 'services': {'web': 'busybox:latest'}}, - 'working_dir', - 'filename.yml' - ) - ) - - def test_load_throws_error_with_invalid_network_fields(self): - with self.assertRaises(ConfigurationError): - config.load( - build_config_details({ - 'version': '2', - 'services': {'web': 'busybox:latest'}, - 'networks': { - 'invalid': {'foo', 'bar'} - } - }, 'working_dir', 'filename.yml') - ) - - def test_load_config_link_local_ips_network(self): - base_file = config.ConfigFile( - 'base.yaml', - { - 'version': V2_1, - 'services': { - 'web': { - 'image': 'example/web', - 'networks': { - 'foobar': { - 'aliases': ['foo', 'bar'], - 'link_local_ips': ['169.254.8.8'] - } - } - } - }, - 'networks': {'foobar': {}} - } - ) - - details = config.ConfigDetails('.', [base_file]) - web_service = config.load(details).services[0] - assert web_service['networks'] == { - 'foobar': { - 'aliases': ['foo', 'bar'], - 'link_local_ips': ['169.254.8.8'] - } - } - - def test_load_config_volume_and_network_labels(self): - base_file = config.ConfigFile( - 'base.yaml', - { - 'version': '2.1', - 'services': { - 'web': { - 'image': 'example/web', - }, - }, - 'networks': { - 'with_label': { - 'labels': { - 'label_key': 'label_val' - } - } - }, - 'volumes': { - 'with_label': { - 'labels': { - 'label_key': 'label_val' - } - } - } - } - ) - - details = config.ConfigDetails('.', [base_file]) - network_dict = config.load(details).networks - volume_dict = config.load(details).volumes - - self.assertEqual( - network_dict, - { - 'with_label': { - 'labels': { - 'label_key': 'label_val' - } - } - } - ) - - self.assertEqual( - volume_dict, - { - 'with_label': { - 'labels': { - 'label_key': 'label_val' - } - } - } - ) - - def test_load_config_invalid_service_names(self): - for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: - with pytest.raises(ConfigurationError) as exc: - config.load(build_config_details( - {invalid_name: {'image': 'busybox'}})) - assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly() - - def test_load_config_invalid_service_names_v2(self): - for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']: - with pytest.raises(ConfigurationError) as exc: - config.load(build_config_details( - { - 'version': '2', - 'services': {invalid_name: {'image': 'busybox'}}, - })) - assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly() - - def test_load_with_invalid_field_name(self): - with pytest.raises(ConfigurationError) as exc: - config.load(build_config_details( - { - 'version': '2', - 'services': { - 'web': {'image': 'busybox', 'name': 'bogus'}, - } - }, - 'working_dir', - 'filename.yml', - )) - - assert "Unsupported config option for services.web: 'name'" in exc.exconly() - - def test_load_with_invalid_field_name_v1(self): - with pytest.raises(ConfigurationError) as exc: - config.load(build_config_details( - { - 'web': {'image': 'busybox', 'name': 'bogus'}, - }, - 'working_dir', - 'filename.yml', - )) - - assert "Unsupported config option for web: 'name'" in exc.exconly() - - def test_load_invalid_service_definition(self): - config_details = build_config_details( - {'web': 'wrong'}, - 'working_dir', - 'filename.yml') - with pytest.raises(ConfigurationError) as exc: - config.load(config_details) - assert "service 'web' must be a mapping not a string." in exc.exconly() - - def test_load_with_empty_build_args(self): - config_details = build_config_details( - { - 'version': '2', - 'services': { - 'web': { - 'build': { - 'context': '.', - 'args': None, - }, - }, - }, - } - ) - with pytest.raises(ConfigurationError) as exc: - config.load(config_details) - assert ( - "services.web.build.args contains an invalid type, it should be an " - "object, or an array" in exc.exconly() - ) - - def test_config_integer_service_name_raise_validation_error(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - {1: {'image': 'busybox'}}, - 'working_dir', - 'filename.yml' - ) - ) - - assert ( - "In file 'filename.yml', the service name 1 must be a quoted string, i.e. '1'" in - excinfo.exconly() - ) - - def test_config_integer_service_name_raise_validation_error_v2(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - { - 'version': '2', - 'services': {1: {'image': 'busybox'}} - }, - 'working_dir', - 'filename.yml' - ) - ) - - assert ( - "In file 'filename.yml', the service name 1 must be a quoted string, i.e. '1'." in - excinfo.exconly() - ) - - def test_load_with_multiple_files_v1(self): - base_file = config.ConfigFile( - 'base.yaml', - { - 'web': { - 'image': 'example/web', - 'links': ['db'], - }, - 'db': { - 'image': 'example/db', - }, - }) - override_file = config.ConfigFile( - 'override.yaml', - { - 'web': { - 'build': '/', - 'volumes': ['/home/user/project:/code'], - }, - }) - details = config.ConfigDetails('.', [base_file, override_file]) - - service_dicts = config.load(details).services - expected = [ - { - 'name': 'web', - 'build': {'context': os.path.abspath('/')}, - 'volumes': [VolumeSpec.parse('/home/user/project:/code')], - 'links': ['db'], - }, - { - 'name': 'db', - 'image': 'example/db', - }, - ] - assert service_sort(service_dicts) == service_sort(expected) - - def test_load_with_multiple_files_and_empty_override(self): - base_file = config.ConfigFile( - 'base.yml', - {'web': {'image': 'example/web'}}) - override_file = config.ConfigFile('override.yml', None) - details = config.ConfigDetails('.', [base_file, override_file]) - - with pytest.raises(ConfigurationError) as exc: - config.load(details) - error_msg = "Top level object in 'override.yml' needs to be an object" - assert error_msg in exc.exconly() - - def test_load_with_multiple_files_and_empty_override_v2(self): - base_file = config.ConfigFile( - 'base.yml', - {'version': '2', 'services': {'web': {'image': 'example/web'}}}) - override_file = config.ConfigFile('override.yml', None) - details = config.ConfigDetails('.', [base_file, override_file]) - - with pytest.raises(ConfigurationError) as exc: - config.load(details) - error_msg = "Top level object in 'override.yml' needs to be an object" - assert error_msg in exc.exconly() - - def test_load_with_multiple_files_and_empty_base(self): - base_file = config.ConfigFile('base.yml', None) - override_file = config.ConfigFile( - 'override.yml', - {'web': {'image': 'example/web'}}) - details = config.ConfigDetails('.', [base_file, override_file]) - - with pytest.raises(ConfigurationError) as exc: - config.load(details) - assert "Top level object in 'base.yml' needs to be an object" in exc.exconly() - - def test_load_with_multiple_files_and_empty_base_v2(self): - base_file = config.ConfigFile('base.yml', None) - override_file = config.ConfigFile( - 'override.tml', - {'version': '2', 'services': {'web': {'image': 'example/web'}}} - ) - details = config.ConfigDetails('.', [base_file, override_file]) - with pytest.raises(ConfigurationError) as exc: - config.load(details) - assert "Top level object in 'base.yml' needs to be an object" in exc.exconly() - - def test_load_with_multiple_files_and_extends_in_override_file(self): - base_file = config.ConfigFile( - 'base.yaml', - { - 'web': {'image': 'example/web'}, - }) - override_file = config.ConfigFile( - 'override.yaml', - { - 'web': { - 'extends': { - 'file': 'common.yml', - 'service': 'base', - }, - 'volumes': ['/home/user/project:/code'], - }, - }) - details = config.ConfigDetails('.', [base_file, override_file]) - - tmpdir = py.test.ensuretemp('config_test') - self.addCleanup(tmpdir.remove) - tmpdir.join('common.yml').write(""" - base: - labels: ['label=one'] - """) - with tmpdir.as_cwd(): - service_dicts = config.load(details).services - - expected = [ - { - 'name': 'web', - 'image': 'example/web', - 'volumes': [VolumeSpec.parse('/home/user/project:/code')], - 'labels': {'label': 'one'}, - }, - ] - self.assertEqual(service_sort(service_dicts), service_sort(expected)) - - def test_load_with_multiple_files_and_invalid_override(self): - base_file = config.ConfigFile( - 'base.yaml', - {'web': {'image': 'example/web'}}) - override_file = config.ConfigFile( - 'override.yaml', - {'bogus': 'thing'}) - details = config.ConfigDetails('.', [base_file, override_file]) - - with pytest.raises(ConfigurationError) as exc: - config.load(details) - assert "service 'bogus' must be a mapping not a string." in exc.exconly() - assert "In file 'override.yaml'" in exc.exconly() - - def test_load_sorts_in_dependency_order(self): - config_details = build_config_details({ - 'web': { - 'image': 'busybox:latest', - 'links': ['db'], - }, - 'db': { - 'image': 'busybox:latest', - 'volumes_from': ['volume:ro'] - }, - 'volume': { - 'image': 'busybox:latest', - 'volumes': ['/tmp'], - } - }) - services = config.load(config_details).services - - assert services[0]['name'] == 'volume' - assert services[1]['name'] == 'db' - assert services[2]['name'] == 'web' - - def test_config_build_configuration(self): - service = config.load( - build_config_details( - {'web': { - 'build': '.', - 'dockerfile': 'Dockerfile-alt' - }}, - 'tests/fixtures/extends', - 'filename.yml' - ) - ).services - self.assertTrue('context' in service[0]['build']) - self.assertEqual(service[0]['build']['dockerfile'], 'Dockerfile-alt') - - def test_config_build_configuration_v2(self): - # service.dockerfile is invalid in v2 - with self.assertRaises(ConfigurationError): - config.load( - build_config_details( - { - 'version': '2', - 'services': { - 'web': { - 'build': '.', - 'dockerfile': 'Dockerfile-alt' - } - } - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ) - - service = config.load( - build_config_details({ - 'version': '2', - 'services': { - 'web': { - 'build': '.' - } - } - }, 'tests/fixtures/extends', 'filename.yml') - ).services[0] - self.assertTrue('context' in service['build']) - - service = config.load( - build_config_details( - { - 'version': '2', - 'services': { - 'web': { - 'build': { - 'context': '.', - 'dockerfile': 'Dockerfile-alt' - } - } - } - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ).services - self.assertTrue('context' in service[0]['build']) - self.assertEqual(service[0]['build']['dockerfile'], 'Dockerfile-alt') - - def test_load_with_buildargs(self): - service = config.load( - build_config_details( - { - 'version': '2', - 'services': { - 'web': { - 'build': { - 'context': '.', - 'dockerfile': 'Dockerfile-alt', - 'args': { - 'opt1': 42, - 'opt2': 'foobar' - } - } - } - } - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ).services[0] - assert 'args' in service['build'] - assert 'opt1' in service['build']['args'] - assert isinstance(service['build']['args']['opt1'], str) - assert service['build']['args']['opt1'] == '42' - assert service['build']['args']['opt2'] == 'foobar' - - def test_build_args_allow_empty_properties(self): - service = config.load( - build_config_details( - { - 'version': '2', - 'services': { - 'web': { - 'build': { - 'context': '.', - 'dockerfile': 'Dockerfile-alt', - 'args': { - 'foo': None - } - } - } - } - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ).services[0] - assert 'args' in service['build'] - assert 'foo' in service['build']['args'] - assert service['build']['args']['foo'] == '' - - # If build argument is None then it will be converted to the empty - # string. Make sure that int zero kept as it is, i.e. not converted to - # the empty string - def test_build_args_check_zero_preserved(self): - service = config.load( - build_config_details( - { - 'version': '2', - 'services': { - 'web': { - 'build': { - 'context': '.', - 'dockerfile': 'Dockerfile-alt', - 'args': { - 'foo': 0 - } - } - } - } - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ).services[0] - assert 'args' in service['build'] - assert 'foo' in service['build']['args'] - assert service['build']['args']['foo'] == '0' - - def test_load_with_multiple_files_mismatched_networks_format(self): - base_file = config.ConfigFile( - 'base.yaml', - { - 'version': '2', - 'services': { - 'web': { - 'image': 'example/web', - 'networks': { - 'foobar': {'aliases': ['foo', 'bar']} - } - } - }, - 'networks': {'foobar': {}, 'baz': {}} - } - ) - - override_file = config.ConfigFile( - 'override.yaml', - { - 'version': '2', - 'services': { - 'web': { - 'networks': ['baz'] - } - } - } - ) - - details = config.ConfigDetails('.', [base_file, override_file]) - web_service = config.load(details).services[0] - assert web_service['networks'] == { - 'foobar': {'aliases': ['foo', 'bar']}, - 'baz': None - } - - def test_load_with_multiple_files_v2(self): - base_file = config.ConfigFile( - 'base.yaml', - { - 'version': '2', - 'services': { - 'web': { - 'image': 'example/web', - 'depends_on': ['db'], - }, - 'db': { - 'image': 'example/db', - } - }, - }) - override_file = config.ConfigFile( - 'override.yaml', - { - 'version': '2', - 'services': { - 'web': { - 'build': '/', - 'volumes': ['/home/user/project:/code'], - 'depends_on': ['other'], - }, - 'other': { - 'image': 'example/other', - } - } - }) - details = config.ConfigDetails('.', [base_file, override_file]) - - service_dicts = config.load(details).services - expected = [ - { - 'name': 'web', - 'build': {'context': os.path.abspath('/')}, - 'image': 'example/web', - 'volumes': [VolumeSpec.parse('/home/user/project:/code')], - 'depends_on': { - 'db': {'condition': 'service_started'}, - 'other': {'condition': 'service_started'}, - }, - }, - { - 'name': 'db', - 'image': 'example/db', - }, - { - 'name': 'other', - 'image': 'example/other', - }, - ] - assert service_sort(service_dicts) == service_sort(expected) - - def test_undeclared_volume_v2(self): - base_file = config.ConfigFile( - 'base.yaml', - { - 'version': '2', - 'services': { - 'web': { - 'image': 'busybox:latest', - 'volumes': ['data0028:/data:ro'], - }, - }, - } - ) - details = config.ConfigDetails('.', [base_file]) - with self.assertRaises(ConfigurationError): - config.load(details) - - base_file = config.ConfigFile( - 'base.yaml', - { - 'version': '2', - 'services': { - 'web': { - 'image': 'busybox:latest', - 'volumes': ['./data0028:/data:ro'], - }, - }, - } - ) - details = config.ConfigDetails('.', [base_file]) - config_data = config.load(details) - volume = config_data.services[0].get('volumes')[0] - assert not volume.is_named_volume - - def test_undeclared_volume_v1(self): - base_file = config.ConfigFile( - 'base.yaml', - { - 'web': { - 'image': 'busybox:latest', - 'volumes': ['data0028:/data:ro'], - }, - } - ) - details = config.ConfigDetails('.', [base_file]) - config_data = config.load(details) - volume = config_data.services[0].get('volumes')[0] - assert volume.external == 'data0028' - assert volume.is_named_volume - - def test_config_valid_service_names(self): - for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']: - services = config.load( - build_config_details( - {valid_name: {'image': 'busybox'}}, - 'tests/fixtures/extends', - 'common.yml')).services - assert services[0]['name'] == valid_name - - def test_config_hint(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - { - 'foo': {'image': 'busybox', 'privilige': 'something'}, - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ) - - assert "(did you mean 'privileged'?)" in excinfo.exconly() - - def test_load_errors_on_uppercase_with_no_image(self): - with pytest.raises(ConfigurationError) as exc: - config.load(build_config_details({ - 'Foo': {'build': '.'}, - }, 'tests/fixtures/build-ctx')) - assert "Service 'Foo' contains uppercase characters" in exc.exconly() - - def test_invalid_config_v1(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - { - 'foo': {'image': 1}, - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ) - - assert "foo.image contains an invalid type, it should be a string" \ - in excinfo.exconly() - - def test_invalid_config_v2(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - { - 'version': '2', - 'services': { - 'foo': {'image': 1}, - }, - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ) - - assert "services.foo.image contains an invalid type, it should be a string" \ - in excinfo.exconly() - - def test_invalid_config_build_and_image_specified_v1(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - { - 'foo': {'image': 'busybox', 'build': '.'}, - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ) - - assert "foo has both an image and build path specified." in excinfo.exconly() - - def test_invalid_config_type_should_be_an_array(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - { - 'foo': {'image': 'busybox', 'links': 'an_link'}, - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ) - - assert "foo.links contains an invalid type, it should be an array" \ - in excinfo.exconly() - - def test_invalid_config_not_a_dictionary(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - ['foo', 'lol'], - 'tests/fixtures/extends', - 'filename.yml' - ) - ) - - assert "Top level object in 'filename.yml' needs to be an object" \ - in excinfo.exconly() - - def test_invalid_config_not_unique_items(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - { - 'web': {'build': '.', 'devices': ['/dev/foo:/dev/foo', '/dev/foo:/dev/foo']} - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ) - - assert "has non-unique elements" in excinfo.exconly() - - def test_invalid_list_of_strings_format(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - { - 'web': {'build': '.', 'command': [1]} - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ) - - assert "web.command contains 1, which is an invalid type, it should be a string" \ - in excinfo.exconly() - - def test_load_config_dockerfile_without_build_raises_error_v1(self): - with pytest.raises(ConfigurationError) as exc: - config.load(build_config_details({ - 'web': { - 'image': 'busybox', - 'dockerfile': 'Dockerfile.alt' - } - })) - - assert "web has both an image and alternate Dockerfile." in exc.exconly() - - def test_config_extra_hosts_string_raises_validation_error(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - {'web': { - 'image': 'busybox', - 'extra_hosts': 'somehost:162.242.195.82' - }}, - 'working_dir', - 'filename.yml' - ) - ) - - assert "web.extra_hosts contains an invalid type" \ - in excinfo.exconly() - - def test_config_extra_hosts_list_of_dicts_validation_error(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - {'web': { - 'image': 'busybox', - 'extra_hosts': [ - {'somehost': '162.242.195.82'}, - {'otherhost': '50.31.209.229'} - ] - }}, - 'working_dir', - 'filename.yml' - ) - ) - - assert "web.extra_hosts contains {\"somehost\": \"162.242.195.82\"}, " \ - "which is an invalid type, it should be a string" \ - in excinfo.exconly() - - def test_config_ulimits_invalid_keys_validation_error(self): - with pytest.raises(ConfigurationError) as exc: - config.load(build_config_details( - { - 'web': { - 'image': 'busybox', - 'ulimits': { - 'nofile': { - "not_soft_or_hard": 100, - "soft": 10000, - "hard": 20000, - } - } - } - }, - 'working_dir', - 'filename.yml')) - - assert "web.ulimits.nofile contains unsupported option: 'not_soft_or_hard'" \ - in exc.exconly() - - def test_config_ulimits_required_keys_validation_error(self): - with pytest.raises(ConfigurationError) as exc: - config.load(build_config_details( - { - 'web': { - 'image': 'busybox', - 'ulimits': {'nofile': {"soft": 10000}} - } - }, - 'working_dir', - 'filename.yml')) - assert "web.ulimits.nofile" in exc.exconly() - assert "'hard' is a required property" in exc.exconly() - - def test_config_ulimits_soft_greater_than_hard_error(self): - expected = "'soft' value can not be greater than 'hard' value" - - with pytest.raises(ConfigurationError) as exc: - config.load(build_config_details( - { - 'web': { - 'image': 'busybox', - 'ulimits': { - 'nofile': {"soft": 10000, "hard": 1000} - } - } - }, - 'working_dir', - 'filename.yml')) - assert expected in exc.exconly() - - def test_valid_config_which_allows_two_type_definitions(self): - expose_values = [["8000"], [8000]] - for expose in expose_values: - service = config.load( - build_config_details( - {'web': { - 'image': 'busybox', - 'expose': expose - }}, - 'working_dir', - 'filename.yml' - ) - ).services - self.assertEqual(service[0]['expose'], expose) - - def test_valid_config_oneof_string_or_list(self): - entrypoint_values = [["sh"], "sh"] - for entrypoint in entrypoint_values: - service = config.load( - build_config_details( - {'web': { - 'image': 'busybox', - 'entrypoint': entrypoint - }}, - 'working_dir', - 'filename.yml' - ) - ).services - self.assertEqual(service[0]['entrypoint'], entrypoint) - - def test_logs_warning_for_boolean_in_environment(self): - config_details = build_config_details({ - 'web': { - 'image': 'busybox', - 'environment': {'SHOW_STUFF': True} - } - }) - - with pytest.raises(ConfigurationError) as exc: - config.load(config_details) - - assert "contains true, which is an invalid type" in exc.exconly() - - def test_config_valid_environment_dict_key_contains_dashes(self): - services = config.load( - build_config_details( - {'web': { - 'image': 'busybox', - 'environment': {'SPRING_JPA_HIBERNATE_DDL-AUTO': 'none'} - }}, - 'working_dir', - 'filename.yml' - ) - ).services - self.assertEqual(services[0]['environment']['SPRING_JPA_HIBERNATE_DDL-AUTO'], 'none') - - def test_load_yaml_with_yaml_error(self): - tmpdir = py.test.ensuretemp('invalid_yaml_test') - self.addCleanup(tmpdir.remove) - invalid_yaml_file = tmpdir.join('docker-compose.yml') - invalid_yaml_file.write(""" - web: - this is bogus: ok: what - """) - with pytest.raises(ConfigurationError) as exc: - config.load_yaml(str(invalid_yaml_file)) - - assert 'line 3, column 32' in exc.exconly() - - def test_validate_extra_hosts_invalid(self): - with pytest.raises(ConfigurationError) as exc: - config.load(build_config_details({ - 'web': { - 'image': 'alpine', - 'extra_hosts': "www.example.com: 192.168.0.17", - } - })) - assert "web.extra_hosts contains an invalid type" in exc.exconly() - - def test_validate_extra_hosts_invalid_list(self): - with pytest.raises(ConfigurationError) as exc: - config.load(build_config_details({ - 'web': { - 'image': 'alpine', - 'extra_hosts': [ - {'www.example.com': '192.168.0.17'}, - {'api.example.com': '192.168.0.18'} - ], - } - })) - assert "which is an invalid type" in exc.exconly() - - def test_normalize_dns_options(self): - actual = config.load(build_config_details({ - 'web': { - 'image': 'alpine', - 'dns': '8.8.8.8', - 'dns_search': 'domain.local', - } - })) - assert actual.services == [ - { - 'name': 'web', - 'image': 'alpine', - 'dns': ['8.8.8.8'], - 'dns_search': ['domain.local'], - } - ] - - def test_tmpfs_option(self): - actual = config.load(build_config_details({ - 'version': '2', - 'services': { - 'web': { - 'image': 'alpine', - 'tmpfs': '/run', - } - } - })) - assert actual.services == [ - { - 'name': 'web', - 'image': 'alpine', - 'tmpfs': ['/run'], - } - ] - - def test_oom_score_adj_option(self): - - actual = config.load(build_config_details({ - 'version': '2', - 'services': { - 'web': { - 'image': 'alpine', - 'oom_score_adj': 500 - } - } - })) - - assert actual.services == [ - { - 'name': 'web', - 'image': 'alpine', - 'oom_score_adj': 500 - } - ] - - def test_swappiness_option(self): - actual = config.load(build_config_details({ - 'version': '2', - 'services': { - 'web': { - 'image': 'alpine', - 'mem_swappiness': 10, - } - } - })) - assert actual.services == [ - { - 'name': 'web', - 'image': 'alpine', - 'mem_swappiness': 10, - } - ] - - def test_group_add_option(self): - - actual = config.load(build_config_details({ - 'version': '2', - 'services': { - 'web': { - 'image': 'alpine', - 'group_add': ["docker", 777] - } - } - })) - - assert actual.services == [ - { - 'name': 'web', - 'image': 'alpine', - 'group_add': ["docker", 777] - } - ] - - def test_isolation_option(self): - actual = config.load(build_config_details({ - 'version': V2_1, - 'services': { - 'web': { - 'image': 'win10', - 'isolation': 'hyperv' - } - } - })) - - assert actual.services == [ - { - 'name': 'web', - 'image': 'win10', - 'isolation': 'hyperv', - } - ] - - def test_merge_service_dicts_from_files_with_extends_in_base(self): - base = { - 'volumes': ['.:/app'], - 'extends': {'service': 'app'} - } - override = { - 'image': 'alpine:edge', - } - actual = config.merge_service_dicts_from_files( - base, - override, - DEFAULT_VERSION) - assert actual == { - 'image': 'alpine:edge', - 'volumes': ['.:/app'], - 'extends': {'service': 'app'} - } - - def test_merge_service_dicts_from_files_with_extends_in_override(self): - base = { - 'volumes': ['.:/app'], - 'extends': {'service': 'app'} - } - override = { - 'image': 'alpine:edge', - 'extends': {'service': 'foo'} - } - actual = config.merge_service_dicts_from_files( - base, - override, - DEFAULT_VERSION) - assert actual == { - 'image': 'alpine:edge', - 'volumes': ['.:/app'], - 'extends': {'service': 'foo'} - } - - def test_merge_build_args(self): - base = { - 'build': { - 'context': '.', - 'args': { - 'ONE': '1', - 'TWO': '2', - }, - } - } - override = { - 'build': { - 'args': { - 'TWO': 'dos', - 'THREE': '3', - }, - } - } - actual = config.merge_service_dicts( - base, - override, - DEFAULT_VERSION) - assert actual == { - 'build': { - 'context': '.', - 'args': { - 'ONE': '1', - 'TWO': 'dos', - 'THREE': '3', - }, - } - } - - def test_merge_logging_v1(self): - base = { - 'image': 'alpine:edge', - 'log_driver': 'something', - 'log_opt': {'foo': 'three'}, - } - override = { - 'image': 'alpine:edge', - 'command': 'true', - } - actual = config.merge_service_dicts(base, override, V1) - assert actual == { - 'image': 'alpine:edge', - 'log_driver': 'something', - 'log_opt': {'foo': 'three'}, - 'command': 'true', - } - - def test_merge_logging_v2(self): - base = { - 'image': 'alpine:edge', - 'logging': { - 'driver': 'json-file', - 'options': { - 'frequency': '2000', - 'timeout': '23' - } - } - } - override = { - 'logging': { - 'options': { - 'timeout': '360', - 'pretty-print': 'on' - } - } - } - - actual = config.merge_service_dicts(base, override, V2_0) - assert actual == { - 'image': 'alpine:edge', - 'logging': { - 'driver': 'json-file', - 'options': { - 'frequency': '2000', - 'timeout': '360', - 'pretty-print': 'on' - } - } - } - - def test_merge_logging_v2_override_driver(self): - base = { - 'image': 'alpine:edge', - 'logging': { - 'driver': 'json-file', - 'options': { - 'frequency': '2000', - 'timeout': '23' - } - } - } - override = { - 'logging': { - 'driver': 'syslog', - 'options': { - 'timeout': '360', - 'pretty-print': 'on' - } - } - } - - actual = config.merge_service_dicts(base, override, V2_0) - assert actual == { - 'image': 'alpine:edge', - 'logging': { - 'driver': 'syslog', - 'options': { - 'timeout': '360', - 'pretty-print': 'on' - } - } - } - - def test_merge_logging_v2_no_base_driver(self): - base = { - 'image': 'alpine:edge', - 'logging': { - 'options': { - 'frequency': '2000', - 'timeout': '23' - } - } - } - override = { - 'logging': { - 'driver': 'json-file', - 'options': { - 'timeout': '360', - 'pretty-print': 'on' - } - } - } - - actual = config.merge_service_dicts(base, override, V2_0) - assert actual == { - 'image': 'alpine:edge', - 'logging': { - 'driver': 'json-file', - 'options': { - 'frequency': '2000', - 'timeout': '360', - 'pretty-print': 'on' - } - } - } - - def test_merge_logging_v2_no_drivers(self): - base = { - 'image': 'alpine:edge', - 'logging': { - 'options': { - 'frequency': '2000', - 'timeout': '23' - } - } - } - override = { - 'logging': { - 'options': { - 'timeout': '360', - 'pretty-print': 'on' - } - } - } - - actual = config.merge_service_dicts(base, override, V2_0) - assert actual == { - 'image': 'alpine:edge', - 'logging': { - 'options': { - 'frequency': '2000', - 'timeout': '360', - 'pretty-print': 'on' - } - } - } - - def test_merge_logging_v2_no_override_options(self): - base = { - 'image': 'alpine:edge', - 'logging': { - 'driver': 'json-file', - 'options': { - 'frequency': '2000', - 'timeout': '23' - } - } - } - override = { - 'logging': { - 'driver': 'syslog' - } - } - - actual = config.merge_service_dicts(base, override, V2_0) - assert actual == { - 'image': 'alpine:edge', - 'logging': { - 'driver': 'syslog', - 'options': None - } - } - - def test_merge_logging_v2_no_base(self): - base = { - 'image': 'alpine:edge' - } - override = { - 'logging': { - 'driver': 'json-file', - 'options': { - 'frequency': '2000' - } - } - } - actual = config.merge_service_dicts(base, override, V2_0) - assert actual == { - 'image': 'alpine:edge', - 'logging': { - 'driver': 'json-file', - 'options': { - 'frequency': '2000' - } - } - } - - def test_merge_logging_v2_no_override(self): - base = { - 'image': 'alpine:edge', - 'logging': { - 'driver': 'syslog', - 'options': { - 'frequency': '2000' - } - } - } - override = {} - actual = config.merge_service_dicts(base, override, V2_0) - assert actual == { - 'image': 'alpine:edge', - 'logging': { - 'driver': 'syslog', - 'options': { - 'frequency': '2000' - } - } - } - - def test_merge_depends_on_no_override(self): - base = { - 'image': 'busybox', - 'depends_on': { - 'app1': {'condition': 'service_started'}, - 'app2': {'condition': 'service_healthy'} - } - } - override = {} - actual = config.merge_service_dicts(base, override, V2_1) - assert actual == base - - def test_merge_depends_on_mixed_syntax(self): - base = { - 'image': 'busybox', - 'depends_on': { - 'app1': {'condition': 'service_started'}, - 'app2': {'condition': 'service_healthy'} - } - } - override = { - 'depends_on': ['app3'] - } - - actual = config.merge_service_dicts(base, override, V2_1) - assert actual == { - 'image': 'busybox', - 'depends_on': { - 'app1': {'condition': 'service_started'}, - 'app2': {'condition': 'service_healthy'}, - 'app3': {'condition': 'service_started'} - } - } - - def test_merge_pid(self): - # Regression: https://github.com/docker/compose/issues/4184 - base = { - 'image': 'busybox', - 'pid': 'host' - } - - override = { - 'labels': {'com.docker.compose.test': 'yes'} - } - - actual = config.merge_service_dicts(base, override, V2_0) - assert actual == { - 'image': 'busybox', - 'pid': 'host', - 'labels': {'com.docker.compose.test': 'yes'} - } - - def test_merge_different_secrets(self): - base = { - 'image': 'busybox', - 'secrets': [ - {'source': 'src.txt'} - ] - } - override = {'secrets': ['other-src.txt']} - - actual = config.merge_service_dicts(base, override, V3_1) - assert secret_sort(actual['secrets']) == secret_sort([ - {'source': 'src.txt'}, - {'source': 'other-src.txt'} - ]) - - def test_merge_secrets_override(self): - base = { - 'image': 'busybox', - 'secrets': ['src.txt'], - } - override = { - 'secrets': [ - { - 'source': 'src.txt', - 'target': 'data.txt', - 'mode': 0o400 - } - ] - } - actual = config.merge_service_dicts(base, override, V3_1) - assert actual['secrets'] == override['secrets'] - - def test_external_volume_config(self): - config_details = build_config_details({ - 'version': '2', - 'services': { - 'bogus': {'image': 'busybox'} - }, - 'volumes': { - 'ext': {'external': True}, - 'ext2': {'external': {'name': 'aliased'}} - } - }) - config_result = config.load(config_details) - volumes = config_result.volumes - assert 'ext' in volumes - assert volumes['ext']['external'] is True - assert 'ext2' in volumes - assert volumes['ext2']['external']['name'] == 'aliased' - - def test_external_volume_invalid_config(self): - config_details = build_config_details({ - 'version': '2', - 'services': { - 'bogus': {'image': 'busybox'} - }, - 'volumes': { - 'ext': {'external': True, 'driver': 'foo'} - } - }) - with pytest.raises(ConfigurationError): - config.load(config_details) - - def test_depends_on_orders_services(self): - config_details = build_config_details({ - 'version': '2', - 'services': { - 'one': {'image': 'busybox', 'depends_on': ['three', 'two']}, - 'two': {'image': 'busybox', 'depends_on': ['three']}, - 'three': {'image': 'busybox'}, - }, - }) - actual = config.load(config_details) - assert ( - [service['name'] for service in actual.services] == - ['three', 'two', 'one'] - ) - - def test_depends_on_unknown_service_errors(self): - config_details = build_config_details({ - 'version': '2', - 'services': { - 'one': {'image': 'busybox', 'depends_on': ['three']}, - }, - }) - with pytest.raises(ConfigurationError) as exc: - config.load(config_details) - assert "Service 'one' depends on service 'three'" in exc.exconly() - - def test_linked_service_is_undefined(self): - with self.assertRaises(ConfigurationError): - config.load( - build_config_details({ - 'version': '2', - 'services': { - 'web': {'image': 'busybox', 'links': ['db:db']}, - }, - }) - ) - - def test_load_dockerfile_without_context(self): - config_details = build_config_details({ - 'version': '2', - 'services': { - 'one': {'build': {'dockerfile': 'Dockerfile.foo'}}, - }, - }) - with pytest.raises(ConfigurationError) as exc: - config.load(config_details) - assert 'has neither an image nor a build context' in exc.exconly() - - def test_load_secrets(self): - base_file = config.ConfigFile( - 'base.yaml', - { - 'version': '3.1', - 'services': { - 'web': { - 'image': 'example/web', - 'secrets': [ - 'one', - { - 'source': 'source', - 'target': 'target', - 'uid': '100', - 'gid': '200', - 'mode': 0o777, - }, - ], - }, - }, - 'secrets': { - 'one': {'file': 'secret.txt'}, - }, - }) - details = config.ConfigDetails('.', [base_file]) - service_dicts = config.load(details).services - expected = [ - { - 'name': 'web', - 'image': 'example/web', - 'secrets': [ - types.ServiceSecret('one', None, None, None, None), - types.ServiceSecret('source', 'target', '100', '200', 0o777), - ], - }, - ] - assert service_sort(service_dicts) == service_sort(expected) - - def test_load_secrets_multi_file(self): - base_file = config.ConfigFile( - 'base.yaml', - { - 'version': '3.1', - 'services': { - 'web': { - 'image': 'example/web', - 'secrets': ['one'], - }, - }, - 'secrets': { - 'one': {'file': 'secret.txt'}, - }, - }) - override_file = config.ConfigFile( - 'base.yaml', - { - 'version': '3.1', - 'services': { - 'web': { - 'secrets': [ - { - 'source': 'source', - 'target': 'target', - 'uid': '100', - 'gid': '200', - 'mode': 0o777, - }, - ], - }, - }, - }) - details = config.ConfigDetails('.', [base_file, override_file]) - service_dicts = config.load(details).services - expected = [ - { - 'name': 'web', - 'image': 'example/web', - 'secrets': [ - types.ServiceSecret('one', None, None, None, None), - types.ServiceSecret('source', 'target', '100', '200', 0o777), - ], - }, - ] - assert service_sort(service_dicts) == service_sort(expected) - - -class NetworkModeTest(unittest.TestCase): - def test_network_mode_standard(self): - config_data = config.load(build_config_details({ - 'version': '2', - 'services': { - 'web': { - 'image': 'busybox', - 'command': "top", - 'network_mode': 'bridge', - }, - }, - })) - - assert config_data.services[0]['network_mode'] == 'bridge' - - def test_network_mode_standard_v1(self): - config_data = config.load(build_config_details({ - 'web': { - 'image': 'busybox', - 'command': "top", - 'net': 'bridge', - }, - })) - - assert config_data.services[0]['network_mode'] == 'bridge' - assert 'net' not in config_data.services[0] - - def test_network_mode_container(self): - config_data = config.load(build_config_details({ - 'version': '2', - 'services': { - 'web': { - 'image': 'busybox', - 'command': "top", - 'network_mode': 'container:foo', - }, - }, - })) - - assert config_data.services[0]['network_mode'] == 'container:foo' - - def test_network_mode_container_v1(self): - config_data = config.load(build_config_details({ - 'web': { - 'image': 'busybox', - 'command': "top", - 'net': 'container:foo', - }, - })) - - assert config_data.services[0]['network_mode'] == 'container:foo' - - def test_network_mode_service(self): - config_data = config.load(build_config_details({ - 'version': '2', - 'services': { - 'web': { - 'image': 'busybox', - 'command': "top", - 'network_mode': 'service:foo', - }, - 'foo': { - 'image': 'busybox', - 'command': "top", - }, - }, - })) - - assert config_data.services[1]['network_mode'] == 'service:foo' - - def test_network_mode_service_v1(self): - config_data = config.load(build_config_details({ - 'web': { - 'image': 'busybox', - 'command': "top", - 'net': 'container:foo', - }, - 'foo': { - 'image': 'busybox', - 'command': "top", - }, - })) - - assert config_data.services[1]['network_mode'] == 'service:foo' - - def test_network_mode_service_nonexistent(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load(build_config_details({ - 'version': '2', - 'services': { - 'web': { - 'image': 'busybox', - 'command': "top", - 'network_mode': 'service:foo', - }, - }, - })) - - assert "service 'foo' which is undefined" in excinfo.exconly() - - def test_network_mode_plus_networks_is_invalid(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load(build_config_details({ - 'version': '2', - 'services': { - 'web': { - 'image': 'busybox', - 'command': "top", - 'network_mode': 'bridge', - 'networks': ['front'], - }, - }, - 'networks': { - 'front': None, - } - })) - - assert "'network_mode' and 'networks' cannot be combined" in excinfo.exconly() - - -class PortsTest(unittest.TestCase): - INVALID_PORTS_TYPES = [ - {"1": "8000"}, - False, - "8000", - 8000, - ] - - NON_UNIQUE_SINGLE_PORTS = [ - ["8000", "8000"], - ] - - INVALID_PORT_MAPPINGS = [ - ["8000-8001:8000"], - ] - - VALID_SINGLE_PORTS = [ - ["8000"], - ["8000/tcp"], - ["8000", "9000"], - [8000], - [8000, 9000], - ] - - VALID_PORT_MAPPINGS = [ - ["8000:8050"], - ["49153-49154:3002-3003"], - ] - - def test_config_invalid_ports_type_validation(self): - for invalid_ports in self.INVALID_PORTS_TYPES: - with pytest.raises(ConfigurationError) as exc: - self.check_config({'ports': invalid_ports}) - - assert "contains an invalid type" in exc.value.msg - - def test_config_non_unique_ports_validation(self): - for invalid_ports in self.NON_UNIQUE_SINGLE_PORTS: - with pytest.raises(ConfigurationError) as exc: - self.check_config({'ports': invalid_ports}) - - assert "non-unique" in exc.value.msg - - def test_config_invalid_ports_format_validation(self): - for invalid_ports in self.INVALID_PORT_MAPPINGS: - with pytest.raises(ConfigurationError) as exc: - self.check_config({'ports': invalid_ports}) - - assert "Port ranges don't match in length" in exc.value.msg - - def test_config_valid_ports_format_validation(self): - for valid_ports in self.VALID_SINGLE_PORTS + self.VALID_PORT_MAPPINGS: - self.check_config({'ports': valid_ports}) - - def test_config_invalid_expose_type_validation(self): - for invalid_expose in self.INVALID_PORTS_TYPES: - with pytest.raises(ConfigurationError) as exc: - self.check_config({'expose': invalid_expose}) - - assert "contains an invalid type" in exc.value.msg - - def test_config_non_unique_expose_validation(self): - for invalid_expose in self.NON_UNIQUE_SINGLE_PORTS: - with pytest.raises(ConfigurationError) as exc: - self.check_config({'expose': invalid_expose}) - - assert "non-unique" in exc.value.msg - - def test_config_invalid_expose_format_validation(self): - # Valid port mappings ARE NOT valid 'expose' entries - for invalid_expose in self.INVALID_PORT_MAPPINGS + self.VALID_PORT_MAPPINGS: - with pytest.raises(ConfigurationError) as exc: - self.check_config({'expose': invalid_expose}) - - assert "should be of the format" in exc.value.msg - - def test_config_valid_expose_format_validation(self): - # Valid single ports ARE valid 'expose' entries - for valid_expose in self.VALID_SINGLE_PORTS: - self.check_config({'expose': valid_expose}) - - def check_config(self, cfg): - config.load( - build_config_details( - {'web': dict(image='busybox', **cfg)}, - 'working_dir', - 'filename.yml' - ) - ) - - -class InterpolationTest(unittest.TestCase): - @mock.patch.dict(os.environ) - def test_config_file_with_environment_file(self): - project_dir = 'tests/fixtures/default-env-file' - service_dicts = config.load( - config.find( - project_dir, None, Environment.from_env_file(project_dir) - ) - ).services - - self.assertEqual(service_dicts[0], { - 'name': 'web', - 'image': 'alpine:latest', - 'ports': ['5643', '9999'], - 'command': 'true' - }) - - @mock.patch.dict(os.environ) - def test_config_file_with_environment_variable(self): - project_dir = 'tests/fixtures/environment-interpolation' - os.environ.update( - IMAGE="busybox", - HOST_PORT="80", - LABEL_VALUE="myvalue", - ) - - service_dicts = config.load( - config.find( - project_dir, None, Environment.from_env_file(project_dir) - ) - ).services - - self.assertEqual(service_dicts, [ - { - 'name': 'web', - 'image': 'busybox', - 'ports': ['80:8000'], - 'labels': {'mylabel': 'myvalue'}, - 'hostname': 'host-', - 'command': '${ESCAPED}', - } - ]) - - @mock.patch.dict(os.environ) - def test_unset_variable_produces_warning(self): - os.environ.pop('FOO', None) - os.environ.pop('BAR', None) - config_details = build_config_details( - { - 'web': { - 'image': '${FOO}', - 'command': '${BAR}', - 'container_name': '${BAR}', - }, - }, - '.', - None, - ) - - with mock.patch('compose.config.environment.log') as log: - config.load(config_details) - - self.assertEqual(2, log.warn.call_count) - warnings = sorted(args[0][0] for args in log.warn.call_args_list) - self.assertIn('BAR', warnings[0]) - self.assertIn('FOO', warnings[1]) - - @mock.patch.dict(os.environ) - def test_invalid_interpolation(self): - with self.assertRaises(config.ConfigurationError) as cm: - config.load( - build_config_details( - {'web': {'image': '${'}}, - 'working_dir', - 'filename.yml' - ) - ) - - self.assertIn('Invalid', cm.exception.msg) - self.assertIn('for "image" option', cm.exception.msg) - self.assertIn('in service "web"', cm.exception.msg) - self.assertIn('"${"', cm.exception.msg) - - def test_empty_environment_key_allowed(self): - service_dict = config.load( - build_config_details( - { - 'web': { - 'build': '.', - 'environment': { - 'POSTGRES_PASSWORD': '' - }, - }, - }, - '.', - None, - ) - ).services[0] - self.assertEquals(service_dict['environment']['POSTGRES_PASSWORD'], '') - - -class VolumeConfigTest(unittest.TestCase): - def test_no_binding(self): - d = make_service_dict('foo', {'build': '.', 'volumes': ['/data']}, working_dir='.') - self.assertEqual(d['volumes'], ['/data']) - - @mock.patch.dict(os.environ) - def test_volume_binding_with_environment_variable(self): - os.environ['VOLUME_PATH'] = '/host/path' - - d = config.load( - build_config_details( - {'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}}, - '.', - None, - ) - ).services[0] - self.assertEqual(d['volumes'], [VolumeSpec.parse('/host/path:/container/path')]) - - @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') - @mock.patch.dict(os.environ) - def test_volume_binding_with_home(self): - os.environ['HOME'] = '/home/user' - d = make_service_dict('foo', {'build': '.', 'volumes': ['~:/container/path']}, working_dir='.') - self.assertEqual(d['volumes'], ['/home/user:/container/path']) - - def test_name_does_not_expand(self): - d = make_service_dict('foo', {'build': '.', 'volumes': ['mydatavolume:/data']}, working_dir='.') - self.assertEqual(d['volumes'], ['mydatavolume:/data']) - - def test_absolute_posix_path_does_not_expand(self): - d = make_service_dict('foo', {'build': '.', 'volumes': ['/var/lib/data:/data']}, working_dir='.') - self.assertEqual(d['volumes'], ['/var/lib/data:/data']) - - def test_absolute_windows_path_does_not_expand(self): - d = make_service_dict('foo', {'build': '.', 'volumes': ['c:\\data:/data']}, working_dir='.') - self.assertEqual(d['volumes'], ['c:\\data:/data']) - - @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths') - def test_relative_path_does_expand_posix(self): - d = make_service_dict( - 'foo', - {'build': '.', 'volumes': ['./data:/data']}, - working_dir='/home/me/myproject') - self.assertEqual(d['volumes'], ['/home/me/myproject/data:/data']) - - d = make_service_dict( - 'foo', - {'build': '.', 'volumes': ['.:/data']}, - working_dir='/home/me/myproject') - self.assertEqual(d['volumes'], ['/home/me/myproject:/data']) - - d = make_service_dict( - 'foo', - {'build': '.', 'volumes': ['../otherproject:/data']}, - working_dir='/home/me/myproject') - self.assertEqual(d['volumes'], ['/home/me/otherproject:/data']) - - @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='windows paths') - def test_relative_path_does_expand_windows(self): - d = make_service_dict( - 'foo', - {'build': '.', 'volumes': ['./data:/data']}, - working_dir='c:\\Users\\me\\myproject') - self.assertEqual(d['volumes'], ['c:\\Users\\me\\myproject\\data:/data']) - - d = make_service_dict( - 'foo', - {'build': '.', 'volumes': ['.:/data']}, - working_dir='c:\\Users\\me\\myproject') - self.assertEqual(d['volumes'], ['c:\\Users\\me\\myproject:/data']) - - d = make_service_dict( - 'foo', - {'build': '.', 'volumes': ['../otherproject:/data']}, - working_dir='c:\\Users\\me\\myproject') - self.assertEqual(d['volumes'], ['c:\\Users\\me\\otherproject:/data']) - - @mock.patch.dict(os.environ) - def test_home_directory_with_driver_does_not_expand(self): - os.environ['NAME'] = 'surprise!' - d = make_service_dict('foo', { - 'build': '.', - 'volumes': ['~:/data'], - 'volume_driver': 'foodriver', - }, working_dir='.') - self.assertEqual(d['volumes'], ['~:/data']) - - def test_volume_path_with_non_ascii_directory(self): - volume = u'/Füü/data:/data' - container_path = config.resolve_volume_path(".", volume) - self.assertEqual(container_path, volume) - - -class MergePathMappingTest(object): - config_name = "" - - def test_empty(self): - service_dict = config.merge_service_dicts({}, {}, DEFAULT_VERSION) - assert self.config_name not in service_dict - - def test_no_override(self): - service_dict = config.merge_service_dicts( - {self.config_name: ['/foo:/code', '/data']}, - {}, - DEFAULT_VERSION) - assert set(service_dict[self.config_name]) == set(['/foo:/code', '/data']) - - def test_no_base(self): - service_dict = config.merge_service_dicts( - {}, - {self.config_name: ['/bar:/code']}, - DEFAULT_VERSION) - assert set(service_dict[self.config_name]) == set(['/bar:/code']) - - def test_override_explicit_path(self): - service_dict = config.merge_service_dicts( - {self.config_name: ['/foo:/code', '/data']}, - {self.config_name: ['/bar:/code']}, - DEFAULT_VERSION) - assert set(service_dict[self.config_name]) == set(['/bar:/code', '/data']) - - def test_add_explicit_path(self): - service_dict = config.merge_service_dicts( - {self.config_name: ['/foo:/code', '/data']}, - {self.config_name: ['/bar:/code', '/quux:/data']}, - DEFAULT_VERSION) - assert set(service_dict[self.config_name]) == set(['/bar:/code', '/quux:/data']) - - def test_remove_explicit_path(self): - service_dict = config.merge_service_dicts( - {self.config_name: ['/foo:/code', '/quux:/data']}, - {self.config_name: ['/bar:/code', '/data']}, - DEFAULT_VERSION) - assert set(service_dict[self.config_name]) == set(['/bar:/code', '/data']) - - -class MergeVolumesTest(unittest.TestCase, MergePathMappingTest): - config_name = 'volumes' - - -class MergeDevicesTest(unittest.TestCase, MergePathMappingTest): - config_name = 'devices' - - -class BuildOrImageMergeTest(unittest.TestCase): - def test_merge_build_or_image_no_override(self): - self.assertEqual( - config.merge_service_dicts({'build': '.'}, {}, V1), - {'build': '.'}, - ) - - self.assertEqual( - config.merge_service_dicts({'image': 'redis'}, {}, V1), - {'image': 'redis'}, - ) - - def test_merge_build_or_image_override_with_same(self): - self.assertEqual( - config.merge_service_dicts({'build': '.'}, {'build': './web'}, V1), - {'build': './web'}, - ) - - self.assertEqual( - config.merge_service_dicts({'image': 'redis'}, {'image': 'postgres'}, V1), - {'image': 'postgres'}, - ) - - def test_merge_build_or_image_override_with_other(self): - self.assertEqual( - config.merge_service_dicts({'build': '.'}, {'image': 'redis'}, V1), - {'image': 'redis'}, - ) - - self.assertEqual( - config.merge_service_dicts({'image': 'redis'}, {'build': '.'}, V1), - {'build': '.'} - ) - - -class MergeListsTest(object): - config_name = "" - base_config = [] - override_config = [] - - def merged_config(self): - return set(self.base_config) | set(self.override_config) - - def test_empty(self): - assert self.config_name not in config.merge_service_dicts({}, {}, DEFAULT_VERSION) - - def test_no_override(self): - service_dict = config.merge_service_dicts( - {self.config_name: self.base_config}, - {}, - DEFAULT_VERSION) - assert set(service_dict[self.config_name]) == set(self.base_config) - - def test_no_base(self): - service_dict = config.merge_service_dicts( - {}, - {self.config_name: self.base_config}, - DEFAULT_VERSION) - assert set(service_dict[self.config_name]) == set(self.base_config) - - def test_add_item(self): - service_dict = config.merge_service_dicts( - {self.config_name: self.base_config}, - {self.config_name: self.override_config}, - DEFAULT_VERSION) - assert set(service_dict[self.config_name]) == set(self.merged_config()) - - -class MergePortsTest(unittest.TestCase, MergeListsTest): - config_name = 'ports' - base_config = ['10:8000', '9000'] - override_config = ['20:8000'] - - def test_duplicate_port_mappings(self): - service_dict = config.merge_service_dicts( - {self.config_name: self.base_config}, - {self.config_name: self.base_config}, - DEFAULT_VERSION - ) - assert set(service_dict[self.config_name]) == set(self.base_config) - - -class MergeNetworksTest(unittest.TestCase, MergeListsTest): - config_name = 'networks' - base_config = ['frontend', 'backend'] - override_config = ['monitoring'] - - -class MergeStringsOrListsTest(unittest.TestCase): - def test_no_override(self): - service_dict = config.merge_service_dicts( - {'dns': '8.8.8.8'}, - {}, - DEFAULT_VERSION) - assert set(service_dict['dns']) == set(['8.8.8.8']) - - def test_no_base(self): - service_dict = config.merge_service_dicts( - {}, - {'dns': '8.8.8.8'}, - DEFAULT_VERSION) - assert set(service_dict['dns']) == set(['8.8.8.8']) - - def test_add_string(self): - service_dict = config.merge_service_dicts( - {'dns': ['8.8.8.8']}, - {'dns': '9.9.9.9'}, - DEFAULT_VERSION) - assert set(service_dict['dns']) == set(['8.8.8.8', '9.9.9.9']) - - def test_add_list(self): - service_dict = config.merge_service_dicts( - {'dns': '8.8.8.8'}, - {'dns': ['9.9.9.9']}, - DEFAULT_VERSION) - assert set(service_dict['dns']) == set(['8.8.8.8', '9.9.9.9']) - - -class MergeLabelsTest(unittest.TestCase): - def test_empty(self): - assert 'labels' not in config.merge_service_dicts({}, {}, DEFAULT_VERSION) - - def test_no_override(self): - service_dict = config.merge_service_dicts( - make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'), - make_service_dict('foo', {'build': '.'}, 'tests/'), - DEFAULT_VERSION) - assert service_dict['labels'] == {'foo': '1', 'bar': ''} - - def test_no_base(self): - service_dict = config.merge_service_dicts( - make_service_dict('foo', {'build': '.'}, 'tests/'), - make_service_dict('foo', {'build': '.', 'labels': ['foo=2']}, 'tests/'), - DEFAULT_VERSION) - assert service_dict['labels'] == {'foo': '2'} - - def test_override_explicit_value(self): - service_dict = config.merge_service_dicts( - make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'), - make_service_dict('foo', {'build': '.', 'labels': ['foo=2']}, 'tests/'), - DEFAULT_VERSION) - assert service_dict['labels'] == {'foo': '2', 'bar': ''} - - def test_add_explicit_value(self): - service_dict = config.merge_service_dicts( - make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'), - make_service_dict('foo', {'build': '.', 'labels': ['bar=2']}, 'tests/'), - DEFAULT_VERSION) - assert service_dict['labels'] == {'foo': '1', 'bar': '2'} - - def test_remove_explicit_value(self): - service_dict = config.merge_service_dicts( - make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar=2']}, 'tests/'), - make_service_dict('foo', {'build': '.', 'labels': ['bar']}, 'tests/'), - DEFAULT_VERSION) - assert service_dict['labels'] == {'foo': '1', 'bar': ''} - - -class MemoryOptionsTest(unittest.TestCase): - def test_validation_fails_with_just_memswap_limit(self): - """ - When you set a 'memswap_limit' it is invalid config unless you also set - a mem_limit - """ - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - { - 'foo': {'image': 'busybox', 'memswap_limit': 2000000}, - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ) - - assert "foo.memswap_limit is invalid: when defining " \ - "'memswap_limit' you must set 'mem_limit' as well" \ - in excinfo.exconly() - - def test_validation_with_correct_memswap_values(self): - service_dict = config.load( - build_config_details( - {'foo': {'image': 'busybox', 'mem_limit': 1000000, 'memswap_limit': 2000000}}, - 'tests/fixtures/extends', - 'common.yml' - ) - ).services - self.assertEqual(service_dict[0]['memswap_limit'], 2000000) - - def test_memswap_can_be_a_string(self): - service_dict = config.load( - build_config_details( - {'foo': {'image': 'busybox', 'mem_limit': "1G", 'memswap_limit': "512M"}}, - 'tests/fixtures/extends', - 'common.yml' - ) - ).services - self.assertEqual(service_dict[0]['memswap_limit'], "512M") - - -class EnvTest(unittest.TestCase): - def test_parse_environment_as_list(self): - environment = [ - 'NORMAL=F1', - 'CONTAINS_EQUALS=F=2', - 'TRAILING_EQUALS=', - ] - self.assertEqual( - config.parse_environment(environment), - {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''}, - ) - - def test_parse_environment_as_dict(self): - environment = { - 'NORMAL': 'F1', - 'CONTAINS_EQUALS': 'F=2', - 'TRAILING_EQUALS': None, - } - self.assertEqual(config.parse_environment(environment), environment) - - def test_parse_environment_invalid(self): - with self.assertRaises(ConfigurationError): - config.parse_environment('a=b') - - def test_parse_environment_empty(self): - self.assertEqual(config.parse_environment(None), {}) - - @mock.patch.dict(os.environ) - def test_resolve_environment(self): - os.environ['FILE_DEF'] = 'E1' - os.environ['FILE_DEF_EMPTY'] = 'E2' - os.environ['ENV_DEF'] = 'E3' - - service_dict = { - 'build': '.', - 'environment': { - 'FILE_DEF': 'F1', - 'FILE_DEF_EMPTY': '', - 'ENV_DEF': None, - 'NO_DEF': None - }, - } - self.assertEqual( - resolve_environment( - service_dict, Environment.from_env_file(None) - ), - {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': None}, - ) - - def test_resolve_environment_from_env_file(self): - self.assertEqual( - resolve_environment({'env_file': ['tests/fixtures/env/one.env']}), - {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'bar'}, - ) - - def test_environment_overrides_env_file(self): - self.assertEqual( - resolve_environment({ - 'environment': {'FOO': 'baz'}, - 'env_file': ['tests/fixtures/env/one.env'], - }), - {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz'}, - ) - - def test_resolve_environment_with_multiple_env_files(self): - service_dict = { - 'env_file': [ - 'tests/fixtures/env/one.env', - 'tests/fixtures/env/two.env' - ] - } - self.assertEqual( - resolve_environment(service_dict), - {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}, - ) - - def test_resolve_environment_nonexistent_file(self): - with pytest.raises(ConfigurationError) as exc: - config.load(build_config_details( - {'foo': {'image': 'example', 'env_file': 'nonexistent.env'}}, - working_dir='tests/fixtures/env')) - - assert 'Couldn\'t find env file' in exc.exconly() - assert 'nonexistent.env' in exc.exconly() - - @mock.patch.dict(os.environ) - def test_resolve_environment_from_env_file_with_empty_values(self): - os.environ['FILE_DEF'] = 'E1' - os.environ['FILE_DEF_EMPTY'] = 'E2' - os.environ['ENV_DEF'] = 'E3' - self.assertEqual( - resolve_environment( - {'env_file': ['tests/fixtures/env/resolve.env']}, - Environment.from_env_file(None) - ), - { - 'FILE_DEF': u'bär', - 'FILE_DEF_EMPTY': '', - 'ENV_DEF': 'E3', - 'NO_DEF': None - }, - ) - - @mock.patch.dict(os.environ) - def test_resolve_build_args(self): - os.environ['env_arg'] = 'value2' - - build = { - 'context': '.', - 'args': { - 'arg1': 'value1', - 'empty_arg': '', - 'env_arg': None, - 'no_env': None - } - } - self.assertEqual( - resolve_build_args(build, Environment.from_env_file(build['context'])), - {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': None}, - ) - - @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') - @mock.patch.dict(os.environ) - def test_resolve_path(self): - os.environ['HOSTENV'] = '/tmp' - os.environ['CONTAINERENV'] = '/host/tmp' - - service_dict = config.load( - build_config_details( - {'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}}, - "tests/fixtures/env", - ) - ).services[0] - self.assertEqual( - set(service_dict['volumes']), - set([VolumeSpec.parse('/tmp:/host/tmp')])) - - service_dict = config.load( - build_config_details( - {'foo': {'build': '.', 'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}}, - "tests/fixtures/env", - ) - ).services[0] - self.assertEqual( - set(service_dict['volumes']), - set([VolumeSpec.parse('/opt/tmp:/opt/host/tmp')])) - - -def load_from_filename(filename): - return config.load( - config.find('.', [filename], Environment.from_env_file('.')) - ).services - - -class ExtendsTest(unittest.TestCase): - def test_extends(self): - service_dicts = load_from_filename('tests/fixtures/extends/docker-compose.yml') - - self.assertEqual(service_sort(service_dicts), service_sort([ - { - 'name': 'mydb', - 'image': 'busybox', - 'command': 'top', - }, - { - 'name': 'myweb', - 'image': 'busybox', - 'command': 'top', - 'network_mode': 'bridge', - 'links': ['mydb:db'], - 'environment': { - "FOO": "1", - "BAR": "2", - "BAZ": "2", - }, - } - ])) - - def test_merging_env_labels_ulimits(self): - service_dicts = load_from_filename('tests/fixtures/extends/common-env-labels-ulimits.yml') - - self.assertEqual(service_sort(service_dicts), service_sort([ - { - 'name': 'web', - 'image': 'busybox', - 'command': '/bin/true', - 'network_mode': 'host', - 'environment': { - "FOO": "2", - "BAR": "1", - "BAZ": "3", - }, - 'labels': {'label': 'one'}, - 'ulimits': {'nproc': 65535, 'memlock': {'soft': 1024, 'hard': 2048}} - } - ])) - - def test_nested(self): - service_dicts = load_from_filename('tests/fixtures/extends/nested.yml') - - self.assertEqual(service_dicts, [ - { - 'name': 'myweb', - 'image': 'busybox', - 'command': '/bin/true', - 'network_mode': 'host', - 'environment': { - "FOO": "2", - "BAR": "2", - }, - }, - ]) - - def test_self_referencing_file(self): - """ - We specify a 'file' key that is the filename we're already in. - """ - service_dicts = load_from_filename('tests/fixtures/extends/specify-file-as-self.yml') - self.assertEqual(service_sort(service_dicts), service_sort([ - { - 'environment': - { - 'YEP': '1', 'BAR': '1', 'BAZ': '3' - }, - 'image': 'busybox', - 'name': 'myweb' - }, - { - 'environment': - {'YEP': '1'}, - 'image': 'busybox', - 'name': 'otherweb' - }, - { - 'environment': - {'YEP': '1', 'BAZ': '3'}, - 'image': 'busybox', - 'name': 'web' - } - ])) - - def test_circular(self): - with pytest.raises(config.CircularReference) as exc: - load_from_filename('tests/fixtures/extends/circle-1.yml') - - path = [ - (os.path.basename(filename), service_name) - for (filename, service_name) in exc.value.trail - ] - expected = [ - ('circle-1.yml', 'web'), - ('circle-2.yml', 'other'), - ('circle-1.yml', 'web'), - ] - self.assertEqual(path, expected) - - def test_extends_validation_empty_dictionary(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - { - 'web': {'image': 'busybox', 'extends': {}}, - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ) - - assert 'service' in excinfo.exconly() - - def test_extends_validation_missing_service_key(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - { - 'web': {'image': 'busybox', 'extends': {'file': 'common.yml'}}, - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ) - - assert "'service' is a required property" in excinfo.exconly() - - def test_extends_validation_invalid_key(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - { - 'web': { - 'image': 'busybox', - 'extends': { - 'file': 'common.yml', - 'service': 'web', - 'rogue_key': 'is not allowed' - } - }, - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ) - - assert "web.extends contains unsupported option: 'rogue_key'" \ - in excinfo.exconly() - - def test_extends_validation_sub_property_key(self): - with pytest.raises(ConfigurationError) as excinfo: - config.load( - build_config_details( - { - 'web': { - 'image': 'busybox', - 'extends': { - 'file': 1, - 'service': 'web', - } - }, - }, - 'tests/fixtures/extends', - 'filename.yml' - ) - ) - - assert "web.extends.file contains 1, which is an invalid type, it should be a string" \ - in excinfo.exconly() - - def test_extends_validation_no_file_key_no_filename_set(self): - dictionary = {'extends': {'service': 'web'}} - - with pytest.raises(ConfigurationError) as excinfo: - make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends') - - assert 'file' in excinfo.exconly() - - def test_extends_validation_valid_config(self): - service = config.load( - build_config_details( - { - 'web': {'image': 'busybox', 'extends': {'service': 'web', 'file': 'common.yml'}}, - }, - 'tests/fixtures/extends', - 'common.yml' - ) - ).services - - self.assertEquals(len(service), 1) - self.assertIsInstance(service[0], dict) - self.assertEquals(service[0]['command'], "/bin/true") - - def test_extended_service_with_invalid_config(self): - with pytest.raises(ConfigurationError) as exc: - load_from_filename('tests/fixtures/extends/service-with-invalid-schema.yml') - assert ( - "myweb has neither an image nor a build context specified" in - exc.exconly() - ) - - def test_extended_service_with_valid_config(self): - service = load_from_filename('tests/fixtures/extends/service-with-valid-composite-extends.yml') - self.assertEquals(service[0]['command'], "top") - - def test_extends_file_defaults_to_self(self): - """ - Test not specifying a file in our extends options that the - config is valid and correctly extends from itself. - """ - service_dicts = load_from_filename('tests/fixtures/extends/no-file-specified.yml') - self.assertEqual(service_sort(service_dicts), service_sort([ - { - 'name': 'myweb', - 'image': 'busybox', - 'environment': { - "BAR": "1", - "BAZ": "3", - } - }, - { - 'name': 'web', - 'image': 'busybox', - 'environment': { - "BAZ": "3", - } - } - ])) - - def test_invalid_links_in_extended_service(self): - with pytest.raises(ConfigurationError) as excinfo: - load_from_filename('tests/fixtures/extends/invalid-links.yml') - - assert "services with 'links' cannot be extended" in excinfo.exconly() - - def test_invalid_volumes_from_in_extended_service(self): - with pytest.raises(ConfigurationError) as excinfo: - load_from_filename('tests/fixtures/extends/invalid-volumes.yml') - - assert "services with 'volumes_from' cannot be extended" in excinfo.exconly() - - def test_invalid_net_in_extended_service(self): - with pytest.raises(ConfigurationError) as excinfo: - load_from_filename('tests/fixtures/extends/invalid-net-v2.yml') - - assert 'network_mode: service' in excinfo.exconly() - assert 'cannot be extended' in excinfo.exconly() - - with pytest.raises(ConfigurationError) as excinfo: - load_from_filename('tests/fixtures/extends/invalid-net.yml') - - assert 'net: container' in excinfo.exconly() - assert 'cannot be extended' in excinfo.exconly() - - @mock.patch.dict(os.environ) - def test_load_config_runs_interpolation_in_extended_service(self): - os.environ.update(HOSTNAME_VALUE="penguin") - expected_interpolated_value = "host-penguin" - service_dicts = load_from_filename( - 'tests/fixtures/extends/valid-interpolation.yml') - for service in service_dicts: - assert service['hostname'] == expected_interpolated_value - - @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') - def test_volume_path(self): - dicts = load_from_filename('tests/fixtures/volume-path/docker-compose.yml') - - paths = [ - VolumeSpec( - os.path.abspath('tests/fixtures/volume-path/common/foo'), - '/foo', - 'rw'), - VolumeSpec( - os.path.abspath('tests/fixtures/volume-path/bar'), - '/bar', - 'rw') - ] - - self.assertEqual(set(dicts[0]['volumes']), set(paths)) - - def test_parent_build_path_dne(self): - child = load_from_filename('tests/fixtures/extends/nonexistent-path-child.yml') - - self.assertEqual(child, [ - { - 'name': 'dnechild', - 'image': 'busybox', - 'command': '/bin/true', - 'environment': { - "FOO": "1", - "BAR": "2", - }, - }, - ]) - - def test_load_throws_error_when_base_service_does_not_exist(self): - with pytest.raises(ConfigurationError) as excinfo: - load_from_filename('tests/fixtures/extends/nonexistent-service.yml') - - assert "Cannot extend service 'foo'" in excinfo.exconly() - assert "Service not found" in excinfo.exconly() - - def test_partial_service_config_in_extends_is_still_valid(self): - dicts = load_from_filename('tests/fixtures/extends/valid-common-config.yml') - self.assertEqual(dicts[0]['environment'], {'FOO': '1'}) - - def test_extended_service_with_verbose_and_shorthand_way(self): - services = load_from_filename('tests/fixtures/extends/verbose-and-shorthand.yml') - self.assertEqual(service_sort(services), service_sort([ - { - 'name': 'base', - 'image': 'busybox', - 'environment': {'BAR': '1'}, - }, - { - 'name': 'verbose', - 'image': 'busybox', - 'environment': {'BAR': '1', 'FOO': '1'}, - }, - { - 'name': 'shorthand', - 'image': 'busybox', - 'environment': {'BAR': '1', 'FOO': '2'}, - }, - ])) - - @mock.patch.dict(os.environ) - def test_extends_with_environment_and_env_files(self): - tmpdir = py.test.ensuretemp('test_extends_with_environment') - self.addCleanup(tmpdir.remove) - commondir = tmpdir.mkdir('common') - commondir.join('base.yml').write(""" - app: - image: 'example/app' - env_file: - - 'envs' - environment: - - SECRET - - TEST_ONE=common - - TEST_TWO=common - """) - tmpdir.join('docker-compose.yml').write(""" - ext: - extends: - file: common/base.yml - service: app - env_file: - - 'envs' - environment: - - THING - - TEST_ONE=top - """) - commondir.join('envs').write(""" - COMMON_ENV_FILE - TEST_ONE=common-env-file - TEST_TWO=common-env-file - TEST_THREE=common-env-file - TEST_FOUR=common-env-file - """) - tmpdir.join('envs').write(""" - TOP_ENV_FILE - TEST_ONE=top-env-file - TEST_TWO=top-env-file - TEST_THREE=top-env-file - """) - - expected = [ - { - 'name': 'ext', - 'image': 'example/app', - 'environment': { - 'SECRET': 'secret', - 'TOP_ENV_FILE': 'secret', - 'COMMON_ENV_FILE': 'secret', - 'THING': 'thing', - 'TEST_ONE': 'top', - 'TEST_TWO': 'common', - 'TEST_THREE': 'top-env-file', - 'TEST_FOUR': 'common-env-file', - }, - }, - ] - - os.environ['SECRET'] = 'secret' - os.environ['THING'] = 'thing' - os.environ['COMMON_ENV_FILE'] = 'secret' - os.environ['TOP_ENV_FILE'] = 'secret' - config = load_from_filename(str(tmpdir.join('docker-compose.yml'))) - - assert config == expected - - def test_extends_with_mixed_versions_is_error(self): - tmpdir = py.test.ensuretemp('test_extends_with_mixed_version') - self.addCleanup(tmpdir.remove) - tmpdir.join('docker-compose.yml').write(""" - version: "2" - services: - web: - extends: - file: base.yml - service: base - image: busybox - """) - tmpdir.join('base.yml').write(""" - base: - volumes: ['/foo'] - ports: ['3000:3000'] - """) - - with pytest.raises(ConfigurationError) as exc: - load_from_filename(str(tmpdir.join('docker-compose.yml'))) - assert 'Version mismatch' in exc.exconly() - - def test_extends_with_defined_version_passes(self): - tmpdir = py.test.ensuretemp('test_extends_with_defined_version') - self.addCleanup(tmpdir.remove) - tmpdir.join('docker-compose.yml').write(""" - version: "2" - services: - web: - extends: - file: base.yml - service: base - image: busybox - """) - tmpdir.join('base.yml').write(""" - version: "2" - services: - base: - volumes: ['/foo'] - ports: ['3000:3000'] - command: top - """) - - service = load_from_filename(str(tmpdir.join('docker-compose.yml'))) - self.assertEquals(service[0]['command'], "top") - - def test_extends_with_depends_on(self): - tmpdir = py.test.ensuretemp('test_extends_with_defined_version') - self.addCleanup(tmpdir.remove) - tmpdir.join('docker-compose.yml').write(""" - version: "2" - services: - base: - image: example - web: - extends: base - image: busybox - depends_on: ['other'] - other: - image: example - """) - services = load_from_filename(str(tmpdir.join('docker-compose.yml'))) - assert service_sort(services)[2]['depends_on'] == { - 'other': {'condition': 'service_started'} - } - - def test_extends_with_healthcheck(self): - service_dicts = load_from_filename('tests/fixtures/extends/healthcheck-2.yml') - assert service_sort(service_dicts) == [{ - 'name': 'demo', - 'image': 'foobar:latest', - 'healthcheck': { - 'test': ['CMD', '/health.sh'], - 'interval': 10000000000, - 'timeout': 5000000000, - 'retries': 36, - } - }] - - -@pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') -class ExpandPathTest(unittest.TestCase): - working_dir = '/home/user/somedir' - - def test_expand_path_normal(self): - result = config.expand_path(self.working_dir, 'myfile') - self.assertEqual(result, self.working_dir + '/' + 'myfile') - - def test_expand_path_absolute(self): - abs_path = '/home/user/otherdir/somefile' - result = config.expand_path(self.working_dir, abs_path) - self.assertEqual(result, abs_path) - - def test_expand_path_with_tilde(self): - test_path = '~/otherdir/somefile' - with mock.patch.dict(os.environ): - os.environ['HOME'] = user_path = '/home/user/' - result = config.expand_path(self.working_dir, test_path) - - self.assertEqual(result, user_path + 'otherdir/somefile') - - -class VolumePathTest(unittest.TestCase): - def test_split_path_mapping_with_windows_path(self): - host_path = "c:\\Users\\msamblanet\\Documents\\anvil\\connect\\config" - windows_volume_path = host_path + ":/opt/connect/config:ro" - expected_mapping = ("/opt/connect/config:ro", host_path) - - mapping = config.split_path_mapping(windows_volume_path) - assert mapping == expected_mapping - - def test_split_path_mapping_with_windows_path_in_container(self): - host_path = 'c:\\Users\\remilia\\data' - container_path = 'c:\\scarletdevil\\data' - expected_mapping = (container_path, host_path) - - mapping = config.split_path_mapping('{0}:{1}'.format(host_path, container_path)) - assert mapping == expected_mapping - - def test_split_path_mapping_with_root_mount(self): - host_path = '/' - container_path = '/var/hostroot' - expected_mapping = (container_path, host_path) - mapping = config.split_path_mapping('{0}:{1}'.format(host_path, container_path)) - assert mapping == expected_mapping - - -@pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash') -class BuildPathTest(unittest.TestCase): - def setUp(self): - self.abs_context_path = os.path.join(os.getcwd(), 'tests/fixtures/build-ctx') - - def test_nonexistent_path(self): - with self.assertRaises(ConfigurationError): - config.load( - build_config_details( - { - 'foo': {'build': 'nonexistent.path'}, - }, - 'working_dir', - 'filename.yml' - ) - ) - - def test_relative_path(self): - relative_build_path = '../build-ctx/' - service_dict = make_service_dict( - 'relpath', - {'build': relative_build_path}, - working_dir='tests/fixtures/build-path' - ) - self.assertEquals(service_dict['build'], self.abs_context_path) - - def test_absolute_path(self): - service_dict = make_service_dict( - 'abspath', - {'build': self.abs_context_path}, - working_dir='tests/fixtures/build-path' - ) - self.assertEquals(service_dict['build'], self.abs_context_path) - - def test_from_file(self): - service_dict = load_from_filename('tests/fixtures/build-path/docker-compose.yml') - self.assertEquals(service_dict, [{'name': 'foo', 'build': {'context': self.abs_context_path}}]) - - def test_valid_url_in_build_path(self): - valid_urls = [ - 'git://github.com/docker/docker', - 'git@github.com:docker/docker.git', - 'git@bitbucket.org:atlassianlabs/atlassian-docker.git', - 'https://github.com/docker/docker.git', - 'http://github.com/docker/docker.git', - 'github.com/docker/docker.git', - ] - for valid_url in valid_urls: - service_dict = config.load(build_config_details({ - 'validurl': {'build': valid_url}, - }, '.', None)).services - assert service_dict[0]['build'] == {'context': valid_url} - - def test_invalid_url_in_build_path(self): - invalid_urls = [ - 'example.com/bogus', - 'ftp://example.com/', - '/path/does/not/exist', - ] - for invalid_url in invalid_urls: - with pytest.raises(ConfigurationError) as exc: - config.load(build_config_details({ - 'invalidurl': {'build': invalid_url}, - }, '.', None)) - assert 'build path' in exc.exconly() - - -class HealthcheckTest(unittest.TestCase): - def test_healthcheck(self): - service_dict = make_service_dict( - 'test', - {'healthcheck': { - 'test': ['CMD', 'true'], - 'interval': '1s', - 'timeout': '1m', - 'retries': 3, - }}, - '.', - ) - - assert service_dict['healthcheck'] == { - 'test': ['CMD', 'true'], - 'interval': nanoseconds_from_time_seconds(1), - 'timeout': nanoseconds_from_time_seconds(60), - 'retries': 3, - } - - def test_disable(self): - service_dict = make_service_dict( - 'test', - {'healthcheck': { - 'disable': True, - }}, - '.', - ) - - assert service_dict['healthcheck'] == { - 'test': ['NONE'], - } - - def test_disable_with_other_config_is_invalid(self): - with pytest.raises(ConfigurationError) as excinfo: - make_service_dict( - 'invalid-healthcheck', - {'healthcheck': { - 'disable': True, - 'interval': '1s', - }}, - '.', - ) - - assert 'invalid-healthcheck' in excinfo.exconly() - assert 'disable' in excinfo.exconly() - - -class GetDefaultConfigFilesTestCase(unittest.TestCase): - - files = [ - 'docker-compose.yml', - 'docker-compose.yaml', - ] - - def test_get_config_path_default_file_in_basedir(self): - for index, filename in enumerate(self.files): - self.assertEqual( - filename, - get_config_filename_for_files(self.files[index:])) - with self.assertRaises(config.ComposeFileNotFound): - get_config_filename_for_files([]) - - def test_get_config_path_default_file_in_parent_dir(self): - """Test with files placed in the subdir""" - - def get_config_in_subdir(files): - return get_config_filename_for_files(files, subdir=True) - - for index, filename in enumerate(self.files): - self.assertEqual(filename, get_config_in_subdir(self.files[index:])) - with self.assertRaises(config.ComposeFileNotFound): - get_config_in_subdir([]) - - -def get_config_filename_for_files(filenames, subdir=None): - def make_files(dirname, filenames): - for fname in filenames: - with open(os.path.join(dirname, fname), 'w') as f: - f.write('') - - project_dir = tempfile.mkdtemp() - try: - make_files(project_dir, filenames) - if subdir: - base_dir = tempfile.mkdtemp(dir=project_dir) - else: - base_dir = project_dir - filename, = config.get_default_config_files(base_dir) - return os.path.basename(filename) - finally: - shutil.rmtree(project_dir) - - -class SerializeTest(unittest.TestCase): - def test_denormalize_depends_on_v3(self): - service_dict = { - 'image': 'busybox', - 'command': 'true', - 'depends_on': { - 'service2': {'condition': 'service_started'}, - 'service3': {'condition': 'service_started'}, - } - } - - assert denormalize_service_dict(service_dict, V3_0) == { - 'image': 'busybox', - 'command': 'true', - 'depends_on': ['service2', 'service3'] - } - - def test_denormalize_depends_on_v2_1(self): - service_dict = { - 'image': 'busybox', - 'command': 'true', - 'depends_on': { - 'service2': {'condition': 'service_started'}, - 'service3': {'condition': 'service_started'}, - } - } - - assert denormalize_service_dict(service_dict, V2_1) == service_dict - - def test_serialize_time(self): - data = { - 9: '9ns', - 9000: '9us', - 9000000: '9ms', - 90000000: '90ms', - 900000000: '900ms', - 999999999: '999999999ns', - 1000000000: '1s', - 60000000000: '1m', - 60000000001: '60000000001ns', - 9000000000000: '150m', - 90000000000000: '25h', - } - - for k, v in data.items(): - assert serialize_ns_time_value(k) == v - - def test_denormalize_healthcheck(self): - service_dict = { - 'image': 'test', - 'healthcheck': { - 'test': 'exit 1', - 'interval': '1m40s', - 'timeout': '30s', - 'retries': 5 - } - } - processed_service = config.process_service(config.ServiceConfig( - '.', 'test', 'test', service_dict - )) - denormalized_service = denormalize_service_dict(processed_service, V2_1) - assert denormalized_service['healthcheck']['interval'] == '100s' - assert denormalized_service['healthcheck']['timeout'] == '30s' - - def test_denormalize_secrets(self): - service_dict = { - 'name': 'web', - 'image': 'example/web', - 'secrets': [ - types.ServiceSecret('one', None, None, None, None), - types.ServiceSecret('source', 'target', '100', '200', 0o777), - ], - } - denormalized_service = denormalize_service_dict(service_dict, V3_1) - assert secret_sort(denormalized_service['secrets']) == secret_sort([ - {'source': 'one'}, - { - 'source': 'source', - 'target': 'target', - 'uid': '100', - 'gid': '200', - 'mode': 0o777, - }, - ]) diff --git a/tests/unit/config/environment_test.py b/tests/unit/config/environment_test.py deleted file mode 100644 index 20446d2b..00000000 --- a/tests/unit/config/environment_test.py +++ /dev/null @@ -1,40 +0,0 @@ -# encoding: utf-8 -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - -from compose.config.environment import Environment -from tests import unittest - - -class EnvironmentTest(unittest.TestCase): - def test_get_simple(self): - env = Environment({ - 'FOO': 'bar', - 'BAR': '1', - 'BAZ': '' - }) - - assert env.get('FOO') == 'bar' - assert env.get('BAR') == '1' - assert env.get('BAZ') == '' - - def test_get_undefined(self): - env = Environment({ - 'FOO': 'bar' - }) - assert env.get('FOOBAR') is None - - def test_get_boolean(self): - env = Environment({ - 'FOO': '', - 'BAR': '0', - 'BAZ': 'FALSE', - 'FOOBAR': 'true', - }) - - assert env.get_boolean('FOO') is False - assert env.get_boolean('BAR') is False - assert env.get_boolean('BAZ') is False - assert env.get_boolean('FOOBAR') is True - assert env.get_boolean('UNDEFINED') is False diff --git a/tests/unit/config/interpolation_test.py b/tests/unit/config/interpolation_test.py deleted file mode 100644 index fd40153d..00000000 --- a/tests/unit/config/interpolation_test.py +++ /dev/null @@ -1,121 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import pytest - -from compose.config.environment import Environment -from compose.config.interpolation import interpolate_environment_variables -from compose.config.interpolation import Interpolator -from compose.config.interpolation import InvalidInterpolation -from compose.config.interpolation import TemplateWithDefaults - - -@pytest.fixture -def mock_env(): - return Environment({'USER': 'jenny', 'FOO': 'bar'}) - - -@pytest.fixture -def variable_mapping(): - return Environment({'FOO': 'first', 'BAR': ''}) - - -@pytest.fixture -def defaults_interpolator(variable_mapping): - return Interpolator(TemplateWithDefaults, variable_mapping).interpolate - - -def test_interpolate_environment_variables_in_services(mock_env): - services = { - 'servicea': { - 'image': 'example:${USER}', - 'volumes': ['$FOO:/target'], - 'logging': { - 'driver': '${FOO}', - 'options': { - 'user': '$USER', - } - } - } - } - expected = { - 'servicea': { - 'image': 'example:jenny', - 'volumes': ['bar:/target'], - 'logging': { - 'driver': 'bar', - 'options': { - 'user': 'jenny', - } - } - } - } - value = interpolate_environment_variables("2.0", services, 'service', mock_env) - assert value == expected - - -def test_interpolate_environment_variables_in_volumes(mock_env): - volumes = { - 'data': { - 'driver': '$FOO', - 'driver_opts': { - 'max': 2, - 'user': '${USER}' - } - }, - 'other': None, - } - expected = { - 'data': { - 'driver': 'bar', - 'driver_opts': { - 'max': 2, - 'user': 'jenny' - } - }, - 'other': {}, - } - value = interpolate_environment_variables("2.0", volumes, 'volume', mock_env) - assert value == expected - - -def test_escaped_interpolation(defaults_interpolator): - assert defaults_interpolator('$${foo}') == '${foo}' - - -def test_invalid_interpolation(defaults_interpolator): - with pytest.raises(InvalidInterpolation): - defaults_interpolator('${') - with pytest.raises(InvalidInterpolation): - defaults_interpolator('$}') - with pytest.raises(InvalidInterpolation): - defaults_interpolator('${}') - with pytest.raises(InvalidInterpolation): - defaults_interpolator('${ }') - with pytest.raises(InvalidInterpolation): - defaults_interpolator('${ foo}') - with pytest.raises(InvalidInterpolation): - defaults_interpolator('${foo }') - with pytest.raises(InvalidInterpolation): - defaults_interpolator('${foo!}') - - -def test_interpolate_missing_no_default(defaults_interpolator): - assert defaults_interpolator("This ${missing} var") == "This var" - assert defaults_interpolator("This ${BAR} var") == "This var" - - -def test_interpolate_with_value(defaults_interpolator): - assert defaults_interpolator("This $FOO var") == "This first var" - assert defaults_interpolator("This ${FOO} var") == "This first var" - - -def test_interpolate_missing_with_default(defaults_interpolator): - assert defaults_interpolator("ok ${missing:-def}") == "ok def" - assert defaults_interpolator("ok ${missing-def}") == "ok def" - assert defaults_interpolator("ok ${BAR:-/non:-alphanumeric}") == "ok /non:-alphanumeric" - - -def test_interpolate_with_empty_and_default_value(defaults_interpolator): - assert defaults_interpolator("ok ${BAR:-def}") == "ok def" - assert defaults_interpolator("ok ${BAR-def}") == "ok " diff --git a/tests/unit/config/types_test.py b/tests/unit/config/types_test.py deleted file mode 100644 index 11427352..00000000 --- a/tests/unit/config/types_test.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import pytest - -from compose.config.config import V1 -from compose.config.config import V2_0 -from compose.config.errors import ConfigurationError -from compose.config.types import parse_extra_hosts -from compose.config.types import VolumeFromSpec -from compose.config.types import VolumeSpec - - -def test_parse_extra_hosts_list(): - expected = {'www.example.com': '192.168.0.17'} - assert parse_extra_hosts(["www.example.com:192.168.0.17"]) == expected - - expected = {'www.example.com': '192.168.0.17'} - assert parse_extra_hosts(["www.example.com: 192.168.0.17"]) == expected - - assert parse_extra_hosts([ - "www.example.com: 192.168.0.17", - "static.example.com:192.168.0.19", - "api.example.com: 192.168.0.18", - "v6.example.com: ::1" - ]) == { - 'www.example.com': '192.168.0.17', - 'static.example.com': '192.168.0.19', - 'api.example.com': '192.168.0.18', - 'v6.example.com': '::1' - } - - -def test_parse_extra_hosts_dict(): - assert parse_extra_hosts({ - 'www.example.com': '192.168.0.17', - 'api.example.com': '192.168.0.18' - }) == { - 'www.example.com': '192.168.0.17', - 'api.example.com': '192.168.0.18' - } - - -class TestVolumeSpec(object): - - def test_parse_volume_spec_only_one_path(self): - spec = VolumeSpec.parse('/the/volume') - assert spec == (None, '/the/volume', 'rw') - - def test_parse_volume_spec_internal_and_external(self): - spec = VolumeSpec.parse('external:interval') - assert spec == ('external', 'interval', 'rw') - - def test_parse_volume_spec_with_mode(self): - spec = VolumeSpec.parse('external:interval:ro') - assert spec == ('external', 'interval', 'ro') - - spec = VolumeSpec.parse('external:interval:z') - assert spec == ('external', 'interval', 'z') - - def test_parse_volume_spec_too_many_parts(self): - with pytest.raises(ConfigurationError) as exc: - VolumeSpec.parse('one:two:three:four') - assert 'has incorrect format' in exc.exconly() - - def test_parse_volume_windows_absolute_path_normalized(self): - windows_path = "c:\\Users\\me\\Documents\\shiny\\config:/opt/shiny/config:ro" - assert VolumeSpec._parse_win32(windows_path, True) == ( - "/c/Users/me/Documents/shiny/config", - "/opt/shiny/config", - "ro" - ) - - def test_parse_volume_windows_absolute_path_native(self): - windows_path = "c:\\Users\\me\\Documents\\shiny\\config:/opt/shiny/config:ro" - assert VolumeSpec._parse_win32(windows_path, False) == ( - "c:\\Users\\me\\Documents\\shiny\\config", - "/opt/shiny/config", - "ro" - ) - - def test_parse_volume_windows_internal_path_normalized(self): - windows_path = 'C:\\Users\\reimu\\scarlet:C:\\scarlet\\app:ro' - assert VolumeSpec._parse_win32(windows_path, True) == ( - '/c/Users/reimu/scarlet', - 'C:\\scarlet\\app', - 'ro' - ) - - def test_parse_volume_windows_internal_path_native(self): - windows_path = 'C:\\Users\\reimu\\scarlet:C:\\scarlet\\app:ro' - assert VolumeSpec._parse_win32(windows_path, False) == ( - 'C:\\Users\\reimu\\scarlet', - 'C:\\scarlet\\app', - 'ro' - ) - - def test_parse_volume_windows_just_drives_normalized(self): - windows_path = 'E:\\:C:\\:ro' - assert VolumeSpec._parse_win32(windows_path, True) == ( - '/e/', - 'C:\\', - 'ro' - ) - - def test_parse_volume_windows_just_drives_native(self): - windows_path = 'E:\\:C:\\:ro' - assert VolumeSpec._parse_win32(windows_path, False) == ( - 'E:\\', - 'C:\\', - 'ro' - ) - - def test_parse_volume_windows_mixed_notations_normalized(self): - windows_path = 'C:\\Foo:/root/foo' - assert VolumeSpec._parse_win32(windows_path, True) == ( - '/c/Foo', - '/root/foo', - 'rw' - ) - - def test_parse_volume_windows_mixed_notations_native(self): - windows_path = 'C:\\Foo:/root/foo' - assert VolumeSpec._parse_win32(windows_path, False) == ( - 'C:\\Foo', - '/root/foo', - 'rw' - ) - - -class TestVolumesFromSpec(object): - - services = ['servicea', 'serviceb'] - - def test_parse_v1_from_service(self): - volume_from = VolumeFromSpec.parse('servicea', self.services, V1) - assert volume_from == VolumeFromSpec('servicea', 'rw', 'service') - - def test_parse_v1_from_container(self): - volume_from = VolumeFromSpec.parse('foo:ro', self.services, V1) - assert volume_from == VolumeFromSpec('foo', 'ro', 'container') - - def test_parse_v1_invalid(self): - with pytest.raises(ConfigurationError): - VolumeFromSpec.parse('unknown:format:ro', self.services, V1) - - def test_parse_v2_from_service(self): - volume_from = VolumeFromSpec.parse('servicea', self.services, V2_0) - assert volume_from == VolumeFromSpec('servicea', 'rw', 'service') - - def test_parse_v2_from_service_with_mode(self): - volume_from = VolumeFromSpec.parse('servicea:ro', self.services, V2_0) - assert volume_from == VolumeFromSpec('servicea', 'ro', 'service') - - def test_parse_v2_from_container(self): - volume_from = VolumeFromSpec.parse('container:foo', self.services, V2_0) - assert volume_from == VolumeFromSpec('foo', 'rw', 'container') - - def test_parse_v2_from_container_with_mode(self): - volume_from = VolumeFromSpec.parse('container:foo:ro', self.services, V2_0) - assert volume_from == VolumeFromSpec('foo', 'ro', 'container') - - def test_parse_v2_invalid_type(self): - with pytest.raises(ConfigurationError) as exc: - VolumeFromSpec.parse('bogus:foo:ro', self.services, V2_0) - assert "Unknown volumes_from type 'bogus'" in exc.exconly() - - def test_parse_v2_invalid(self): - with pytest.raises(ConfigurationError): - VolumeFromSpec.parse('unknown:format:ro', self.services, V2_0) diff --git a/tests/unit/config_test.py b/tests/unit/config_test.py new file mode 100644 index 00000000..ebd2af7d --- /dev/null +++ b/tests/unit/config_test.py @@ -0,0 +1,506 @@ +import os +import mock +from .. import unittest + +from compose import config + + +class ConfigTest(unittest.TestCase): + def test_from_dictionary(self): + service_dicts = config.from_dictionary({ + 'foo': {'image': 'busybox'}, + 'bar': {'environment': ['FOO=1']}, + }) + + self.assertEqual( + sorted(service_dicts, key=lambda d: d['name']), + sorted([ + { + 'name': 'bar', + 'environment': {'FOO': '1'}, + }, + { + 'name': 'foo', + 'image': 'busybox', + } + ]) + ) + + def test_from_dictionary_throws_error_when_not_dict(self): + with self.assertRaises(config.ConfigurationError): + config.from_dictionary({ + 'web': 'busybox:latest', + }) + + def test_config_validation(self): + self.assertRaises( + config.ConfigurationError, + lambda: config.make_service_dict('foo', {'port': ['8000']}) + ) + config.make_service_dict('foo', {'ports': ['8000']}) + + +class VolumePathTest(unittest.TestCase): + @mock.patch.dict(os.environ) + def test_volume_binding_with_environ(self): + os.environ['VOLUME_PATH'] = '/host/path' + d = config.make_service_dict('foo', {'volumes': ['${VOLUME_PATH}:/container/path']}, working_dir='.') + self.assertEqual(d['volumes'], ['/host/path:/container/path']) + + @mock.patch.dict(os.environ) + def test_volume_binding_with_home(self): + os.environ['HOME'] = '/home/user' + d = config.make_service_dict('foo', {'volumes': ['~:/container/path']}, working_dir='.') + self.assertEqual(d['volumes'], ['/home/user:/container/path']) + + +class MergePathMappingTest(object): + def config_name(self): + return "" + + def test_empty(self): + service_dict = config.merge_service_dicts({}, {}) + self.assertNotIn(self.config_name(), service_dict) + + def test_no_override(self): + service_dict = config.merge_service_dicts( + {self.config_name(): ['/foo:/code', '/data']}, + {}, + ) + self.assertEqual(set(service_dict[self.config_name()]), set(['/foo:/code', '/data'])) + + def test_no_base(self): + service_dict = config.merge_service_dicts( + {}, + {self.config_name(): ['/bar:/code']}, + ) + self.assertEqual(set(service_dict[self.config_name()]), set(['/bar:/code'])) + + def test_override_explicit_path(self): + service_dict = config.merge_service_dicts( + {self.config_name(): ['/foo:/code', '/data']}, + {self.config_name(): ['/bar:/code']}, + ) + self.assertEqual(set(service_dict[self.config_name()]), set(['/bar:/code', '/data'])) + + def test_add_explicit_path(self): + service_dict = config.merge_service_dicts( + {self.config_name(): ['/foo:/code', '/data']}, + {self.config_name(): ['/bar:/code', '/quux:/data']}, + ) + self.assertEqual(set(service_dict[self.config_name()]), set(['/bar:/code', '/quux:/data'])) + + def test_remove_explicit_path(self): + service_dict = config.merge_service_dicts( + {self.config_name(): ['/foo:/code', '/quux:/data']}, + {self.config_name(): ['/bar:/code', '/data']}, + ) + self.assertEqual(set(service_dict[self.config_name()]), set(['/bar:/code', '/data'])) + + +class MergeVolumesTest(unittest.TestCase, MergePathMappingTest): + def config_name(self): + return 'volumes' + + +class MergeDevicesTest(unittest.TestCase, MergePathMappingTest): + def config_name(self): + return 'devices' + + +class BuildOrImageMergeTest(unittest.TestCase): + def test_merge_build_or_image_no_override(self): + self.assertEqual( + config.merge_service_dicts({'build': '.'}, {}), + {'build': '.'}, + ) + + self.assertEqual( + config.merge_service_dicts({'image': 'redis'}, {}), + {'image': 'redis'}, + ) + + def test_merge_build_or_image_override_with_same(self): + self.assertEqual( + config.merge_service_dicts({'build': '.'}, {'build': './web'}), + {'build': './web'}, + ) + + self.assertEqual( + config.merge_service_dicts({'image': 'redis'}, {'image': 'postgres'}), + {'image': 'postgres'}, + ) + + def test_merge_build_or_image_override_with_other(self): + self.assertEqual( + config.merge_service_dicts({'build': '.'}, {'image': 'redis'}), + {'image': 'redis'} + ) + + self.assertEqual( + config.merge_service_dicts({'image': 'redis'}, {'build': '.'}), + {'build': '.'} + ) + + +class MergeListsTest(unittest.TestCase): + def test_empty(self): + service_dict = config.merge_service_dicts({}, {}) + self.assertNotIn('ports', service_dict) + + def test_no_override(self): + service_dict = config.merge_service_dicts( + {'ports': ['10:8000', '9000']}, + {}, + ) + self.assertEqual(set(service_dict['ports']), set(['10:8000', '9000'])) + + def test_no_base(self): + service_dict = config.merge_service_dicts( + {}, + {'ports': ['10:8000', '9000']}, + ) + self.assertEqual(set(service_dict['ports']), set(['10:8000', '9000'])) + + def test_add_item(self): + service_dict = config.merge_service_dicts( + {'ports': ['10:8000', '9000']}, + {'ports': ['20:8000']}, + ) + self.assertEqual(set(service_dict['ports']), set(['10:8000', '9000', '20:8000'])) + + +class MergeStringsOrListsTest(unittest.TestCase): + def test_no_override(self): + service_dict = config.merge_service_dicts( + {'dns': '8.8.8.8'}, + {}, + ) + self.assertEqual(set(service_dict['dns']), set(['8.8.8.8'])) + + def test_no_base(self): + service_dict = config.merge_service_dicts( + {}, + {'dns': '8.8.8.8'}, + ) + self.assertEqual(set(service_dict['dns']), set(['8.8.8.8'])) + + def test_add_string(self): + service_dict = config.merge_service_dicts( + {'dns': ['8.8.8.8']}, + {'dns': '9.9.9.9'}, + ) + self.assertEqual(set(service_dict['dns']), set(['8.8.8.8', '9.9.9.9'])) + + def test_add_list(self): + service_dict = config.merge_service_dicts( + {'dns': '8.8.8.8'}, + {'dns': ['9.9.9.9']}, + ) + self.assertEqual(set(service_dict['dns']), set(['8.8.8.8', '9.9.9.9'])) + + +class MergeLabelsTest(unittest.TestCase): + def test_empty(self): + service_dict = config.merge_service_dicts({}, {}) + self.assertNotIn('labels', service_dict) + + def test_no_override(self): + service_dict = config.merge_service_dicts( + config.make_service_dict('foo', {'labels': ['foo=1', 'bar']}), + config.make_service_dict('foo', {}), + ) + self.assertEqual(service_dict['labels'], {'foo': '1', 'bar': ''}) + + def test_no_base(self): + service_dict = config.merge_service_dicts( + config.make_service_dict('foo', {}), + config.make_service_dict('foo', {'labels': ['foo=2']}), + ) + self.assertEqual(service_dict['labels'], {'foo': '2'}) + + def test_override_explicit_value(self): + service_dict = config.merge_service_dicts( + config.make_service_dict('foo', {'labels': ['foo=1', 'bar']}), + config.make_service_dict('foo', {'labels': ['foo=2']}), + ) + self.assertEqual(service_dict['labels'], {'foo': '2', 'bar': ''}) + + def test_add_explicit_value(self): + service_dict = config.merge_service_dicts( + config.make_service_dict('foo', {'labels': ['foo=1', 'bar']}), + config.make_service_dict('foo', {'labels': ['bar=2']}), + ) + self.assertEqual(service_dict['labels'], {'foo': '1', 'bar': '2'}) + + def test_remove_explicit_value(self): + service_dict = config.merge_service_dicts( + config.make_service_dict('foo', {'labels': ['foo=1', 'bar=2']}), + config.make_service_dict('foo', {'labels': ['bar']}), + ) + self.assertEqual(service_dict['labels'], {'foo': '1', 'bar': ''}) + + +class EnvTest(unittest.TestCase): + def test_parse_environment_as_list(self): + environment = [ + 'NORMAL=F1', + 'CONTAINS_EQUALS=F=2', + 'TRAILING_EQUALS=', + ] + self.assertEqual( + config.parse_environment(environment), + {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''}, + ) + + def test_parse_environment_as_dict(self): + environment = { + 'NORMAL': 'F1', + 'CONTAINS_EQUALS': 'F=2', + 'TRAILING_EQUALS': None, + } + self.assertEqual(config.parse_environment(environment), environment) + + def test_parse_environment_invalid(self): + with self.assertRaises(config.ConfigurationError): + config.parse_environment('a=b') + + def test_parse_environment_empty(self): + self.assertEqual(config.parse_environment(None), {}) + + @mock.patch.dict(os.environ) + def test_resolve_environment(self): + os.environ['FILE_DEF'] = 'E1' + os.environ['FILE_DEF_EMPTY'] = 'E2' + os.environ['ENV_DEF'] = 'E3' + + service_dict = config.make_service_dict( + 'foo', { + 'environment': { + 'FILE_DEF': 'F1', + 'FILE_DEF_EMPTY': '', + 'ENV_DEF': None, + 'NO_DEF': None + }, + }, + ) + + self.assertEqual( + service_dict['environment'], + {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}, + ) + + def test_env_from_file(self): + service_dict = config.make_service_dict( + 'foo', + {'env_file': 'one.env'}, + 'tests/fixtures/env', + ) + self.assertEqual( + service_dict['environment'], + {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'bar'}, + ) + + def test_env_from_multiple_files(self): + service_dict = config.make_service_dict( + 'foo', + {'env_file': ['one.env', 'two.env']}, + 'tests/fixtures/env', + ) + self.assertEqual( + service_dict['environment'], + {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'}, + ) + + def test_env_nonexistent_file(self): + options = {'env_file': 'nonexistent.env'} + self.assertRaises( + config.ConfigurationError, + lambda: config.make_service_dict('foo', options, 'tests/fixtures/env'), + ) + + @mock.patch.dict(os.environ) + def test_resolve_environment_from_file(self): + os.environ['FILE_DEF'] = 'E1' + os.environ['FILE_DEF_EMPTY'] = 'E2' + os.environ['ENV_DEF'] = 'E3' + service_dict = config.make_service_dict( + 'foo', + {'env_file': 'resolve.env'}, + 'tests/fixtures/env', + ) + self.assertEqual( + service_dict['environment'], + {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''}, + ) + + +class ExtendsTest(unittest.TestCase): + def test_extends(self): + service_dicts = config.load('tests/fixtures/extends/docker-compose.yml') + + service_dicts = sorted( + service_dicts, + key=lambda sd: sd['name'], + ) + + self.assertEqual(service_dicts, [ + { + 'name': 'mydb', + 'image': 'busybox', + 'command': 'top', + }, + { + 'name': 'myweb', + 'image': 'busybox', + 'command': 'top', + 'links': ['mydb:db'], + 'environment': { + "FOO": "1", + "BAR": "2", + "BAZ": "2", + }, + } + ]) + + def test_nested(self): + service_dicts = config.load('tests/fixtures/extends/nested.yml') + + self.assertEqual(service_dicts, [ + { + 'name': 'myweb', + 'image': 'busybox', + 'command': '/bin/true', + 'environment': { + "FOO": "2", + "BAR": "2", + }, + }, + ]) + + def test_circular(self): + try: + config.load('tests/fixtures/extends/circle-1.yml') + raise Exception("Expected config.CircularReference to be raised") + except config.CircularReference as e: + self.assertEqual( + [(os.path.basename(filename), service_name) for (filename, service_name) in e.trail], + [ + ('circle-1.yml', 'web'), + ('circle-2.yml', 'web'), + ('circle-1.yml', 'web'), + ], + ) + + def test_extends_validation(self): + dictionary = {'extends': None} + + def load_config(): + return config.make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends') + + self.assertRaisesRegexp(config.ConfigurationError, 'dictionary', load_config) + + dictionary['extends'] = {} + self.assertRaises(config.ConfigurationError, load_config) + + dictionary['extends']['file'] = 'common.yml' + self.assertRaisesRegexp(config.ConfigurationError, 'service', load_config) + + dictionary['extends']['service'] = 'web' + self.assertIsInstance(load_config(), dict) + + dictionary['extends']['what'] = 'is this' + self.assertRaisesRegexp(config.ConfigurationError, 'what', load_config) + + def test_blacklisted_options(self): + def load_config(): + return config.make_service_dict('myweb', { + 'extends': { + 'file': 'whatever', + 'service': 'web', + } + }, '.') + + with self.assertRaisesRegexp(config.ConfigurationError, 'links'): + other_config = {'web': {'links': ['db']}} + + with mock.patch.object(config, 'load_yaml', return_value=other_config): + print load_config() + + with self.assertRaisesRegexp(config.ConfigurationError, 'volumes_from'): + other_config = {'web': {'volumes_from': ['db']}} + + with mock.patch.object(config, 'load_yaml', return_value=other_config): + print load_config() + + with self.assertRaisesRegexp(config.ConfigurationError, 'net'): + other_config = {'web': {'net': 'container:db'}} + + with mock.patch.object(config, 'load_yaml', return_value=other_config): + print load_config() + + other_config = {'web': {'net': 'host'}} + + with mock.patch.object(config, 'load_yaml', return_value=other_config): + print load_config() + + def test_volume_path(self): + dicts = config.load('tests/fixtures/volume-path/docker-compose.yml') + + paths = [ + '%s:/foo' % os.path.abspath('tests/fixtures/volume-path/common/foo'), + '%s:/bar' % os.path.abspath('tests/fixtures/volume-path/bar'), + ] + + self.assertEqual(set(dicts[0]['volumes']), set(paths)) + + def test_parent_build_path_dne(self): + child = config.load('tests/fixtures/extends/nonexistent-path-child.yml') + + self.assertEqual(child, [ + { + 'name': 'dnechild', + 'image': 'busybox', + 'command': '/bin/true', + 'environment': { + "FOO": "1", + "BAR": "2", + }, + }, + ]) + + +class BuildPathTest(unittest.TestCase): + def setUp(self): + self.abs_context_path = os.path.join(os.getcwd(), 'tests/fixtures/build-ctx') + + def test_nonexistent_path(self): + options = {'build': 'nonexistent.path'} + self.assertRaises( + config.ConfigurationError, + lambda: config.from_dictionary({ + 'foo': options, + 'working_dir': 'tests/fixtures/build-path' + }) + ) + + def test_relative_path(self): + relative_build_path = '../build-ctx/' + service_dict = config.make_service_dict( + 'relpath', + {'build': relative_build_path}, + working_dir='tests/fixtures/build-path' + ) + self.assertEquals(service_dict['build'], self.abs_context_path) + + def test_absolute_path(self): + service_dict = config.make_service_dict( + 'abspath', + {'build': self.abs_context_path}, + working_dir='tests/fixtures/build-path' + ) + self.assertEquals(service_dict['build'], self.abs_context_path) + + def test_from_file(self): + service_dict = config.load('tests/fixtures/build-path/docker-compose.yml') + self.assertEquals(service_dict, [{'name': 'foo', 'build': self.abs_context_path}]) diff --git a/tests/unit/container_test.py b/tests/unit/container_test.py index 04f43016..c537a8cf 100644 --- a/tests/unit/container_test.py +++ b/tests/unit/container_test.py @@ -1,10 +1,9 @@ -from __future__ import absolute_import from __future__ import unicode_literals +from .. import unittest +import mock import docker -from .. import mock -from .. import unittest from compose.container import Container from compose.container import get_container_name @@ -12,9 +11,8 @@ from compose.container import get_container_name class ContainerTest(unittest.TestCase): def setUp(self): - self.container_id = "abcabcabcbabc12345" self.container_dict = { - "Id": self.container_id, + "Id": "abc", "Image": "busybox:latest", "Command": "top", "Created": 1387384730, @@ -42,22 +40,19 @@ class ContainerTest(unittest.TestCase): self.assertEqual( container.dictionary, { - "Id": self.container_id, + "Id": "abc", "Image": "busybox:latest", "Name": "/composetest_db_1", }) def test_from_ps_prefixed(self): - self.container_dict['Names'] = [ - '/swarm-host-1' + n for n in self.container_dict['Names'] - ] + self.container_dict['Names'] = ['/swarm-host-1' + n for n in self.container_dict['Names']] - container = Container.from_ps( - None, - self.container_dict, - has_been_inspected=True) + container = Container.from_ps(None, + self.container_dict, + has_been_inspected=True) self.assertEqual(container.dictionary, { - "Id": self.container_id, + "Id": "abc", "Image": "busybox:latest", "Name": "/composetest_db_1", }) @@ -88,17 +83,11 @@ class ContainerTest(unittest.TestCase): self.assertEqual(container.name, "composetest_db_1") def test_name_without_project(self): - self.container_dict['Name'] = "/composetest_web_7" container = Container(None, self.container_dict, has_been_inspected=True) self.assertEqual(container.name_without_project, "web_7") - def test_name_without_project_custom_container_name(self): - self.container_dict['Name'] = "/custom_name_of_container" - container = Container(None, self.container_dict, has_been_inspected=True) - self.assertEqual(container.name_without_project, "custom_name_of_container") - def test_inspect_if_not_inspected(self): - mock_client = mock.create_autospec(docker.APIClient) + mock_client = mock.create_autospec(docker.Client) container = Container(mock_client, dict(Id="the_id")) container.inspect_if_not_inspected() @@ -146,53 +135,11 @@ class ContainerTest(unittest.TestCase): self.assertEqual(container.get('HostConfig.VolumesFrom'), ["volume_id"]) self.assertEqual(container.get('Foo.Bar.DoesNotExist'), None) - def test_short_id(self): - container = Container(None, self.container_dict, has_been_inspected=True) - assert container.short_id == self.container_id[:12] - - def test_has_api_logs(self): - container_dict = { - 'HostConfig': { - 'LogConfig': { - 'Type': 'json-file' - } - } - } - - container = Container(None, container_dict, has_been_inspected=True) - assert container.has_api_logs is True - - container_dict['HostConfig']['LogConfig']['Type'] = 'none' - container = Container(None, container_dict, has_been_inspected=True) - assert container.has_api_logs is False - - container_dict['HostConfig']['LogConfig']['Type'] = 'syslog' - container = Container(None, container_dict, has_been_inspected=True) - assert container.has_api_logs is False - - container_dict['HostConfig']['LogConfig']['Type'] = 'journald' - container = Container(None, container_dict, has_been_inspected=True) - assert container.has_api_logs is True - - container_dict['HostConfig']['LogConfig']['Type'] = 'foobar' - container = Container(None, container_dict, has_been_inspected=True) - assert container.has_api_logs is False - class GetContainerNameTestCase(unittest.TestCase): def test_get_container_name(self): self.assertIsNone(get_container_name({})) self.assertEqual(get_container_name({'Name': 'myproject_db_1'}), 'myproject_db_1') - self.assertEqual( - get_container_name({'Names': ['/myproject_db_1', '/myproject_web_1/db']}), - 'myproject_db_1') - self.assertEqual( - get_container_name({ - 'Names': [ - '/swarm-host-1/myproject_db_1', - '/swarm-host-1/myproject_web_1/db' - ] - }), - 'myproject_db_1' - ) + self.assertEqual(get_container_name({'Names': ['/myproject_db_1', '/myproject_web_1/db']}), 'myproject_db_1') + self.assertEqual(get_container_name({'Names': ['/swarm-host-1/myproject_db_1', '/swarm-host-1/myproject_web_1/db']}), 'myproject_db_1') diff --git a/tests/unit/log_printer_test.py b/tests/unit/log_printer_test.py new file mode 100644 index 00000000..e40a1f75 --- /dev/null +++ b/tests/unit/log_printer_test.py @@ -0,0 +1,69 @@ +from __future__ import unicode_literals +from __future__ import absolute_import +import os + +from compose.cli.log_printer import LogPrinter +from .. import unittest + + +class LogPrinterTest(unittest.TestCase): + def get_default_output(self, monochrome=False): + def reader(*args, **kwargs): + yield "hello\nworld" + + container = MockContainer(reader) + output = run_log_printer([container], monochrome=monochrome) + return output + + def test_single_container(self): + output = self.get_default_output() + + self.assertIn('hello', output) + self.assertIn('world', output) + + def test_monochrome(self): + output = self.get_default_output(monochrome=True) + self.assertNotIn('\033[', output) + + def test_polychrome(self): + output = self.get_default_output() + self.assertIn('\033[', output) + + def test_unicode(self): + glyph = u'\u2022'.encode('utf-8') + + def reader(*args, **kwargs): + yield glyph + b'\n' + + container = MockContainer(reader) + output = run_log_printer([container]) + + self.assertIn(glyph, output) + + +def run_log_printer(containers, monochrome=False): + r, w = os.pipe() + reader, writer = os.fdopen(r, 'r'), os.fdopen(w, 'w') + printer = LogPrinter(containers, output=writer, monochrome=monochrome) + printer.run() + writer.close() + return reader.read() + + +class MockContainer(object): + def __init__(self, reader): + self._reader = reader + + @property + def name(self): + return 'myapp_web_1' + + @property + def name_without_project(self): + return 'web_1' + + def attach(self, *args, **kwargs): + return self._reader() + + def wait(self, *args, **kwargs): + return 0 diff --git a/tests/unit/network_test.py b/tests/unit/network_test.py deleted file mode 100644 index 12d06f41..00000000 --- a/tests/unit/network_test.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import pytest - -from .. import unittest -from compose.config import ConfigurationError -from compose.network import check_remote_network_config -from compose.network import Network - - -class NetworkTest(unittest.TestCase): - def test_check_remote_network_config_success(self): - options = {'com.docker.network.driver.foo': 'bar'} - net = Network( - None, 'compose_test', 'net1', 'bridge', - options - ) - check_remote_network_config( - {'Driver': 'bridge', 'Options': options}, net - ) - - def test_check_remote_network_config_whitelist(self): - options = {'com.docker.network.driver.foo': 'bar'} - remote_options = { - 'com.docker.network.driver.overlay.vxlanid_list': '257', - 'com.docker.network.driver.foo': 'bar' - } - net = Network( - None, 'compose_test', 'net1', 'overlay', - options - ) - check_remote_network_config( - {'Driver': 'overlay', 'Options': remote_options}, net - ) - - def test_check_remote_network_config_driver_mismatch(self): - net = Network(None, 'compose_test', 'net1', 'overlay') - with pytest.raises(ConfigurationError): - check_remote_network_config( - {'Driver': 'bridge', 'Options': {}}, net - ) - - def test_check_remote_network_config_options_mismatch(self): - net = Network(None, 'compose_test', 'net1', 'overlay') - with pytest.raises(ConfigurationError): - check_remote_network_config({'Driver': 'overlay', 'Options': { - 'com.docker.network.driver.foo': 'baz' - }}, net) - - def test_check_remote_network_config_null_remote(self): - net = Network(None, 'compose_test', 'net1', 'overlay') - check_remote_network_config( - {'Driver': 'overlay', 'Options': None}, net - ) diff --git a/tests/unit/parallel_test.py b/tests/unit/parallel_test.py deleted file mode 100644 index 2a50b718..00000000 --- a/tests/unit/parallel_test.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import six -from docker.errors import APIError - -from compose.parallel import parallel_execute -from compose.parallel import parallel_execute_iter -from compose.parallel import UpstreamError - - -web = 'web' -db = 'db' -data_volume = 'data_volume' -cache = 'cache' - -objects = [web, db, data_volume, cache] - -deps = { - web: [db, cache], - db: [data_volume], - data_volume: [], - cache: [], -} - - -def get_deps(obj): - return [(dep, None) for dep in deps[obj]] - - -def test_parallel_execute(): - results, errors = parallel_execute( - objects=[1, 2, 3, 4, 5], - func=lambda x: x * 2, - get_name=six.text_type, - msg="Doubling", - ) - - assert sorted(results) == [2, 4, 6, 8, 10] - assert errors == {} - - -def test_parallel_execute_with_deps(): - log = [] - - def process(x): - log.append(x) - - parallel_execute( - objects=objects, - func=process, - get_name=lambda obj: obj, - msg="Processing", - get_deps=get_deps, - ) - - assert sorted(log) == sorted(objects) - - assert log.index(data_volume) < log.index(db) - assert log.index(db) < log.index(web) - assert log.index(cache) < log.index(web) - - -def test_parallel_execute_with_upstream_errors(): - log = [] - - def process(x): - if x is data_volume: - raise APIError(None, None, "Something went wrong") - log.append(x) - - parallel_execute( - objects=objects, - func=process, - get_name=lambda obj: obj, - msg="Processing", - get_deps=get_deps, - ) - - assert log == [cache] - - events = [ - (obj, result, type(exception)) - for obj, result, exception - in parallel_execute_iter(objects, process, get_deps) - ] - - assert (cache, None, type(None)) in events - assert (data_volume, None, APIError) in events - assert (db, None, UpstreamError) in events - assert (web, None, UpstreamError) in events diff --git a/tests/unit/progress_stream_test.py b/tests/unit/progress_stream_test.py index c0cb906d..317b77e9 100644 --- a/tests/unit/progress_stream_test.py +++ b/tests/unit/progress_stream_test.py @@ -1,87 +1,37 @@ -from __future__ import absolute_import from __future__ import unicode_literals +from __future__ import absolute_import +from tests import unittest from six import StringIO from compose import progress_stream -from tests import unittest class ProgressStreamTestCase(unittest.TestCase): + def test_stream_output(self): output = [ - b'{"status": "Downloading", "progressDetail": {"current": ' - b'31019763, "start": 1413653874, "total": 62763875}, ' - b'"progress": "..."}', + '{"status": "Downloading", "progressDetail": {"current": ' + '31019763, "start": 1413653874, "total": 62763875}, ' + '"progress": "..."}', ] events = progress_stream.stream_output(output, StringIO()) self.assertEqual(len(events), 1) def test_stream_output_div_zero(self): output = [ - b'{"status": "Downloading", "progressDetail": {"current": ' - b'0, "start": 1413653874, "total": 0}, ' - b'"progress": "..."}', + '{"status": "Downloading", "progressDetail": {"current": ' + '0, "start": 1413653874, "total": 0}, ' + '"progress": "..."}', ] events = progress_stream.stream_output(output, StringIO()) self.assertEqual(len(events), 1) def test_stream_output_null_total(self): output = [ - b'{"status": "Downloading", "progressDetail": {"current": ' - b'0, "start": 1413653874, "total": null}, ' - b'"progress": "..."}', + '{"status": "Downloading", "progressDetail": {"current": ' + '0, "start": 1413653874, "total": null}, ' + '"progress": "..."}', ] events = progress_stream.stream_output(output, StringIO()) self.assertEqual(len(events), 1) - - def test_stream_output_progress_event_tty(self): - events = [ - b'{"status": "Already exists", "progressDetail": {}, "id": "8d05e3af52b0"}' - ] - - class TTYStringIO(StringIO): - def isatty(self): - return True - - output = TTYStringIO() - events = progress_stream.stream_output(events, output) - self.assertTrue(len(output.getvalue()) > 0) - - def test_stream_output_progress_event_no_tty(self): - events = [ - b'{"status": "Already exists", "progressDetail": {}, "id": "8d05e3af52b0"}' - ] - output = StringIO() - - events = progress_stream.stream_output(events, output) - self.assertEqual(len(output.getvalue()), 0) - - def test_stream_output_no_progress_event_no_tty(self): - events = [ - b'{"status": "Pulling from library/xy", "id": "latest"}' - ] - output = StringIO() - - events = progress_stream.stream_output(events, output) - self.assertTrue(len(output.getvalue()) > 0) - - -def test_get_digest_from_push(): - digest = "sha256:abcd" - events = [ - {"status": "..."}, - {"status": "..."}, - {"progressDetail": {}, "aux": {"Digest": digest}}, - ] - assert progress_stream.get_digest_from_push(events) == digest - - -def test_get_digest_from_pull(): - digest = "sha256:abcd" - events = [ - {"status": "..."}, - {"status": "..."}, - {"status": "Digest: %s" % digest}, - ] - assert progress_stream.get_digest_from_pull(events) == digest diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 32d0adfa..fc49e9b8 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -1,75 +1,70 @@ -from __future__ import absolute_import from __future__ import unicode_literals - -import datetime - -import docker -from docker.errors import NotFound - -from .. import mock from .. import unittest -from compose.config.config import Config -from compose.config.types import VolumeFromSpec -from compose.const import LABEL_SERVICE -from compose.container import Container -from compose.project import Project -from compose.service import ImageType from compose.service import Service +from compose.project import Project +from compose.container import Container +from compose import config + +import mock +import docker class ProjectTest(unittest.TestCase): - def setUp(self): - self.mock_client = mock.create_autospec(docker.APIClient) - - def test_from_config(self): - config = Config( - version=None, - services=[ - { - 'name': 'web', - 'image': 'busybox:latest', - }, - { - 'name': 'db', - 'image': 'busybox:latest', - }, - ], - networks=None, - volumes=None, - secrets=None, - ) - project = Project.from_config( - name='composetest', - config_data=config, - client=None, - ) + def test_from_dict(self): + project = Project.from_dicts('composetest', [ + { + 'name': 'web', + 'image': 'busybox:latest' + }, + { + 'name': 'db', + 'image': 'busybox:latest' + }, + ], None) self.assertEqual(len(project.services), 2) self.assertEqual(project.get_service('web').name, 'web') self.assertEqual(project.get_service('web').options['image'], 'busybox:latest') self.assertEqual(project.get_service('db').name, 'db') self.assertEqual(project.get_service('db').options['image'], 'busybox:latest') - self.assertFalse(project.networks.use_networking) - def test_from_config_v2(self): - config = Config( - version=2, - services=[ - { - 'name': 'web', - 'image': 'busybox:latest', - }, - { - 'name': 'db', - 'image': 'busybox:latest', - }, - ], - networks=None, - volumes=None, - secrets=None, - ) - project = Project.from_config('composetest', config, None) + def test_from_dict_sorts_in_dependency_order(self): + project = Project.from_dicts('composetest', [ + { + 'name': 'web', + 'image': 'busybox:latest', + 'links': ['db'], + }, + { + 'name': 'db', + 'image': 'busybox:latest', + 'volumes_from': ['volume'] + }, + { + 'name': 'volume', + 'image': 'busybox:latest', + 'volumes': ['/tmp'], + } + ], None) + + self.assertEqual(project.services[0].name, 'volume') + self.assertEqual(project.services[1].name, 'db') + self.assertEqual(project.services[2].name, 'web') + + def test_from_config(self): + dicts = config.from_dictionary({ + 'web': { + 'image': 'busybox:latest', + }, + 'db': { + 'image': 'busybox:latest', + }, + }) + project = Project.from_dicts('composetest', dicts, None) self.assertEqual(len(project.services), 2) - self.assertTrue(project.networks.use_networking) + self.assertEqual(project.get_service('web').name, 'web') + self.assertEqual(project.get_service('web').options['image'], 'busybox:latest') + self.assertEqual(project.get_service('db').name, 'db') + self.assertEqual(project.get_service('db').options['image'], 'busybox:latest') def test_get_service(self): web = Service( @@ -159,27 +154,21 @@ class ProjectTest(unittest.TestCase): def test_use_volumes_from_container(self): container_id = 'aabbccddee' container_dict = dict(Name='aaa', Id=container_id) - self.mock_client.inspect_container.return_value = container_dict - project = Project.from_config( - name='test', - client=self.mock_client, - config_data=Config( - version=None, - services=[{ - 'name': 'test', - 'image': 'busybox:latest', - 'volumes_from': [VolumeFromSpec('aaa', 'rw', 'container')] - }], - networks=None, - volumes=None, - secrets=None, - ), - ) - assert project.get_service('test')._get_volumes_from() == [container_id + ":rw"] + mock_client = mock.create_autospec(docker.Client) + mock_client.inspect_container.return_value = container_dict + project = Project.from_dicts('test', [ + { + 'name': 'test', + 'image': 'busybox:latest', + 'volumes_from': ['aaa'] + } + ], mock_client) + self.assertEqual(project.get_service('test')._get_volumes_from(), [container_id]) def test_use_volumes_from_service_no_container(self): container_name = 'test_vol_1' - self.mock_client.containers.return_value = [ + mock_client = mock.create_autospec(docker.Client) + mock_client.containers.return_value = [ { "Name": container_name, "Names": [container_name], @@ -187,218 +176,58 @@ class ProjectTest(unittest.TestCase): "Image": 'busybox:latest' } ] - project = Project.from_config( - name='test', - client=self.mock_client, - config_data=Config( - version=None, - services=[ - { - 'name': 'vol', - 'image': 'busybox:latest' - }, - { - 'name': 'test', - 'image': 'busybox:latest', - 'volumes_from': [VolumeFromSpec('vol', 'rw', 'service')] - } - ], - networks=None, - volumes=None, - secrets=None, - ), - ) - assert project.get_service('test')._get_volumes_from() == [container_name + ":rw"] - - def test_use_volumes_from_service_container(self): - container_ids = ['aabbccddee', '12345'] - - project = Project.from_config( - name='test', - client=None, - config_data=Config( - version=None, - services=[ - { - 'name': 'vol', - 'image': 'busybox:latest' - }, - { - 'name': 'test', - 'image': 'busybox:latest', - 'volumes_from': [VolumeFromSpec('vol', 'rw', 'service')] - } - ], - networks=None, - volumes=None, - secrets=None, - ), - ) - with mock.patch.object(Service, 'containers') as mock_return: - mock_return.return_value = [ - mock.Mock(id=container_id, spec=Container) - for container_id in container_ids] - assert ( - project.get_service('test')._get_volumes_from() == - [container_ids[0] + ':rw'] - ) - - def test_events(self): - services = [Service(name='web'), Service(name='db')] - project = Project('test', services, self.mock_client) - self.mock_client.events.return_value = iter([ + project = Project.from_dicts('test', [ { - 'status': 'create', - 'from': 'example/image', - 'id': 'abcde', - 'time': 1420092061, - 'timeNano': 14200920610000002000, + 'name': 'vol', + 'image': 'busybox:latest' }, { - 'status': 'attach', - 'from': 'example/image', - 'id': 'abcde', - 'time': 1420092061, - 'timeNano': 14200920610000003000, - }, - { - 'status': 'create', - 'from': 'example/other', - 'id': 'bdbdbd', - 'time': 1420092061, - 'timeNano': 14200920610000005000, - }, - { - 'status': 'create', - 'from': 'example/db', - 'id': 'ababa', - 'time': 1420092061, - 'timeNano': 14200920610000004000, - }, - { - 'status': 'destroy', - 'from': 'example/db', - 'id': 'eeeee', - 'time': 1420092061, - 'timeNano': 14200920610000004000, - }, - ]) - - def dt_with_microseconds(dt, us): - return datetime.datetime.fromtimestamp(dt).replace(microsecond=us) - - def get_container(cid): - if cid == 'eeeee': - raise NotFound(None, None, "oops") - if cid == 'abcde': - name = 'web' - labels = {LABEL_SERVICE: name} - elif cid == 'ababa': - name = 'db' - labels = {LABEL_SERVICE: name} - else: - labels = {} - name = '' - return { - 'Id': cid, - 'Config': {'Labels': labels}, - 'Name': '/project_%s_1' % name, + 'name': 'test', + 'image': 'busybox:latest', + 'volumes_from': ['vol'] } + ], mock_client) + self.assertEqual(project.get_service('test')._get_volumes_from(), [container_name]) - self.mock_client.inspect_container.side_effect = get_container + @mock.patch.object(Service, 'containers') + def test_use_volumes_from_service_container(self, mock_return): + container_ids = ['aabbccddee', '12345'] + mock_return.return_value = [ + mock.Mock(id=container_id, spec=Container) + for container_id in container_ids] - events = project.events() - - events_list = list(events) - # Assert the return value is a generator - assert not list(events) - assert events_list == [ + project = Project.from_dicts('test', [ { - 'type': 'container', - 'service': 'web', - 'action': 'create', - 'id': 'abcde', - 'attributes': { - 'name': 'project_web_1', - 'image': 'example/image', - }, - 'time': dt_with_microseconds(1420092061, 2), - 'container': Container(None, {'Id': 'abcde'}), + 'name': 'vol', + 'image': 'busybox:latest' }, { - 'type': 'container', - 'service': 'web', - 'action': 'attach', - 'id': 'abcde', - 'attributes': { - 'name': 'project_web_1', - 'image': 'example/image', - }, - 'time': dt_with_microseconds(1420092061, 3), - 'container': Container(None, {'Id': 'abcde'}), - }, - { - 'type': 'container', - 'service': 'db', - 'action': 'create', - 'id': 'ababa', - 'attributes': { - 'name': 'project_db_1', - 'image': 'example/db', - }, - 'time': dt_with_microseconds(1420092061, 4), - 'container': Container(None, {'Id': 'ababa'}), - }, - ] - - def test_net_unset(self): - project = Project.from_config( - name='test', - client=self.mock_client, - config_data=Config( - version=None, - services=[ - { - 'name': 'test', - 'image': 'busybox:latest', - } - ], - networks=None, - volumes=None, - secrets=None, - ), - ) - service = project.get_service('test') - self.assertEqual(service.network_mode.id, None) - self.assertNotIn('NetworkMode', service._get_container_host_config({})) + 'name': 'test', + 'image': 'busybox:latest', + 'volumes_from': ['vol'] + } + ], None) + self.assertEqual(project.get_service('test')._get_volumes_from(), container_ids) def test_use_net_from_container(self): container_id = 'aabbccddee' container_dict = dict(Name='aaa', Id=container_id) - self.mock_client.inspect_container.return_value = container_dict - project = Project.from_config( - name='test', - client=self.mock_client, - config_data=Config( - version=None, - services=[ - { - 'name': 'test', - 'image': 'busybox:latest', - 'network_mode': 'container:aaa' - }, - ], - networks=None, - volumes=None, - secrets=None, - ), - ) + mock_client = mock.create_autospec(docker.Client) + mock_client.inspect_container.return_value = container_dict + project = Project.from_dicts('test', [ + { + 'name': 'test', + 'image': 'busybox:latest', + 'net': 'container:aaa' + } + ], mock_client) service = project.get_service('test') - self.assertEqual(service.network_mode.mode, 'container:' + container_id) + self.assertEqual(service._get_net(), 'container:' + container_id) def test_use_net_from_service(self): container_name = 'test_aaa_1' - self.mock_client.containers.return_value = [ + mock_client = mock.create_autospec(docker.Client) + mock_client.containers.return_value = [ { "Name": container_name, "Names": [container_name], @@ -406,151 +235,17 @@ class ProjectTest(unittest.TestCase): "Image": 'busybox:latest' } ] - project = Project.from_config( - name='test', - client=self.mock_client, - config_data=Config( - version=None, - services=[ - { - 'name': 'aaa', - 'image': 'busybox:latest' - }, - { - 'name': 'test', - 'image': 'busybox:latest', - 'network_mode': 'service:aaa' - }, - ], - networks=None, - volumes=None, - secrets=None, - ), - ) + project = Project.from_dicts('test', [ + { + 'name': 'aaa', + 'image': 'busybox:latest' + }, + { + 'name': 'test', + 'image': 'busybox:latest', + 'net': 'container:aaa' + } + ], mock_client) service = project.get_service('test') - self.assertEqual(service.network_mode.mode, 'container:' + container_name) - - def test_uses_default_network_true(self): - project = Project.from_config( - name='test', - client=self.mock_client, - config_data=Config( - version=2, - services=[ - { - 'name': 'foo', - 'image': 'busybox:latest' - }, - ], - networks=None, - volumes=None, - secrets=None, - ), - ) - - assert 'default' in project.networks.networks - - def test_uses_default_network_false(self): - project = Project.from_config( - name='test', - client=self.mock_client, - config_data=Config( - version=2, - services=[ - { - 'name': 'foo', - 'image': 'busybox:latest', - 'networks': {'custom': None} - }, - ], - networks={'custom': {}}, - volumes=None, - secrets=None, - ), - ) - - assert 'default' not in project.networks.networks - - def test_container_without_name(self): - self.mock_client.containers.return_value = [ - {'Image': 'busybox:latest', 'Id': '1', 'Name': '1'}, - {'Image': 'busybox:latest', 'Id': '2', 'Name': None}, - {'Image': 'busybox:latest', 'Id': '3'}, - ] - self.mock_client.inspect_container.return_value = { - 'Id': '1', - 'Config': { - 'Labels': { - LABEL_SERVICE: 'web', - }, - }, - } - project = Project.from_config( - name='test', - client=self.mock_client, - config_data=Config( - version=None, - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - }], - networks=None, - volumes=None, - secrets=None, - ), - ) - self.assertEqual([c.id for c in project.containers()], ['1']) - - def test_down_with_no_resources(self): - project = Project.from_config( - name='test', - client=self.mock_client, - config_data=Config( - version='2', - services=[{ - 'name': 'web', - 'image': 'busybox:latest', - }], - networks={'default': {}}, - volumes={'data': {}}, - secrets=None, - ), - ) - self.mock_client.remove_network.side_effect = NotFound(None, None, 'oops') - self.mock_client.remove_volume.side_effect = NotFound(None, None, 'oops') - - project.down(ImageType.all, True) - self.mock_client.remove_image.assert_called_once_with("busybox:latest") - - def test_warning_in_swarm_mode(self): - self.mock_client.info.return_value = {'Swarm': {'LocalNodeState': 'active'}} - project = Project('composetest', [], self.mock_client) - - with mock.patch('compose.project.log') as fake_log: - project.up() - assert fake_log.warn.call_count == 1 - - def test_no_warning_on_stop(self): - self.mock_client.info.return_value = {'Swarm': {'LocalNodeState': 'active'}} - project = Project('composetest', [], self.mock_client) - - with mock.patch('compose.project.log') as fake_log: - project.stop() - assert fake_log.warn.call_count == 0 - - def test_no_warning_in_normal_mode(self): - self.mock_client.info.return_value = {'Swarm': {'LocalNodeState': 'inactive'}} - project = Project('composetest', [], self.mock_client) - - with mock.patch('compose.project.log') as fake_log: - project.up() - assert fake_log.warn.call_count == 0 - - def test_no_warning_with_no_swarm_info(self): - self.mock_client.info.return_value = {} - project = Project('composetest', [], self.mock_client) - - with mock.patch('compose.project.log') as fake_log: - project.up() - assert fake_log.warn.call_count == 0 + self.assertEqual(service._get_net(), 'container:' + container_name) diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index 2d5b1761..fb3a7fcb 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -1,52 +1,63 @@ -from __future__ import absolute_import from __future__ import unicode_literals +from __future__ import absolute_import + +from .. import unittest +import mock import docker -import pytest -from docker.errors import APIError -from .. import mock -from .. import unittest -from compose.config.types import VolumeFromSpec -from compose.config.types import VolumeSpec -from compose.const import LABEL_CONFIG_HASH -from compose.const import LABEL_ONE_OFF -from compose.const import LABEL_PROJECT -from compose.const import LABEL_SERVICE -from compose.container import Container -from compose.project import OneOffFilter -from compose.service import build_ulimits -from compose.service import build_volume_binding -from compose.service import BuildAction -from compose.service import ContainerNetworkMode -from compose.service import get_container_data_volumes -from compose.service import ImageType -from compose.service import merge_volume_bindings -from compose.service import NeedsBuildError -from compose.service import NetworkMode -from compose.service import NoSuchImageError -from compose.service import parse_repository_tag from compose.service import Service -from compose.service import ServiceNetworkMode -from compose.service import warn_on_masked_volume +from compose.container import Container +from compose.const import LABEL_SERVICE, LABEL_PROJECT, LABEL_ONE_OFF +from compose.service import ( + ConfigError, + NeedsBuildError, + build_port_bindings, + build_volume_binding, + get_container_data_volumes, + merge_volume_bindings, + parse_repository_tag, + parse_volume_spec, + split_port, +) class ServiceTest(unittest.TestCase): def setUp(self): - self.mock_client = mock.create_autospec(docker.APIClient) + self.mock_client = mock.create_autospec(docker.Client) + + def test_name_validations(self): + self.assertRaises(ConfigError, lambda: Service(name='')) + + self.assertRaises(ConfigError, lambda: Service(name=' ')) + self.assertRaises(ConfigError, lambda: Service(name='/')) + self.assertRaises(ConfigError, lambda: Service(name='!')) + self.assertRaises(ConfigError, lambda: Service(name='\xe2')) + self.assertRaises(ConfigError, lambda: Service(name='_')) + self.assertRaises(ConfigError, lambda: Service(name='____')) + self.assertRaises(ConfigError, lambda: Service(name='foo_bar')) + self.assertRaises(ConfigError, lambda: Service(name='__foo_bar__')) + + Service('a', image='foo') + Service('foo', image='foo') + + def test_project_validation(self): + self.assertRaises(ConfigError, lambda: Service('bar')) + self.assertRaises(ConfigError, lambda: Service(name='foo', project='_', image='foo')) + Service(name='foo', project='bar', image='foo') def test_containers(self): service = Service('db', self.mock_client, 'myproject', image='foo') self.mock_client.containers.return_value = [] - self.assertEqual(list(service.containers()), []) + self.assertEqual(service.containers(), []) def test_containers_with_containers(self): self.mock_client.containers.return_value = [ dict(Name=str(i), Image='foo', Id=i) for i in range(3) ] service = Service('db', self.mock_client, 'myproject', image='foo') - self.assertEqual([c.id for c in service.containers()], list(range(3))) + self.assertEqual([c.id for c in service.containers()], range(3)) expected_labels = [ '{0}=myproject'.format(LABEL_PROJECT), @@ -58,43 +69,14 @@ class ServiceTest(unittest.TestCase): all=False, filters={'label': expected_labels}) - def test_container_without_name(self): - self.mock_client.containers.return_value = [ - {'Image': 'foo', 'Id': '1', 'Name': '1'}, - {'Image': 'foo', 'Id': '2', 'Name': None}, - {'Image': 'foo', 'Id': '3'}, - ] - service = Service('db', self.mock_client, 'myproject', image='foo') - - self.assertEqual([c.id for c in service.containers()], ['1']) - self.assertEqual(service._next_container_number(), 2) - self.assertEqual(service.get_container(1).id, '1') - def test_get_volumes_from_container(self): container_id = 'aabbccddee' service = Service( 'test', image='foo', - volumes_from=[ - VolumeFromSpec( - mock.Mock(id=container_id, spec=Container), - 'rw', - 'container')]) + volumes_from=[mock.Mock(id=container_id, spec=Container)]) - self.assertEqual(service._get_volumes_from(), [container_id + ':rw']) - - def test_get_volumes_from_container_read_only(self): - container_id = 'aabbccddee' - service = Service( - 'test', - image='foo', - volumes_from=[ - VolumeFromSpec( - mock.Mock(id=container_id, spec=Container), - 'ro', - 'container')]) - - self.assertEqual(service._get_volumes_from(), [container_id + ':ro']) + self.assertEqual(service._get_volumes_from(), [container_id]) def test_get_volumes_from_service_container_exists(self): container_ids = ['aabbccddee', '12345'] @@ -103,27 +85,9 @@ class ServiceTest(unittest.TestCase): mock.Mock(id=container_id, spec=Container) for container_id in container_ids ] - service = Service( - 'test', - volumes_from=[VolumeFromSpec(from_service, 'rw', 'service')], - image='foo') + service = Service('test', volumes_from=[from_service], image='foo') - self.assertEqual(service._get_volumes_from(), [container_ids[0] + ":rw"]) - - def test_get_volumes_from_service_container_exists_with_flags(self): - for mode in ['ro', 'rw', 'z', 'rw,z', 'z,rw']: - container_ids = ['aabbccddee:' + mode, '12345:' + mode] - from_service = mock.create_autospec(Service) - from_service.containers.return_value = [ - mock.Mock(id=container_id.split(':')[0], spec=Container) - for container_id in container_ids - ] - service = Service( - 'test', - volumes_from=[VolumeFromSpec(from_service, mode, 'service')], - image='foo') - - self.assertEqual(service._get_volumes_from(), [container_ids[0]]) + self.assertEqual(service._get_volumes_from(), container_ids) def test_get_volumes_from_service_no_container(self): container_id = 'abababab' @@ -132,85 +96,67 @@ class ServiceTest(unittest.TestCase): from_service.create_container.return_value = mock.Mock( id=container_id, spec=Container) - service = Service( - 'test', - image='foo', - volumes_from=[VolumeFromSpec(from_service, 'rw', 'service')]) + service = Service('test', image='foo', volumes_from=[from_service]) - self.assertEqual(service._get_volumes_from(), [container_id + ':rw']) + self.assertEqual(service._get_volumes_from(), [container_id]) from_service.create_container.assert_called_once_with() + def test_split_port_with_host_ip(self): + internal_port, external_port = split_port("127.0.0.1:1000:2000") + self.assertEqual(internal_port, "2000") + self.assertEqual(external_port, ("127.0.0.1", "1000")) + + def test_split_port_with_protocol(self): + internal_port, external_port = split_port("127.0.0.1:1000:2000/udp") + self.assertEqual(internal_port, "2000/udp") + self.assertEqual(external_port, ("127.0.0.1", "1000")) + + def test_split_port_with_host_ip_no_port(self): + internal_port, external_port = split_port("127.0.0.1::2000") + self.assertEqual(internal_port, "2000") + self.assertEqual(external_port, ("127.0.0.1", None)) + + def test_split_port_with_host_port(self): + internal_port, external_port = split_port("1000:2000") + self.assertEqual(internal_port, "2000") + self.assertEqual(external_port, "1000") + + def test_split_port_no_host_port(self): + internal_port, external_port = split_port("2000") + self.assertEqual(internal_port, "2000") + self.assertEqual(external_port, None) + + def test_split_port_invalid(self): + with self.assertRaises(ConfigError): + split_port("0.0.0.0:1000:2000:tcp") + + def test_build_port_bindings_with_one_port(self): + port_bindings = build_port_bindings(["127.0.0.1:1000:1000"]) + self.assertEqual(port_bindings["1000"], [("127.0.0.1", "1000")]) + + def test_build_port_bindings_with_matching_internal_ports(self): + port_bindings = build_port_bindings(["127.0.0.1:1000:1000", "127.0.0.1:2000:1000"]) + self.assertEqual(port_bindings["1000"], [("127.0.0.1", "1000"), ("127.0.0.1", "2000")]) + + def test_build_port_bindings_with_nonmatching_internal_ports(self): + port_bindings = build_port_bindings(["127.0.0.1:1000:1000", "127.0.0.1:2000:2000"]) + self.assertEqual(port_bindings["1000"], [("127.0.0.1", "1000")]) + self.assertEqual(port_bindings["2000"], [("127.0.0.1", "2000")]) + def test_split_domainname_none(self): service = Service('foo', image='foo', hostname='name', client=self.mock_client) + self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'image': 'foo'}, 1) self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertFalse('domainname' in opts, 'domainname') - def test_memory_swap_limit(self): - self.mock_client.create_host_config.return_value = {} - - service = Service( - name='foo', - image='foo', - hostname='name', - client=self.mock_client, - mem_limit=1000000000, - memswap_limit=2000000000) - service._get_container_create_options({'some': 'overrides'}, 1) - - self.assertTrue(self.mock_client.create_host_config.called) - self.assertEqual( - self.mock_client.create_host_config.call_args[1]['mem_limit'], - 1000000000 - ) - self.assertEqual( - self.mock_client.create_host_config.call_args[1]['memswap_limit'], - 2000000000 - ) - - def test_cgroup_parent(self): - self.mock_client.create_host_config.return_value = {} - - service = Service( - name='foo', - image='foo', - hostname='name', - client=self.mock_client, - cgroup_parent='test') - service._get_container_create_options({'some': 'overrides'}, 1) - - self.assertTrue(self.mock_client.create_host_config.called) - self.assertEqual( - self.mock_client.create_host_config.call_args[1]['cgroup_parent'], - 'test' - ) - - def test_log_opt(self): - self.mock_client.create_host_config.return_value = {} - - log_opt = {'syslog-address': 'tcp://192.168.0.42:123'} - logging = {'driver': 'syslog', 'options': log_opt} - service = Service( - name='foo', - image='foo', - hostname='name', - client=self.mock_client, - log_driver='syslog', - logging=logging) - service._get_container_create_options({'some': 'overrides'}, 1) - - self.assertTrue(self.mock_client.create_host_config.called) - self.assertEqual( - self.mock_client.create_host_config.call_args[1]['log_config'], - {'Type': 'syslog', 'Config': {'syslog-address': 'tcp://192.168.0.42:123'}} - ) - def test_split_domainname_fqdn(self): service = Service( 'foo', hostname='name.domain.tld', image='foo', client=self.mock_client) + self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'image': 'foo'}, 1) self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') @@ -222,6 +168,7 @@ class ServiceTest(unittest.TestCase): image='foo', domainname='domain.tld', client=self.mock_client) + self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'image': 'foo'}, 1) self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') @@ -233,107 +180,11 @@ class ServiceTest(unittest.TestCase): domainname='domain.tld', image='foo', client=self.mock_client) + self.mock_client.containers.return_value = [] opts = service._get_container_create_options({'image': 'foo'}, 1) self.assertEqual(opts['hostname'], 'name.sub', 'hostname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') - def test_no_default_hostname_when_not_using_networking(self): - service = Service( - 'foo', - image='foo', - use_networking=False, - client=self.mock_client, - ) - opts = service._get_container_create_options({'image': 'foo'}, 1) - self.assertIsNone(opts.get('hostname')) - - def test_get_container_create_options_with_name_option(self): - service = Service( - 'foo', - image='foo', - client=self.mock_client, - container_name='foo1') - name = 'the_new_name' - opts = service._get_container_create_options( - {'name': name}, - 1, - one_off=OneOffFilter.only) - self.assertEqual(opts['name'], name) - - def test_get_container_create_options_does_not_mutate_options(self): - labels = {'thing': 'real'} - environment = {'also': 'real'} - service = Service( - 'foo', - image='foo', - labels=dict(labels), - client=self.mock_client, - environment=dict(environment), - ) - self.mock_client.inspect_image.return_value = {'Id': 'abcd'} - prev_container = mock.Mock( - id='ababab', - image_config={'ContainerConfig': {}}) - prev_container.get.return_value = None - - opts = service._get_container_create_options( - {}, - 1, - previous_container=prev_container) - - self.assertEqual(service.options['labels'], labels) - self.assertEqual(service.options['environment'], environment) - - self.assertEqual( - opts['labels'][LABEL_CONFIG_HASH], - '2524a06fcb3d781aa2c981fc40bcfa08013bb318e4273bfa388df22023e6f2aa') - assert opts['environment'] == ['also=real'] - - def test_get_container_create_options_sets_affinity_with_binds(self): - service = Service( - 'foo', - image='foo', - client=self.mock_client, - ) - self.mock_client.inspect_image.return_value = {'Id': 'abcd'} - prev_container = mock.Mock( - id='ababab', - image_config={'ContainerConfig': {'Volumes': ['/data']}}) - - def container_get(key): - return { - 'Mounts': [ - { - 'Destination': '/data', - 'Source': '/some/path', - 'Name': 'abab1234', - }, - ] - }.get(key, None) - - prev_container.get.side_effect = container_get - - opts = service._get_container_create_options( - {}, - 1, - previous_container=prev_container) - - assert opts['environment'] == ['affinity:container==ababab'] - - def test_get_container_create_options_no_affinity_without_binds(self): - service = Service('foo', image='foo', client=self.mock_client) - self.mock_client.inspect_image.return_value = {'Id': 'abcd'} - prev_container = mock.Mock( - id='ababab', - image_config={'ContainerConfig': {}}) - prev_container.get.return_value = None - - opts = service._get_container_create_options( - {}, - 1, - previous_container=prev_container) - assert opts['environment'] == [] - def test_get_container_not_found(self): self.mock_client.containers.return_value = [] service = Service('foo', client=self.mock_client, image='foo') @@ -354,10 +205,11 @@ class ServiceTest(unittest.TestCase): @mock.patch('compose.service.log', autospec=True) def test_pull_image(self, mock_log): service = Service('foo', client=self.mock_client, image='someimage:sometag') - service.pull() + service.pull(insecure_registry=True) self.mock_client.pull.assert_called_once_with( 'someimage', tag='sometag', + insecure_registry=True, stream=True) mock_log.info.assert_called_once_with('Pulling foo (someimage:sometag)...') @@ -367,17 +219,25 @@ class ServiceTest(unittest.TestCase): self.mock_client.pull.assert_called_once_with( 'ababab', tag='latest', + insecure_registry=False, stream=True) - @mock.patch('compose.service.log', autospec=True) - def test_pull_image_digest(self, mock_log): - service = Service('foo', client=self.mock_client, image='someimage@sha256:1234') - service.pull() - self.mock_client.pull.assert_called_once_with( - 'someimage', - tag='sha256:1234', - stream=True) - mock_log.info.assert_called_once_with('Pulling foo (someimage@sha256:1234)...') + def test_create_container_from_insecure_registry(self): + service = Service('foo', client=self.mock_client, image='someimage:sometag') + images = [] + + def pull(repo, tag=None, insecure_registry=False, **kwargs): + self.assertEqual('someimage', repo) + self.assertEqual('sometag', tag) + self.assertTrue(insecure_registry) + images.append({'Id': 'abc123'}) + return [] + + service.image = lambda: images[0] if images else None + self.mock_client.pull = pull + + service.create_container(insecure_registry=True) + self.assertEqual(1, len(images)) @mock.patch('compose.service.Container', autospec=True) def test_recreate_container(self, _): @@ -386,416 +246,99 @@ class ServiceTest(unittest.TestCase): service.image = lambda: {'Id': 'abc123'} new_container = service.recreate_container(mock_container) - mock_container.stop.assert_called_once_with(timeout=10) - mock_container.rename_to_tmp_name.assert_called_once_with() + mock_container.stop.assert_called_once_with() + self.mock_client.rename.assert_called_once_with( + mock_container.id, + '%s_%s' % (mock_container.short_id, mock_container.name)) new_container.start.assert_called_once_with() mock_container.remove.assert_called_once_with() - @mock.patch('compose.service.Container', autospec=True) - def test_recreate_container_with_timeout(self, _): - mock_container = mock.create_autospec(Container) - self.mock_client.inspect_image.return_value = {'Id': 'abc123'} - service = Service('foo', client=self.mock_client, image='someimage') - service.recreate_container(mock_container, timeout=1) - - mock_container.stop.assert_called_once_with(timeout=1) - def test_parse_repository_tag(self): - self.assertEqual(parse_repository_tag("root"), ("root", "", ":")) - self.assertEqual(parse_repository_tag("root:tag"), ("root", "tag", ":")) - self.assertEqual(parse_repository_tag("user/repo"), ("user/repo", "", ":")) - self.assertEqual(parse_repository_tag("user/repo:tag"), ("user/repo", "tag", ":")) - self.assertEqual(parse_repository_tag("url:5000/repo"), ("url:5000/repo", "", ":")) - self.assertEqual( - parse_repository_tag("url:5000/repo:tag"), - ("url:5000/repo", "tag", ":")) - self.assertEqual( - parse_repository_tag("root@sha256:digest"), - ("root", "sha256:digest", "@")) - self.assertEqual( - parse_repository_tag("user/repo@sha256:digest"), - ("user/repo", "sha256:digest", "@")) - self.assertEqual( - parse_repository_tag("url:5000/repo@sha256:digest"), - ("url:5000/repo", "sha256:digest", "@")) + self.assertEqual(parse_repository_tag("root"), ("root", "")) + self.assertEqual(parse_repository_tag("root:tag"), ("root", "tag")) + self.assertEqual(parse_repository_tag("user/repo"), ("user/repo", "")) + self.assertEqual(parse_repository_tag("user/repo:tag"), ("user/repo", "tag")) + self.assertEqual(parse_repository_tag("url:5000/repo"), ("url:5000/repo", "")) + self.assertEqual(parse_repository_tag("url:5000/repo:tag"), ("url:5000/repo", "tag")) - def test_create_container(self): - service = Service('foo', client=self.mock_client, build={'context': '.'}) - self.mock_client.inspect_image.side_effect = [ - NoSuchImageError, - {'Id': 'abc123'}, - ] - self.mock_client.build.return_value = [ - '{"stream": "Successfully built abcd"}', - ] + @mock.patch('compose.service.Container', autospec=True) + def test_create_container_latest_is_used_when_no_tag_specified(self, mock_container): + service = Service('foo', client=self.mock_client, image='someimage') + images = [] - with mock.patch('compose.service.log', autospec=True) as mock_log: - service.create_container() - assert mock_log.warn.called - _, args, _ = mock_log.warn.mock_calls[0] - assert 'was built because it did not already exist' in args[0] + def pull(repo, tag=None, **kwargs): + self.assertEqual('someimage', repo) + self.assertEqual('latest', tag) + images.append({'Id': 'abc123'}) + return [] - self.mock_client.build.assert_called_once_with( - tag='default_foo', - dockerfile=None, - stream=True, - path='.', - pull=False, - forcerm=False, - nocache=False, - rm=True, - buildargs=None, - ) + service.image = lambda: images[0] if images else None + self.mock_client.pull = pull - def test_ensure_image_exists_no_build(self): - service = Service('foo', client=self.mock_client, build={'context': '.'}) - self.mock_client.inspect_image.return_value = {'Id': 'abc123'} + service.create_container() + self.assertEqual(1, len(images)) - service.ensure_image_exists(do_build=BuildAction.skip) - assert not self.mock_client.build.called + def test_create_container_with_build(self): + service = Service('foo', client=self.mock_client, build='.') - def test_ensure_image_exists_no_build_but_needs_build(self): - service = Service('foo', client=self.mock_client, build={'context': '.'}) - self.mock_client.inspect_image.side_effect = NoSuchImageError - with pytest.raises(NeedsBuildError): - service.ensure_image_exists(do_build=BuildAction.skip) + images = [] + service.image = lambda *args, **kwargs: images[0] if images else None + service.build = lambda: images.append({'Id': 'abc123'}) - def test_ensure_image_exists_force_build(self): - service = Service('foo', client=self.mock_client, build={'context': '.'}) - self.mock_client.inspect_image.return_value = {'Id': 'abc123'} - self.mock_client.build.return_value = [ - '{"stream": "Successfully built abcd"}', - ] + service.create_container(do_build=True) + self.assertEqual(1, len(images)) - with mock.patch('compose.service.log', autospec=True) as mock_log: - service.ensure_image_exists(do_build=BuildAction.force) + def test_create_container_no_build(self): + service = Service('foo', client=self.mock_client, build='.') + service.image = lambda: {'Id': 'abc123'} - assert not mock_log.warn.called - self.mock_client.build.assert_called_once_with( - tag='default_foo', - dockerfile=None, - stream=True, - path='.', - pull=False, - forcerm=False, - nocache=False, - rm=True, - buildargs=None, - ) + service.create_container(do_build=False) + self.assertFalse(self.mock_client.build.called) - def test_build_does_not_pull(self): - self.mock_client.build.return_value = [ - b'{"stream": "Successfully built 12345"}', - ] + def test_create_container_no_build_but_needs_build(self): + service = Service('foo', client=self.mock_client, build='.') + service.image = lambda: None - service = Service('foo', client=self.mock_client, build={'context': '.'}) - service.build() - - self.assertEqual(self.mock_client.build.call_count, 1) - self.assertFalse(self.mock_client.build.call_args[1]['pull']) - - def test_config_dict(self): - self.mock_client.inspect_image.return_value = {'Id': 'abcd'} - service = Service( - 'foo', - image='example.com/foo', - client=self.mock_client, - network_mode=ServiceNetworkMode(Service('other')), - networks={'default': None}, - links=[(Service('one'), 'one')], - volumes_from=[VolumeFromSpec(Service('two'), 'rw', 'service')]) - - config_dict = service.config_dict() - expected = { - 'image_id': 'abcd', - 'options': {'image': 'example.com/foo'}, - 'links': [('one', 'one')], - 'net': 'other', - 'networks': {'default': None}, - 'volumes_from': [('two', 'rw')], - } - assert config_dict == expected - - def test_config_dict_with_network_mode_from_container(self): - self.mock_client.inspect_image.return_value = {'Id': 'abcd'} - container = Container( - self.mock_client, - {'Id': 'aaabbb', 'Name': '/foo_1'}) - service = Service( - 'foo', - image='example.com/foo', - client=self.mock_client, - network_mode=ContainerNetworkMode(container)) - - config_dict = service.config_dict() - expected = { - 'image_id': 'abcd', - 'options': {'image': 'example.com/foo'}, - 'links': [], - 'networks': {}, - 'net': 'aaabbb', - 'volumes_from': [], - } - assert config_dict == expected - - def test_remove_image_none(self): - web = Service('web', image='example', client=self.mock_client) - assert not web.remove_image(ImageType.none) - assert not self.mock_client.remove_image.called - - def test_remove_image_local_with_image_name_doesnt_remove(self): - web = Service('web', image='example', client=self.mock_client) - assert not web.remove_image(ImageType.local) - assert not self.mock_client.remove_image.called - - def test_remove_image_local_without_image_name_does_remove(self): - web = Service('web', build='.', client=self.mock_client) - assert web.remove_image(ImageType.local) - self.mock_client.remove_image.assert_called_once_with(web.image_name) - - def test_remove_image_all_does_remove(self): - web = Service('web', image='example', client=self.mock_client) - assert web.remove_image(ImageType.all) - self.mock_client.remove_image.assert_called_once_with(web.image_name) - - def test_remove_image_with_error(self): - self.mock_client.remove_image.side_effect = error = APIError( - message="testing", - response={}, - explanation="Boom") - - web = Service('web', image='example', client=self.mock_client) - with mock.patch('compose.service.log', autospec=True) as mock_log: - assert not web.remove_image(ImageType.all) - mock_log.error.assert_called_once_with( - "Failed to remove image for service %s: %s", web.name, error) - - def test_specifies_host_port_with_no_ports(self): - service = Service( - 'foo', - image='foo') - self.assertEqual(service.specifies_host_port(), False) - - def test_specifies_host_port_with_container_port(self): - service = Service( - 'foo', - image='foo', - ports=["2000"]) - self.assertEqual(service.specifies_host_port(), False) - - def test_specifies_host_port_with_host_port(self): - service = Service( - 'foo', - image='foo', - ports=["1000:2000"]) - self.assertEqual(service.specifies_host_port(), True) - - def test_specifies_host_port_with_host_ip_no_port(self): - service = Service( - 'foo', - image='foo', - ports=["127.0.0.1::2000"]) - self.assertEqual(service.specifies_host_port(), False) - - def test_specifies_host_port_with_host_ip_and_port(self): - service = Service( - 'foo', - image='foo', - ports=["127.0.0.1:1000:2000"]) - self.assertEqual(service.specifies_host_port(), True) - - def test_specifies_host_port_with_container_port_range(self): - service = Service( - 'foo', - image='foo', - ports=["2000-3000"]) - self.assertEqual(service.specifies_host_port(), False) - - def test_specifies_host_port_with_host_port_range(self): - service = Service( - 'foo', - image='foo', - ports=["1000-2000:2000-3000"]) - self.assertEqual(service.specifies_host_port(), True) - - def test_specifies_host_port_with_host_ip_no_port_range(self): - service = Service( - 'foo', - image='foo', - ports=["127.0.0.1::2000-3000"]) - self.assertEqual(service.specifies_host_port(), False) - - def test_specifies_host_port_with_host_ip_and_port_range(self): - service = Service( - 'foo', - image='foo', - ports=["127.0.0.1:1000-2000:2000-3000"]) - self.assertEqual(service.specifies_host_port(), True) - - def test_image_name_from_config(self): - image_name = 'example/web:latest' - service = Service('foo', image=image_name) - assert service.image_name == image_name - - def test_image_name_default(self): - service = Service('foo', project='testing') - assert service.image_name == 'testing_foo' - - @mock.patch('compose.service.log', autospec=True) - def test_only_log_warning_when_host_ports_clash(self, mock_log): - self.mock_client.inspect_image.return_value = {'Id': 'abcd'} - name = 'foo' - service = Service( - name, - client=self.mock_client, - ports=["8080:80"]) - - service.scale(0) - self.assertFalse(mock_log.warn.called) - - service.scale(1) - self.assertFalse(mock_log.warn.called) - - service.scale(2) - mock_log.warn.assert_called_once_with( - 'The "{}" service specifies a port on the host. If multiple containers ' - 'for this service are created on a single host, the port will clash.'.format(name)) - - -class TestServiceNetwork(object): - - def test_connect_container_to_networks_short_aliase_exists(self): - mock_client = mock.create_autospec(docker.APIClient) - service = Service( - 'db', - mock_client, - 'myproject', - image='foo', - networks={'project_default': {}}) - container = Container( - None, - { - 'Id': 'abcdef', - 'NetworkSettings': { - 'Networks': { - 'project_default': { - 'Aliases': ['analias', 'abcdef'], - }, - }, - }, - }, - True) - service.connect_container_to_networks(container) - - assert not mock_client.disconnect_container_from_network.call_count - assert not mock_client.connect_container_to_network.call_count - - -def sort_by_name(dictionary_list): - return sorted(dictionary_list, key=lambda k: k['name']) - - -class BuildUlimitsTestCase(unittest.TestCase): - - def test_build_ulimits_with_dict(self): - ulimits = build_ulimits( - { - 'nofile': {'soft': 10000, 'hard': 20000}, - 'nproc': {'soft': 65535, 'hard': 65535} - } - ) - expected = [ - {'name': 'nofile', 'soft': 10000, 'hard': 20000}, - {'name': 'nproc', 'soft': 65535, 'hard': 65535} - ] - assert sort_by_name(ulimits) == sort_by_name(expected) - - def test_build_ulimits_with_ints(self): - ulimits = build_ulimits({'nofile': 20000, 'nproc': 65535}) - expected = [ - {'name': 'nofile', 'soft': 20000, 'hard': 20000}, - {'name': 'nproc', 'soft': 65535, 'hard': 65535} - ] - assert sort_by_name(ulimits) == sort_by_name(expected) - - def test_build_ulimits_with_integers_and_dicts(self): - ulimits = build_ulimits( - { - 'nproc': 65535, - 'nofile': {'soft': 10000, 'hard': 20000} - } - ) - expected = [ - {'name': 'nofile', 'soft': 10000, 'hard': 20000}, - {'name': 'nproc', 'soft': 65535, 'hard': 65535} - ] - assert sort_by_name(ulimits) == sort_by_name(expected) - - -class NetTestCase(unittest.TestCase): - - def test_network_mode(self): - network_mode = NetworkMode('host') - self.assertEqual(network_mode.id, 'host') - self.assertEqual(network_mode.mode, 'host') - self.assertEqual(network_mode.service_name, None) - - def test_network_mode_container(self): - container_id = 'abcd' - network_mode = ContainerNetworkMode(Container(None, {'Id': container_id})) - self.assertEqual(network_mode.id, container_id) - self.assertEqual(network_mode.mode, 'container:' + container_id) - self.assertEqual(network_mode.service_name, None) - - def test_network_mode_service(self): - container_id = 'bbbb' - service_name = 'web' - mock_client = mock.create_autospec(docker.APIClient) - mock_client.containers.return_value = [ - {'Id': container_id, 'Name': container_id, 'Image': 'abcd'}, - ] - - service = Service(name=service_name, client=mock_client) - network_mode = ServiceNetworkMode(service) - - self.assertEqual(network_mode.id, service_name) - self.assertEqual(network_mode.mode, 'container:' + container_id) - self.assertEqual(network_mode.service_name, service_name) - - def test_network_mode_service_no_containers(self): - service_name = 'web' - mock_client = mock.create_autospec(docker.APIClient) - mock_client.containers.return_value = [] - - service = Service(name=service_name, client=mock_client) - network_mode = ServiceNetworkMode(service) - - self.assertEqual(network_mode.id, service_name) - self.assertEqual(network_mode.mode, None) - self.assertEqual(network_mode.service_name, service_name) - - -def build_mount(destination, source, mode='rw'): - return {'Source': source, 'Destination': destination, 'Mode': mode} + with self.assertRaises(NeedsBuildError): + service.create_container(do_build=False) class ServiceVolumesTest(unittest.TestCase): def setUp(self): - self.mock_client = mock.create_autospec(docker.APIClient) + self.mock_client = mock.create_autospec(docker.Client) + + def test_parse_volume_spec_only_one_path(self): + spec = parse_volume_spec('/the/volume') + self.assertEqual(spec, (None, '/the/volume', 'rw')) + + def test_parse_volume_spec_internal_and_external(self): + spec = parse_volume_spec('external:interval') + self.assertEqual(spec, ('external', 'interval', 'rw')) + + def test_parse_volume_spec_with_mode(self): + spec = parse_volume_spec('external:interval:ro') + self.assertEqual(spec, ('external', 'interval', 'ro')) + + def test_parse_volume_spec_too_many_parts(self): + with self.assertRaises(ConfigError): + parse_volume_spec('one:two:three:four') + + def test_parse_volume_bad_mode(self): + with self.assertRaises(ConfigError): + parse_volume_spec('one:two:notrw') def test_build_volume_binding(self): - binding = build_volume_binding(VolumeSpec.parse('/outside:/inside', True)) - assert binding == ('/inside', '/outside:/inside:rw') + binding = build_volume_binding(parse_volume_spec('/outside:/inside')) + self.assertEqual(binding, ('/inside', '/outside:/inside:rw')) def test_get_container_data_volumes(self): - options = [VolumeSpec.parse(v) for v in [ + options = [ '/host/volume:/host/volume:ro', '/new/volume', '/existing/volume', - 'named:/named/vol', - ]] + ] self.mock_client.inspect_image.return_value = { 'ContainerConfig': { @@ -806,84 +349,55 @@ class ServiceVolumesTest(unittest.TestCase): } container = Container(self.mock_client, { 'Image': 'ababab', - 'Mounts': [ - { - 'Source': '/host/volume', - 'Destination': '/host/volume', - 'Mode': '', - 'RW': True, - 'Name': 'hostvolume', - }, { - 'Source': '/var/lib/docker/aaaaaaaa', - 'Destination': '/existing/volume', - 'Mode': '', - 'RW': True, - 'Name': 'existingvolume', - }, { - 'Source': '/var/lib/docker/bbbbbbbb', - 'Destination': '/removed/volume', - 'Mode': '', - 'RW': True, - 'Name': 'removedvolume', - }, { - 'Source': '/var/lib/docker/cccccccc', - 'Destination': '/mnt/image/data', - 'Mode': '', - 'RW': True, - 'Name': 'imagedata', - }, - ] + 'Volumes': { + '/host/volume': '/host/volume', + '/existing/volume': '/var/lib/docker/aaaaaaaa', + '/removed/volume': '/var/lib/docker/bbbbbbbb', + '/mnt/image/data': '/var/lib/docker/cccccccc', + }, }, has_been_inspected=True) - expected = [ - VolumeSpec.parse('existingvolume:/existing/volume:rw'), - VolumeSpec.parse('imagedata:/mnt/image/data:rw'), - ] + expected = { + '/existing/volume': '/var/lib/docker/aaaaaaaa:/existing/volume:rw', + '/mnt/image/data': '/var/lib/docker/cccccccc:/mnt/image/data:rw', + } - volumes = get_container_data_volumes(container, options) - assert sorted(volumes) == sorted(expected) + binds = get_container_data_volumes(container, options) + self.assertEqual(binds, expected) def test_merge_volume_bindings(self): options = [ - VolumeSpec.parse('/host/volume:/host/volume:ro', True), - VolumeSpec.parse('/host/rw/volume:/host/rw/volume', True), - VolumeSpec.parse('/new/volume', True), - VolumeSpec.parse('/existing/volume', True), + '/host/volume:/host/volume:ro', + '/host/rw/volume:/host/rw/volume', + '/new/volume', + '/existing/volume', ] self.mock_client.inspect_image.return_value = { 'ContainerConfig': {'Volumes': {}} } - previous_container = Container(self.mock_client, { - 'Id': 'cdefab', + intermediate_container = Container(self.mock_client, { 'Image': 'ababab', - 'Mounts': [{ - 'Source': '/var/lib/docker/aaaaaaaa', - 'Destination': '/existing/volume', - 'Mode': '', - 'RW': True, - 'Name': 'existingvolume', - }], + 'Volumes': {'/existing/volume': '/var/lib/docker/aaaaaaaa'}, }, has_been_inspected=True) expected = [ '/host/volume:/host/volume:ro', '/host/rw/volume:/host/rw/volume:rw', - 'existingvolume:/existing/volume:rw', + '/var/lib/docker/aaaaaaaa:/existing/volume:rw', ] - binds, affinity = merge_volume_bindings(options, previous_container) - assert sorted(binds) == sorted(expected) - assert affinity == {'affinity:container': '=cdefab'} + binds = merge_volume_bindings(options, intermediate_container) + self.assertEqual(set(binds), set(expected)) def test_mount_same_host_path_to_two_volumes(self): service = Service( 'web', image='busybox', volumes=[ - VolumeSpec.parse('/host/path:/data1', True), - VolumeSpec.parse('/host/path:/data2', True), + '/host/path:/data1', + '/host/path:/data2', ], client=self.mock_client, ) @@ -895,27 +409,26 @@ class ServiceVolumesTest(unittest.TestCase): } } - service._get_container_create_options( + create_options = service._get_container_create_options( override_options={}, number=1, ) self.assertEqual( - set(self.mock_client.create_host_config.call_args[1]['binds']), + set(create_options['host_config']['Binds']), set([ '/host/path:/data1:rw', '/host/path:/data2:rw', ]), ) - def test_get_container_create_options_with_different_host_path_in_container_json(self): + def test_different_host_path_in_container_json(self): service = Service( 'web', image='busybox', - volumes=[VolumeSpec.parse('/host/path:/data')], + volumes=['/host/path:/data'], client=self.mock_client, ) - volume_name = 'abcdefff1234' self.mock_client.inspect_image.return_value = { 'Id': 'ababab', @@ -929,88 +442,18 @@ class ServiceVolumesTest(unittest.TestCase): self.mock_client.inspect_container.return_value = { 'Id': '123123123', 'Image': 'ababab', - 'Mounts': [ - { - 'Destination': '/data', - 'Source': '/mnt/sda1/host/path', - 'Mode': '', - 'RW': True, - 'Driver': 'local', - 'Name': volume_name, - }, - ] + 'Volumes': { + '/data': '/mnt/sda1/host/path', + }, } - service._get_container_create_options( + create_options = service._get_container_create_options( override_options={}, number=1, previous_container=Container(self.mock_client, {'Id': '123123123'}), ) - assert ( - self.mock_client.create_host_config.call_args[1]['binds'] == - ['{}:/data:rw'.format(volume_name)] - ) - - def test_warn_on_masked_volume_no_warning_when_no_container_volumes(self): - volumes_option = [VolumeSpec('/home/user', '/path', 'rw')] - container_volumes = [] - service = 'service_name' - - with mock.patch('compose.service.log', autospec=True) as mock_log: - warn_on_masked_volume(volumes_option, container_volumes, service) - - assert not mock_log.warn.called - - def test_warn_on_masked_volume_when_masked(self): - volumes_option = [VolumeSpec('/home/user', '/path', 'rw')] - container_volumes = [ - VolumeSpec('/var/lib/docker/path', '/path', 'rw'), - VolumeSpec('/var/lib/docker/path', '/other', 'rw'), - ] - service = 'service_name' - - with mock.patch('compose.service.log', autospec=True) as mock_log: - warn_on_masked_volume(volumes_option, container_volumes, service) - - mock_log.warn.assert_called_once_with(mock.ANY) - - def test_warn_on_masked_no_warning_with_same_path(self): - volumes_option = [VolumeSpec('/home/user', '/path', 'rw')] - container_volumes = [VolumeSpec('/home/user', '/path', 'rw')] - service = 'service_name' - - with mock.patch('compose.service.log', autospec=True) as mock_log: - warn_on_masked_volume(volumes_option, container_volumes, service) - - assert not mock_log.warn.called - - def test_warn_on_masked_no_warning_with_container_only_option(self): - volumes_option = [VolumeSpec(None, '/path', 'rw')] - container_volumes = [ - VolumeSpec('/var/lib/docker/volume/path', '/path', 'rw') - ] - service = 'service_name' - - with mock.patch('compose.service.log', autospec=True) as mock_log: - warn_on_masked_volume(volumes_option, container_volumes, service) - - assert not mock_log.warn.called - - def test_create_with_special_volume_mode(self): - self.mock_client.inspect_image.return_value = {'Id': 'imageid'} - - self.mock_client.create_container.return_value = {'Id': 'containerid'} - - volume = '/tmp:/foo:z' - Service( - 'web', - client=self.mock_client, - image='busybox', - volumes=[VolumeSpec.parse(volume, True)], - ).create_container() - - assert self.mock_client.create_container.call_count == 1 self.assertEqual( - self.mock_client.create_host_config.call_args[1]['binds'], - [volume]) + create_options['host_config']['Binds'], + ['/mnt/sda1/host/path:/data:rw'], + ) diff --git a/tests/unit/config/sort_services_test.py b/tests/unit/sort_service_test.py similarity index 57% rename from tests/unit/config/sort_services_test.py rename to tests/unit/sort_service_test.py index c39ac022..f42a9474 100644 --- a/tests/unit/config/sort_services_test.py +++ b/tests/unit/sort_service_test.py @@ -1,14 +1,8 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import pytest - -from compose.config.errors import DependencyError -from compose.config.sort_services import sort_service_dicts -from compose.config.types import VolumeFromSpec +from compose.project import sort_service_dicts, DependencyError +from .. import unittest -class TestSortService(object): +class SortServiceTest(unittest.TestCase): def test_sort_service_dicts_1(self): services = [ { @@ -24,10 +18,10 @@ class TestSortService(object): ] sorted_services = sort_service_dicts(services) - assert len(sorted_services) == 3 - assert sorted_services[0]['name'] == 'grunt' - assert sorted_services[1]['name'] == 'redis' - assert sorted_services[2]['name'] == 'web' + self.assertEqual(len(sorted_services), 3) + self.assertEqual(sorted_services[0]['name'], 'grunt') + self.assertEqual(sorted_services[1]['name'], 'redis') + self.assertEqual(sorted_services[2]['name'], 'web') def test_sort_service_dicts_2(self): services = [ @@ -45,10 +39,10 @@ class TestSortService(object): ] sorted_services = sort_service_dicts(services) - assert len(sorted_services) == 3 - assert sorted_services[0]['name'] == 'redis' - assert sorted_services[1]['name'] == 'postgres' - assert sorted_services[2]['name'] == 'web' + self.assertEqual(len(sorted_services), 3) + self.assertEqual(sorted_services[0]['name'], 'redis') + self.assertEqual(sorted_services[1]['name'], 'postgres') + self.assertEqual(sorted_services[2]['name'], 'web') def test_sort_service_dicts_3(self): services = [ @@ -66,10 +60,10 @@ class TestSortService(object): ] sorted_services = sort_service_dicts(services) - assert len(sorted_services) == 3 - assert sorted_services[0]['name'] == 'child' - assert sorted_services[1]['name'] == 'parent' - assert sorted_services[2]['name'] == 'grandparent' + self.assertEqual(len(sorted_services), 3) + self.assertEqual(sorted_services[0]['name'], 'child') + self.assertEqual(sorted_services[1]['name'], 'parent') + self.assertEqual(sorted_services[2]['name'], 'grandparent') def test_sort_service_dicts_4(self): services = [ @@ -78,7 +72,7 @@ class TestSortService(object): }, { 'name': 'parent', - 'volumes_from': [VolumeFromSpec('child', 'rw', 'service')] + 'volumes_from': ['child'] }, { 'links': ['parent'], @@ -87,10 +81,10 @@ class TestSortService(object): ] sorted_services = sort_service_dicts(services) - assert len(sorted_services) == 3 - assert sorted_services[0]['name'] == 'child' - assert sorted_services[1]['name'] == 'parent' - assert sorted_services[2]['name'] == 'grandparent' + self.assertEqual(len(sorted_services), 3) + self.assertEqual(sorted_services[0]['name'], 'child') + self.assertEqual(sorted_services[1]['name'], 'parent') + self.assertEqual(sorted_services[2]['name'], 'grandparent') def test_sort_service_dicts_5(self): services = [ @@ -100,7 +94,7 @@ class TestSortService(object): }, { 'name': 'parent', - 'network_mode': 'service:child' + 'net': 'container:child' }, { 'name': 'child' @@ -108,10 +102,10 @@ class TestSortService(object): ] sorted_services = sort_service_dicts(services) - assert len(sorted_services) == 3 - assert sorted_services[0]['name'] == 'child' - assert sorted_services[1]['name'] == 'parent' - assert sorted_services[2]['name'] == 'grandparent' + self.assertEqual(len(sorted_services), 3) + self.assertEqual(sorted_services[0]['name'], 'child') + self.assertEqual(sorted_services[1]['name'], 'parent') + self.assertEqual(sorted_services[2]['name'], 'grandparent') def test_sort_service_dicts_6(self): services = [ @@ -121,7 +115,7 @@ class TestSortService(object): }, { 'name': 'parent', - 'volumes_from': [VolumeFromSpec('child', 'ro', 'service')] + 'volumes_from': ['child'] }, { 'name': 'child' @@ -129,15 +123,15 @@ class TestSortService(object): ] sorted_services = sort_service_dicts(services) - assert len(sorted_services) == 3 - assert sorted_services[0]['name'] == 'child' - assert sorted_services[1]['name'] == 'parent' - assert sorted_services[2]['name'] == 'grandparent' + self.assertEqual(len(sorted_services), 3) + self.assertEqual(sorted_services[0]['name'], 'child') + self.assertEqual(sorted_services[1]['name'], 'parent') + self.assertEqual(sorted_services[2]['name'], 'grandparent') def test_sort_service_dicts_7(self): services = [ { - 'network_mode': 'service:three', + 'net': 'container:three', 'name': 'four' }, { @@ -146,7 +140,7 @@ class TestSortService(object): }, { 'name': 'two', - 'volumes_from': [VolumeFromSpec('one', 'rw', 'service')] + 'volumes_from': ['one'] }, { 'name': 'one' @@ -154,11 +148,11 @@ class TestSortService(object): ] sorted_services = sort_service_dicts(services) - assert len(sorted_services) == 4 - assert sorted_services[0]['name'] == 'one' - assert sorted_services[1]['name'] == 'two' - assert sorted_services[2]['name'] == 'three' - assert sorted_services[3]['name'] == 'four' + self.assertEqual(len(sorted_services), 4) + self.assertEqual(sorted_services[0]['name'], 'one') + self.assertEqual(sorted_services[1]['name'], 'two') + self.assertEqual(sorted_services[2]['name'], 'three') + self.assertEqual(sorted_services[3]['name'], 'four') def test_sort_service_dicts_circular_imports(self): services = [ @@ -172,10 +166,13 @@ class TestSortService(object): }, ] - with pytest.raises(DependencyError) as exc: + try: sort_service_dicts(services) - assert 'redis' in exc.exconly() - assert 'web' in exc.exconly() + except DependencyError as e: + self.assertIn('redis', e.msg) + self.assertIn('web', e.msg) + else: + self.fail('Should have thrown an DependencyError') def test_sort_service_dicts_circular_imports_2(self): services = [ @@ -192,10 +189,13 @@ class TestSortService(object): } ] - with pytest.raises(DependencyError) as exc: + try: sort_service_dicts(services) - assert 'redis' in exc.exconly() - assert 'web' in exc.exconly() + except DependencyError as e: + self.assertIn('redis', e.msg) + self.assertIn('web', e.msg) + else: + self.fail('Should have thrown an DependencyError') def test_sort_service_dicts_circular_imports_3(self): services = [ @@ -213,10 +213,13 @@ class TestSortService(object): } ] - with pytest.raises(DependencyError) as exc: + try: sort_service_dicts(services) - assert 'a' in exc.exconly() - assert 'b' in exc.exconly() + except DependencyError as e: + self.assertIn('a', e.msg) + self.assertIn('b', e.msg) + else: + self.fail('Should have thrown an DependencyError') def test_sort_service_dicts_self_imports(self): services = [ @@ -226,18 +229,9 @@ class TestSortService(object): }, ] - with pytest.raises(DependencyError) as exc: + try: sort_service_dicts(services) - assert 'web' in exc.exconly() - - def test_sort_service_dicts_depends_on_self(self): - services = [ - { - 'depends_on': ['web'], - 'name': 'web' - }, - ] - - with pytest.raises(DependencyError) as exc: - sort_service_dicts(services) - assert 'A service can not depend on itself: web' in exc.exconly() + except DependencyError as e: + self.assertIn('web', e.msg) + else: + self.fail('Should have thrown an DependencyError') diff --git a/tests/unit/split_buffer_test.py b/tests/unit/split_buffer_test.py index c41ea27d..8eb54177 100644 --- a/tests/unit/split_buffer_test.py +++ b/tests/unit/split_buffer_test.py @@ -1,8 +1,7 @@ -from __future__ import absolute_import from __future__ import unicode_literals - +from __future__ import absolute_import +from compose.cli.utils import split_buffer from .. import unittest -from compose.utils import split_buffer class SplitBufferTest(unittest.TestCase): @@ -12,7 +11,7 @@ class SplitBufferTest(unittest.TestCase): yield b'def\n' yield b'ghi\n' - self.assert_produces(reader, ['abc\n', 'def\n', 'ghi\n']) + self.assert_produces(reader, [b'abc\n', b'def\n', b'ghi\n']) def test_no_end_separator(self): def reader(): @@ -20,13 +19,13 @@ class SplitBufferTest(unittest.TestCase): yield b'def\n' yield b'ghi' - self.assert_produces(reader, ['abc\n', 'def\n', 'ghi']) + self.assert_produces(reader, [b'abc\n', b'def\n', b'ghi']) def test_multiple_line_chunk(self): def reader(): yield b'abc\ndef\nghi' - self.assert_produces(reader, ['abc\n', 'def\n', 'ghi']) + self.assert_produces(reader, [b'abc\n', b'def\n', b'ghi']) def test_chunked_line(self): def reader(): @@ -36,18 +35,18 @@ class SplitBufferTest(unittest.TestCase): yield b'\n' yield b'd' - self.assert_produces(reader, ['abc\n', 'd']) + self.assert_produces(reader, [b'abc\n', b'd']) def test_preserves_unicode_sequences_within_lines(self): - string = u"a\u2022c\n" + string = u"a\u2022c\n".encode('utf-8') def reader(): - yield string.encode('utf-8') + yield string self.assert_produces(reader, [string]) def assert_produces(self, reader, expectations): - split = split_buffer(reader()) + split = split_buffer(reader(), b'\n') for (actual, expected) in zip(split, expectations): self.assertEqual(type(actual), type(expected)) diff --git a/tests/unit/timeparse_test.py b/tests/unit/timeparse_test.py deleted file mode 100644 index 9915932c..00000000 --- a/tests/unit/timeparse_test.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -from compose import timeparse - - -def test_milli(): - assert timeparse.timeparse('5ms') == 0.005 - - -def test_milli_float(): - assert timeparse.timeparse('50.5ms') == 0.0505 - - -def test_second_milli(): - assert timeparse.timeparse('200s5ms') == 200.005 - - -def test_second_milli_micro(): - assert timeparse.timeparse('200s5ms10us') == 200.00501 - - -def test_second(): - assert timeparse.timeparse('200s') == 200 - - -def test_second_as_float(): - assert timeparse.timeparse('20.5s') == 20.5 - - -def test_minute(): - assert timeparse.timeparse('32m') == 1920 - - -def test_hour_minute(): - assert timeparse.timeparse('2h32m') == 9120 - - -def test_minute_as_float(): - assert timeparse.timeparse('1.5m') == 90 - - -def test_hour_minute_second(): - assert timeparse.timeparse('5h34m56s') == 20096 - - -def test_invalid_with_space(): - assert timeparse.timeparse('5h 34m 56s') is None - - -def test_invalid_with_comma(): - assert timeparse.timeparse('5h,34m,56s') is None - - -def test_invalid_with_empty_string(): - assert timeparse.timeparse('') is None diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py deleted file mode 100644 index 85231957..00000000 --- a/tests/unit/utils_test.py +++ /dev/null @@ -1,62 +0,0 @@ -# encoding: utf-8 -from __future__ import absolute_import -from __future__ import unicode_literals - -from compose import utils - - -class TestJsonSplitter(object): - - def test_json_splitter_no_object(self): - data = '{"foo": "bar' - assert utils.json_splitter(data) is None - - def test_json_splitter_with_object(self): - data = '{"foo": "bar"}\n \n{"next": "obj"}' - assert utils.json_splitter(data) == ({'foo': 'bar'}, '{"next": "obj"}') - - def test_json_splitter_leading_whitespace(self): - data = '\n \r{"foo": "bar"}\n\n {"next": "obj"}' - assert utils.json_splitter(data) == ({'foo': 'bar'}, '{"next": "obj"}') - - -class TestStreamAsText(object): - - def test_stream_with_non_utf_unicode_character(self): - stream = [b'\xed\xf3\xf3'] - output, = utils.stream_as_text(stream) - assert output == '���' - - def test_stream_with_utf_character(self): - stream = ['ěĝ'.encode('utf-8')] - output, = utils.stream_as_text(stream) - assert output == 'ěĝ' - - -class TestJsonStream(object): - - def test_with_falsy_entries(self): - stream = [ - '{"one": "two"}\n{}\n', - "[1, 2, 3]\n[]\n", - ] - output = list(utils.json_stream(stream)) - assert output == [ - {'one': 'two'}, - {}, - [1, 2, 3], - [], - ] - - def test_with_leading_whitespace(self): - stream = [ - '\n \r\n {"one": "two"}{"x": 1}', - ' {"three": "four"}\t\t{"x": 2}' - ] - output = list(utils.json_stream(stream)) - assert output == [ - {'one': 'two'}, - {'x': 1}, - {'three': 'four'}, - {'x': 2} - ] diff --git a/tests/unit/volume_test.py b/tests/unit/volume_test.py deleted file mode 100644 index 24829192..00000000 --- a/tests/unit/volume_test.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import absolute_import -from __future__ import unicode_literals - -import docker -import pytest - -from compose import volume -from tests import mock - - -@pytest.fixture -def mock_client(): - return mock.create_autospec(docker.APIClient) - - -class TestVolume(object): - - def test_remove_local_volume(self, mock_client): - vol = volume.Volume(mock_client, 'foo', 'project') - vol.remove() - mock_client.remove_volume.assert_called_once_with('foo_project') - - def test_remove_external_volume(self, mock_client): - vol = volume.Volume(mock_client, 'foo', 'project', external_name='data') - vol.remove() - assert not mock_client.remove_volume.called diff --git a/tox.ini b/tox.ini index 61bc0574..33cdee16 100644 --- a/tox.ini +++ b/tox.ini @@ -1,51 +1,16 @@ [tox] -envlist = py27,py34,pre-commit +envlist = py26,py27 [testenv] usedevelop=True -passenv = - LD_LIBRARY_PATH - DOCKER_HOST - DOCKER_CERT_PATH - DOCKER_TLS_VERIFY - DOCKER_VERSION -setenv = - HOME=/tmp deps = -rrequirements.txt -rrequirements-dev.txt commands = - py.test -v \ - --cov=compose \ - --cov-report html \ - --cov-report term \ - --cov-config=tox.ini \ - {posargs:tests} - -[testenv:pre-commit] -skip_install = True -deps = - pre-commit -commands = - pre-commit install - pre-commit run --all-files - -# Coverage configuration -[run] -branch = True - -[report] -show_missing = true - -[html] -directory = coverage-html -# end coverage configuration + nosetests -v {posargs} + flake8 compose tests setup.py [flake8] -max-line-length = 105 -# Set this high for now -max-complexity = 11 +# ignore line-length for now +ignore = E501,E203 exclude = compose/packages - -[pytest] -addopts = --tb=short -rxs