├── .ruby-version ├── .coveralls.yml ├── .rspec ├── lib └── docker │ ├── compose │ ├── version.rb │ ├── collection.rb │ ├── shell_printer │ │ ├── fish.rb │ │ └── posix.rb │ ├── shell_printer.rb │ ├── error.rb │ ├── container.rb │ ├── net_info.rb │ ├── rake_tasks.rb │ ├── mapper.rb │ └── session.rb │ └── compose.rb ├── bin ├── setup └── console ├── .travis.yml ├── .gitignore ├── Rakefile ├── spec ├── spec_helper.rb └── docker │ ├── compose_spec.rb │ └── compose │ ├── mapper_spec.rb │ └── session_spec.rb ├── docker-compose.yml ├── Gemfile ├── .github └── workflows │ ├── test.yml │ └── publish.yml ├── CHANGELOG.md ├── .rubocop.yml ├── LICENSE.txt ├── docker-compose.gemspec ├── CODE_OF_CONDUCT.md └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.0.0 2 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | repo_token: hMWX6XPNvG4Gfy9gCDEvrxhQVKdyN5e2m 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/docker/compose/version.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module Docker 3 | module Compose 4 | VERSION = '1.99.0-dev' 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0 4 | - 2.1 5 | - 2.3.1 6 | - 2.7.1 7 | - 3.0.0 8 | before_install: gem install bundler -v 1.10.6 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /.idea 11 | 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'bundler/gem_tasks' 3 | require 'rspec/core/rake_task' 4 | 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | task default: :spec 8 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'coveralls' 2 | Coveralls.wear! 3 | 4 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 5 | require 'docker/compose' 6 | 7 | begin 8 | require 'pry' 9 | rescue LoadError 10 | # debugging is optional 11 | end 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # basic smoke test for the gem. 2 | # TODO: write cucumber features for comprehensive functional tests! 3 | apple: 4 | image: busybox 5 | command: /bin/sh -c 'sleep 3600' 6 | ports: 7 | - 123 8 | - 456 9 | orange: 10 | image: busybox 11 | command: /bin/sh -c 'sleep 3600' 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in docker-compose.gemspec 4 | gemspec 5 | 6 | group :development do 7 | gem 'pry' 8 | gem 'pry-byebug' 9 | gem 'rubocop' 10 | gem 'rubocop-rake' 11 | gem 'rubocop-rspec' 12 | end 13 | 14 | group :test do 15 | gem 'coveralls', require: false 16 | end 17 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | # encoding: utf-8 3 | 4 | require 'bundler/setup' 5 | require 'docker/compose' 6 | 7 | @session = Docker::Compose::Session.new 8 | 9 | begin 10 | require 'pry' 11 | Pry.start(@session) 12 | rescue LoadError 13 | require 'irb' 14 | IRB.setup nil 15 | IRB.conf[:MAIN_CONTEXT] = IRB::Irb.new.context 16 | require 'irb/ext/multi-irb' 17 | IRB.irb nil, @session 18 | end 19 | -------------------------------------------------------------------------------- /lib/docker/compose/collection.rb: -------------------------------------------------------------------------------- 1 | module Docker::Compose 2 | class Collection < Array 3 | # @example find containers that are up 4 | # who_is_up = coll.where { |c| c.up? } 5 | # @example count space taken by all containers 6 | # coll.map { |c| c.size }.inject(0) { |a, x| a + x } 7 | def where 8 | hits = Collection.new 9 | self.each { |c| hits << c if yield(c) } 10 | hits 11 | end 12 | end 13 | end -------------------------------------------------------------------------------- /lib/docker/compose/shell_printer/fish.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module Docker::Compose::ShellPrinter 3 | # Printer that works with the Friendly Interactive Shell (fish). 4 | class Fish < Posix 5 | def eval_output(command) 6 | format('eval (%s)', command) 7 | end 8 | 9 | def export(name, value) 10 | format('set -gx %s %s; ', name, single_quoted_escaped(value)) 11 | end 12 | 13 | def unset(name) 14 | format('set -ex %s; ', name) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/docker/compose.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'net/http' 3 | 4 | require_relative 'compose/version' 5 | require_relative 'compose/error' 6 | require_relative 'compose/container' 7 | require_relative 'compose/collection' 8 | require_relative 'compose/session' 9 | require_relative 'compose/net_info' 10 | require_relative 'compose/mapper' 11 | 12 | module Docker 13 | module Compose 14 | # Create a new session with default options. 15 | def self.new 16 | Session.new 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/docker/compose_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Docker::Compose do 4 | it 'has a version number' do 5 | expect(Docker::Compose::VERSION).not_to be nil 6 | end 7 | 8 | describe '.new' do 9 | subject { described_class } 10 | 11 | it 'makes a session' do 12 | meth = Docker::Compose::Session.instance_methods - 13 | Docker::Compose::Session.superclass.instance_methods 14 | s1 = subject.new 15 | meth.each { |m| expect(s1).to respond_to m } 16 | s2 = subject.new 17 | expect(s1).not_to be(s2) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/docker/compose/shell_printer.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'etc' 3 | 4 | module Docker::Compose 5 | module ShellPrinter 6 | def self.new 7 | shell = Etc.getpwuid(Process.uid).shell 8 | basename = File.basename(shell) 9 | 10 | # Crappy titleize (bash -> Bash) 11 | basename[0] = basename[0].upcase 12 | 13 | # Find adapter class named after shell; default to posix if nothing found 14 | klass = begin 15 | const_get(basename.to_sym) 16 | rescue 17 | Posix 18 | end 19 | 20 | klass.new 21 | end 22 | end 23 | end 24 | 25 | require_relative 'shell_printer/posix' 26 | require_relative 'shell_printer/fish' 27 | -------------------------------------------------------------------------------- /lib/docker/compose/error.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module Docker::Compose 3 | class Error < RuntimeError 4 | attr_reader :status, :detail 5 | 6 | # @param [String] cmd 7 | # @param [Integer] status 8 | # @param [String] detail 9 | def initialize(cmd, status, detail) 10 | @status = status 11 | @detail = detail 12 | 13 | brief = detail.split(/[\r\n]+/).select { |l| !l.empty? }.first || '(no output)' 14 | 15 | case status 16 | when Numeric 17 | status = status.to_s 18 | else 19 | status = "'#{status}'" 20 | end 21 | 22 | message = format("'%s' failed with status %s: %s", cmd, status, brief) 23 | super(message) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | - pull_request 5 | - push 6 | 7 | jobs: 8 | build: 9 | name: build 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: ruby/setup-ruby@v1 15 | with: 16 | ruby-version: 3.0.0 17 | bundler-cache: true 18 | - name: build 19 | run: bundle exec rake build 20 | 21 | test: 22 | name: test 23 | runs-on: ubuntu-latest 24 | 25 | strategy: 26 | matrix: 27 | ruby-version: ["2.6", "2.7", "3.0"] 28 | 29 | steps: 30 | - uses: actions/checkout@v3 31 | - uses: ruby/setup-ruby@v1 32 | with: 33 | ruby-version: ${{ matrix.ruby-version }} 34 | bundler-cache: true 35 | - name: rspec 36 | run: bundle exec rspec 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 1.1 2 | --- 3 | 4 | #### New features 5 | 6 | Add `scale` command to Session methods. 7 | 8 | 9 | 1.0 10 | --- 11 | 12 | No significant changes; the 1.0 increment is to indicate that Docker::Compose now has test coverage, and that we intend to maintain a stable API until 2.0. 13 | 14 | 0.6 15 | --- 16 | 17 | #### Interface-breaking changes 18 | 19 | - Replaced `docker:compose:server` Rake task with more general `docker:compose:host` 20 | - Replaced `server_env` option with `host_env` 21 | - Replaced `extra_server_env` option with `extra_host_env` 22 | - Stopped mapping values in `extra_host_env`; they are now exported verbatim 23 | 24 | #### New features 25 | 26 | Produce `docker:compose:env` output that is compatible with user's login shell. 27 | 28 | 0.5 29 | --- 30 | 31 | Initial public release of prototype. Features work well, but there is no test 32 | coverage. 33 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # CODING CONVENTIONS 2 | # - any cop that can be auto-corrected with `rubocop -a` is mandatory 3 | # - other cops may be enabled if committer fixes all issues 4 | AllCops: 5 | NewCops: enable 6 | Exclude: 7 | - features/**/* 8 | - spec/**/* 9 | Style/Encoding: 10 | Enabled: true 11 | Style/FormatString: 12 | Enabled: false 13 | Style/GuardClause: 14 | Enabled: false 15 | Layout/LineLength: 16 | Enabled: false 17 | Lint/AmbiguousRegexpLiteral: 18 | Enabled: false 19 | Metrics/AbcSize: 20 | Enabled: false 21 | Metrics/BlockLength: 22 | Enabled: false 23 | Metrics/ClassLength: 24 | Enabled: false 25 | Metrics/MethodLength: 26 | Enabled: false 27 | Metrics/CyclomaticComplexity: 28 | Enabled: false 29 | Metrics/PerceivedComplexity: 30 | Enabled: false 31 | Style/ClassAndModuleChildren: 32 | Enabled: false 33 | Style/Documentation: 34 | Enabled: false 35 | Style/RaiseArgs: 36 | Enabled: false 37 | Style/Semicolon: 38 | Enabled: false 39 | -------------------------------------------------------------------------------- /spec/docker/compose/mapper_spec.rb: -------------------------------------------------------------------------------- 1 | describe Docker::Compose::Mapper do 2 | let(:session) { double('compose session') } 3 | let(:net_info) { double('net info') } 4 | 5 | let(:env) { {'DB_HOST' => 'service:1234'} } 6 | subject { described_class.new(session, net_info) } 7 | 8 | it 'maps' do 9 | allow(session).to receive(:port).with('service', '1234').and_return('0.0.0.0:32154') 10 | expect(subject.map('http://service:1234')).to eq('http://0.0.0.0:32154') 11 | expect(subject.map('service:1234')).to eq('0.0.0.0:32154') 12 | expect(subject.map('service:[1234]')).to eq('0.0.0.0') 13 | expect(subject.map('[service]:1234')).to eq('32154') 14 | expect(lambda { 15 | subject.map(':::::') 16 | }).to raise_error 17 | 18 | expect(lambda { 19 | subject.map('notreallyaservice:8080') 20 | }).to raise_error 21 | 22 | described_class.map(env, session:session, net_info:net_info) do |k, v| 23 | expect(k).to eq('DB_HOST') 24 | expect(v).to eq('0.0.0.0:32154') 25 | end 26 | end 27 | end -------------------------------------------------------------------------------- /lib/docker/compose/shell_printer/posix.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module Docker::Compose::ShellPrinter 3 | # Printer that works with any POSIX-compliant shell e.g. sh, bash, zsh 4 | class Posix 5 | def comment(value) 6 | format('# %s', value) 7 | end 8 | 9 | def eval_output(command) 10 | format('eval "$(%s)"', command) 11 | end 12 | 13 | def export(name, value) 14 | format('export %s=%s', name, single_quoted_escaped(value)) 15 | end 16 | 17 | def unset(name) 18 | format('unset %s', name) 19 | end 20 | 21 | protected def single_quoted_escaped(value) 22 | # "escape" any occurrences of ' in value by closing the single-quoted 23 | # string, concatenating a single backslash-escaped ' character, and reopening 24 | # the single-quoted string. 25 | # 26 | # This relies on the shell's willingness to concatenate adjacent string 27 | # literals. Tested under sh, bash and zsh; should work elsewhere. 28 | escaped = value.gsub("'") { "'\\''" } 29 | 30 | "'#{escaped}'" 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Tony Spataro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker-compose.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'docker/compose/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'docker-compose' 8 | spec.version = Docker::Compose::VERSION 9 | spec.authors = ['Tony Spataro'] 10 | spec.email = ['xeger@xeger.net'] 11 | 12 | spec.summary = 'Wrapper docker-compose with added Rake smarts.' 13 | spec.description = 'Provides an OOP interface to docker-compose and facilitates container-to-host and host-to-container networking.' 14 | spec.homepage = 'https://github.com/xeger/docker-compose' 15 | spec.license = 'MIT' 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 18 | spec.bindir = 'exe' 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ['lib'] 21 | 22 | spec.required_ruby_version = Gem::Requirement.new('>= 2.0', '< 4.0') 23 | 24 | spec.add_dependency 'backticks', '~> 1.0' 25 | 26 | spec.add_development_dependency 'bundler', '~> 2.3' 27 | spec.add_development_dependency 'rake', '~> 10.0' 28 | spec.add_development_dependency 'rspec' 29 | end 30 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish: 9 | name: publish 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | packages: write 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: 3.0.0 20 | bundler-cache: true 21 | - name: version 22 | run: | 23 | release='${{ github.event.release.name }}' 24 | version=`echo $release | cut -b2-` 25 | if ! echo $release | grep -q '^v[0-9]\+\.[0-9]\+\.[0-9]\+$'; then 26 | echo "Release name must be in the format of 'vX.Y.Z', got '$release'" 27 | exit 1 28 | fi 29 | vfiles=($(grep -Elr "^\s*VERSION = '.+'" *)) 30 | if ${#vfiles[@]} -gt 1; then 31 | echo "Found multiple VERSION files: $vfiles" 32 | exit 1 33 | fi 34 | sed -i -r "s/VERSION = '.+'/VERSION = '$version'/" ${vfiles[0]} 35 | - name: build 36 | run: | 37 | bundle exec rake build 38 | - name: push 39 | run: | 40 | mkdir -p $HOME/.gem 41 | touch $HOME/.gem/credentials 42 | chmod 0600 $HOME/.gem/credentials 43 | cat << EOF > ~/.gem/credentials 44 | --- 45 | :github: Bearer ${GITHUB_TOKEN} 46 | :rubygems_api_key: ${RUBYGEMS_TOKEN} 47 | EOF 48 | 49 | gem push --key github --host https://rubygems.pkg.github.com/${OWNER} pkg/*.gem 50 | gem push pkg/*.gem 51 | env: 52 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 53 | OWNER: ${{ github.repository_owner }} 54 | RUBYGEMS_TOKEN: "${{ secrets.RUBYGEMS_TOKEN }}" 55 | -------------------------------------------------------------------------------- /lib/docker/compose/container.rb: -------------------------------------------------------------------------------- 1 | module Docker::Compose 2 | class Container 3 | # Format string for `docker ps` 4 | PS_FMT = '({{.ID}}) ({{.Image}}) ({{.Size}}) ({{.Status}}) ({{.Names}}) ({{.Labels}}) ({{.Ports}})' 5 | # Number of template substitutions in PS_FMT 6 | PS_FMT_LEN = PS_FMT.count('.') 7 | # Pattern that parses human-readable values from ps .Status 8 | PS_STATUS = /^([A-Za-z]+) ?\(?([0-9]*)\)? ?(.*)$/i 9 | # Pattern that parses FIRST occurrence of container size from a string, 10 | # along with its units. 11 | PS_SIZE = /^([0-9.]+)\s*([kmgt]?B)/i 12 | 13 | attr_reader :id, :image, :size, :status, :exitstatus 14 | attr_reader :names, :labels, :ports 15 | 16 | # @param [String] id 17 | # @param [String] image 18 | # @param [String,Integer] size e.g. "0B (virtual 100MB)" 19 | # @param [String,#map] status e.g. ['Exited', '0', '3 minutes ago'] 20 | # @param [String,Array] names list of container names (CSV) 21 | # @param [String,Array] labels list of container labels (CSV) 22 | # @param [String,Array] ports list of exposed ports (CSV) 23 | def initialize(id, image, size, status, names, labels, ports) 24 | if size.is_a?(String) && (m = PS_SIZE.match(size)) 25 | scalar, units = m[1], m[2] 26 | scalar = scalar.to_f # lazy: invalid --> 0.0 27 | mult = case units.downcase 28 | when 'b' then 1 29 | when 'kb' then 1_024 30 | when 'mb' then 1_024**2 31 | when 'gb' then 1_024**3 32 | when 'tb' then 1_024**4 33 | else 34 | raise Error.new('Service#new', units, 'Impossibly large unit') 35 | end 36 | size = scalar * mult 37 | end 38 | 39 | if status.is_a?(String) 40 | status = PS_STATUS.match(status) 41 | raise Error.new('Service#new', status, 'Unrecognized status') unless status 42 | end 43 | 44 | names = names.split(',').map{ |x| x.strip } if names.is_a?(String) 45 | labels = labels.split(',').map{ |x| x.strip } if labels.is_a?(String) 46 | ports = ports.split(',').map{ |x| x.strip } if ports.is_a?(String) 47 | 48 | @id = id 49 | @image = image 50 | @size = size 51 | @status = status[1].downcase.to_sym 52 | 53 | @exitstatus = case @status 54 | when :up 55 | nil 56 | else 57 | status[2].to_i 58 | end 59 | 60 | @names = names 61 | @labels = labels 62 | @ports = ports 63 | end 64 | 65 | # static sanity checking ftw! 66 | unless ( initarity = instance_method(:initialize).arity ) == PS_FMT_LEN 67 | raise LoadError.new("#{__FILE__}:#{__LINE__} - arity(\#initialize) != PS_FMT_LEN; #{initarity} != #{PS_FMT_LEN}") 68 | end 69 | 70 | # @return [String] 71 | def name 72 | names.first 73 | end 74 | 75 | # @return [Boolean] 76 | def up? 77 | self.status == :up 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/docker/compose/net_info.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module Docker::Compose 3 | # Utility that gathers information about the relationship between the host 4 | # on which the Ruby VM is running and the docker host, then makes an 5 | # guess about the mutually routable IP addresses of each. 6 | # 7 | # This information can be used to tell containers how to connect to ports on 8 | # the local host, or conversely to tell the local host how to connect to ports 9 | # published by containers running on the docker host. 10 | # 11 | # The heuristic works for most cases encountered in the wild, including: 12 | # - DOCKER_HOST is unset (assume daemon listening on 127.0.0.1) 13 | # - DOCKER_HOST points to a socket (assume 127.0.0.1) 14 | # - DOCKER_HOST points to a tcp, http or https address 15 | class NetInfo 16 | # Determine IP addresses of the local host's network interfaces. 17 | # 18 | # @return [Array] list of String dotted-quad IPv4 addresses 19 | def self.ipv4_interfaces 20 | Socket.getifaddrs 21 | .map { |i| i.addr.ip_address if i.addr && i.addr.ipv4? }.compact 22 | end 23 | 24 | # Create a new instance of this class. 25 | # @param [String] docker_host a URI pointing to the docker host 26 | # @param [Array] list of String dotted-quad IPv4 addresses of local host 27 | def initialize(docker_host = ENV['DOCKER_HOST'], 28 | my_ips = self.class.ipv4_interfaces) 29 | docker_host ||= 'unix:/var/run/docker.sock' 30 | @docker_url = URI.parse(docker_host) 31 | @my_ips = my_ips 32 | end 33 | 34 | # Examine local host's network interfaces; figure out which one is most 35 | # likely to share a route with the given IP address. If no IP address 36 | # is specified, figure out which IP the Docker daemon is reachable on 37 | # and use that as the target IP. 38 | # 39 | # @param [String] target_ip IPv4 address of target 40 | # 41 | # @return [String] IPv4 address of host machine that _may_ be reachable from 42 | # Docker machine 43 | def host_routable_ip(target_ip = docker_routable_ip) 44 | best_match = '' 45 | best_prefix = 0 46 | 47 | target_cps = target_ip.codepoints 48 | 49 | @my_ips.each do |my_ip| 50 | ip_cps = my_ip.codepoints 51 | prefix = 0 52 | ip_cps.each_with_index do |cp, i| 53 | break unless target_cps[i] == cp 54 | prefix = i 55 | end 56 | 57 | if prefix > best_prefix 58 | best_match = my_ip 59 | best_prefix = prefix 60 | end 61 | end 62 | 63 | best_match 64 | end 65 | 66 | # Figure out the likely IP address of the host pointed to by 67 | # self.docker_url. 68 | # 69 | # @return [String] host-reachable IPv4 address of docker host 70 | def docker_routable_ip 71 | case @docker_url.scheme 72 | when 'tcp', 'http', 'https' 73 | docker_dns = @docker_url.host 74 | docker_port = @docker_url.port || 2376 75 | else 76 | # Cheap trick: for unix, file or other protocols, assume docker ports 77 | # are proxied to localhost in addition to other interfaces 78 | docker_dns = 'localhost' 79 | docker_port = 2376 80 | end 81 | 82 | addr = Addrinfo.getaddrinfo( 83 | docker_dns, docker_port, 84 | Socket::AF_INET, Socket::SOCK_STREAM).first 85 | 86 | addr && addr.ip_address 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/docker/compose/rake_tasks.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'json' 3 | require 'rake/tasklib' 4 | require 'shellwords' 5 | 6 | # In case this file is required directly 7 | require 'docker/compose' 8 | 9 | # Only used here, so only required here 10 | require 'docker/compose/shell_printer' 11 | 12 | module Docker::Compose 13 | class RakeTasks < Rake::TaskLib 14 | # Set the directory in which docker-compose commands will be run. Default 15 | # is the directory in which Rakefile is located. 16 | # 17 | # @return [String] 18 | attr_accessor :dir 19 | 20 | # Set the project name. Default is not to pass a custom name. 21 | # @return [String] 22 | attr_accessor :project_name 23 | 24 | # Set the name of the docker-compose file. Default is`docker-compose.yml`. 25 | # @return [String] 26 | attr_accessor :file 27 | 28 | # Provide a mapping of environment variables that should be set in 29 | # _host_ processes, e.g. when running docker:compose:env or 30 | # docker:compose:host. 31 | # 32 | # The values of the environment variables can refer to names of services 33 | # and ports defined in the docker-compose file, and this gem will substitute 34 | # the actual IP and port that the containers are reachable on. This allows 35 | # commands invoked via "docker:compose:host" to reach services running 36 | # inside containers. 37 | # 38 | # @see Docker::Compose::Mapper for information about the substitution syntax 39 | attr_accessor :host_env 40 | 41 | # Extra environment variables to set before invoking host processes. These 42 | # are set _in addition_ to server_env, but are not substituted in any way 43 | # and must not contain any service information. 44 | # 45 | # Extra host env should be disjoint from host_env; if there is overlap 46 | # between the two, then extra_host_env will "win." 47 | attr_accessor :extra_host_env 48 | 49 | # Services to bring up with `docker-compose up` before running any hosted 50 | # command. This is useful if your `docker-compose.yml` contains a service 51 | # definition for the app you will be hosting; if you host the app, you 52 | # want to specify all of the _other_ services, but not the app itself, since 53 | # that will run on the host. 54 | attr_accessor :host_services 55 | 56 | # Namespace to define the rake tasks under. Defaults to "docker:compose'. 57 | attr_accessor :rake_namespace 58 | 59 | # Construct Rake wrapper tasks for docker-compose. If a block is given, 60 | # yield self to the block before defining any tasks so their behavior 61 | # can be configured by calling #server_env=, #file= and so forth. 62 | def initialize 63 | self.dir = Rake.application.original_dir 64 | self.project_name = nil 65 | self.file = 'docker-compose.yml' 66 | self.host_env = {} 67 | self.extra_host_env = {} 68 | self.rake_namespace = 'docker:compose' 69 | yield self if block_given? 70 | 71 | @shell = Backticks::Runner.new 72 | @session = Docker::Compose::Session.new(@shell, dir: dir, project_name: project_name, file: file) 73 | @net_info = Docker::Compose::NetInfo.new 74 | @shell_printer = Docker::Compose::ShellPrinter.new 75 | 76 | @shell.interactive = true 77 | 78 | define 79 | end 80 | 81 | def define 82 | namespace rake_namespace do 83 | desc 'Print bash exports with IP/ports of running services' 84 | task :env do 85 | @shell.interactive = false # suppress useless 'port' output 86 | 87 | tty = STDOUT.tty? 88 | tlt = Rake.application.top_level_tasks.include?(task_name('env')) 89 | 90 | # user invoked this task directly; print some helpful tips on 91 | # how we intend it to be used. 92 | print_usage if tty && tlt 93 | 94 | export_env(print: tlt) 95 | 96 | @shell.interactive = true 97 | end 98 | 99 | desc 'Run command on host, linked to services in containers' 100 | task :host, [:command] => [task_name('env')] do |_task, args| 101 | if host_services 102 | @session.up(*host_services, detached: true) 103 | else 104 | @session.up(detached: true) 105 | end 106 | 107 | exec(args[:command]) 108 | end 109 | end 110 | end 111 | private :define 112 | 113 | # Substitute and set environment variables that point to network ports 114 | # published by docker-compose services. Optionally also print bash export 115 | # statements so this information can be made available to a user's shell. 116 | def export_env(print:) 117 | Docker::Compose::Mapper.map(host_env, 118 | session: @session, 119 | net_info: @net_info) do |k, v| 120 | ENV[k] = serialize_for_env(v) 121 | print_env(k, ENV[k]) if print 122 | end 123 | 124 | extra_host_env.each do |k, v| 125 | ENV[k] = serialize_for_env(v) 126 | print_env(k, ENV[k]) if print 127 | end 128 | end 129 | private :export_env 130 | 131 | # Transform a Ruby value into a String that can be stored in the 132 | # environment. This accepts nil, String, or Array and returns nil, String 133 | # or JSON-serialized Array. 134 | def serialize_for_env(v) 135 | case v 136 | when String 137 | v 138 | when NilClass 139 | nil 140 | when Array 141 | JSON.dump(v) 142 | else 143 | fail ArgumentError, "Can't represent a #{v.class} in the environment" 144 | end 145 | end 146 | private :serialize_for_env 147 | 148 | # Print an export or unset statement suitable for user's shell 149 | def print_env(k, v) 150 | if v 151 | puts @shell_printer.export(k, v) 152 | else 153 | puts @shell_printer.unset(k) 154 | end 155 | end 156 | private :print_env 157 | 158 | def print_usage 159 | command = "rake #{task_name('env')}" 160 | command = 'bundle exec ' + command if defined?(Bundler) 161 | puts @shell_printer.comment('To export these variables to your shell, run:') 162 | puts @shell_printer.comment(@shell_printer.eval_output(command)) 163 | end 164 | private :print_usage 165 | 166 | def task_name(task) 167 | [rake_namespace, task].join(':') 168 | end 169 | private :task_name 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /lib/docker/compose/mapper.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module Docker::Compose 3 | # Uses a Session to discover information about services' IP addresses and 4 | # ports as reachable from localhost, then maps URLs and other common network 5 | # address formats so they point to the right host and port. 6 | # 7 | # **NOTE**: this class uses some heuristics to deal with cases where the 8 | # Docker client is talking to a remote server because the `DOCKER_HOST` 9 | # environment variable is set. In those cases, Mapper tries to determine 10 | # the IP address of the exposed services as reachable from localhost; 11 | # it generally makes a correct guess, but in certain super-complex networking 12 | # scenarios it may guess wrong. Please open a GitHub issue if you find 13 | # a situation where Mapper provides a wrong answer. 14 | class Mapper 15 | # Pattern that matches an "elided" host or port that should be omitted from 16 | # output, but is needed to identify a specific container and port. 17 | ELIDED = /^\[.+\]$/.freeze 18 | 19 | # Regexp that can be used with gsub to strip elision marks 20 | REMOVE_ELIDED = /[\[\]]/.freeze 21 | 22 | BadSubstitution = Class.new(StandardError) 23 | NoService = Class.new(RuntimeError) 24 | 25 | # Instantiate a mapper; map some environment variables; yield to caller for 26 | # additional processing. 27 | # 28 | # @param [Hash] env a set of keys/values whose values will be mapped 29 | # @param [Session] session 30 | # @param [NetInfo] net_info 31 | # @yield yields with each substituted (key, value) pair 32 | def self.map(env, session:Session.new, net_info:NetInfo.new) 33 | mapper = new(session, net_info) 34 | env.each_pair do |k, v| 35 | begin 36 | v = mapper.map(v) 37 | yield(k, v) 38 | rescue NoService 39 | yield(k, nil) 40 | end 41 | end 42 | end 43 | 44 | # Create an instance of Mapper 45 | # 46 | # @param [Docker::Compose::Session] session 47 | # @param [NetInfo] net_info 48 | def initialize(session=Session.new, net_info=NetInfo.new) 49 | docker_host = ENV['DOCKER_HOST'] 50 | if docker_host.nil? || docker_host =~ /^(\/|unix|file)/ 51 | # If DOCKER_HOST is blank, or pointing to a local socket, then we 52 | # can trust the address information returned by `docker-compose port`. 53 | override_host = nil 54 | else 55 | # If DOCKER_HOST is present, assume that containers have bound to 56 | # whatever IP we reach it at; don't fall victim to docker-compose's 57 | # dirty lies! 58 | override_host = net_info.docker_routable_ip 59 | end 60 | 61 | @session = session 62 | @override_host = override_host 63 | end 64 | 65 | # Substitute service hostnames and ports that appear in a URL or a 66 | # host:port string. If either component of a host:port string is 67 | # surrounded by square brackets, "elide" that component, removing it 68 | # from the result but using it to find the correct service and port. 69 | # 70 | # @example map MySQL on local docker host with 3306 published to 13847 71 | # map("tcp://db:3306") # => "tcp://127.0.0.1:13847" 72 | # 73 | # @example map just the hostname of MySQL on local docker host 74 | # map("db:[3306]") # => "127.0.0.1" 75 | # 76 | # @example map just the port of MySQL on local docker host 77 | # map("[db]:3306") # => "13847" 78 | # 79 | # @example map an array of database hosts 80 | # map(["[db1]:3306", "[db2]:3306"]) 81 | # 82 | # @param [String,#map] value a URI, host:port pair, or an array of either 83 | # 84 | # @return [String,Array] the mapped value with container-names and ports substituted 85 | # 86 | # @raise [BadSubstitution] if a substitution string can't be parsed 87 | # @raise [NoService] if service is not up or does not publish port 88 | def map(value) 89 | if value.respond_to?(:map) 90 | value.map { |e| map_scalar(e) } 91 | else 92 | map_scalar(value) 93 | end 94 | end 95 | 96 | # Figure out which host port a given service's port has been published to, 97 | # and/or whether that service is running. Cannot distinguish between the 98 | # "service not running" case and the "container port not published" case! 99 | # 100 | # @raise [NoService] if service is not up or does not publish port 101 | # @return [Array] (String, Integer) pair of host address and port number 102 | def host_and_port(service, port) 103 | result = @session.port(service, port.to_s) 104 | if result 105 | result.chomp! 106 | else 107 | raise NoService, 108 | "Service '#{service}' not running, or does not " \ 109 | "publish port '#{port}'" 110 | end 111 | 112 | host, port = result.split(':') 113 | host = @override_host if @override_host 114 | 115 | [host, Integer(port)] 116 | end 117 | 118 | # Map a single string, replacing service names with IPs and container ports 119 | # with the host ports that they have been mapped to. 120 | # @param [String] value 121 | # @return [String] 122 | def map_scalar(value) 123 | uri = begin 124 | URI.parse(value) 125 | rescue 126 | nil 127 | end 128 | pair = value.split(':') 129 | 130 | if uri && uri.scheme && uri.host 131 | # absolute URI with scheme, authority, etc 132 | uri.host, uri.port = host_and_port(uri.host, uri.port) 133 | return uri.to_s 134 | elsif pair.size == 2 135 | # "host:port" pair; three sub-cases... 136 | if pair.first =~ ELIDED 137 | # output only the port 138 | service = pair.first.gsub(REMOVE_ELIDED, '') 139 | _, port = host_and_port(service, pair.last) 140 | return port.to_s 141 | elsif pair.last =~ ELIDED 142 | # output only the hostname; resolve the port anyway to ensure that 143 | # the service is running. 144 | service = pair.first 145 | port = pair.last.gsub(REMOVE_ELIDED, '') 146 | host, = host_and_port(service, port) 147 | return host 148 | else 149 | # output port:hostname pair 150 | host, port = host_and_port(pair.first, pair.last) 151 | return "#{host}:#{port}" 152 | end 153 | else 154 | fail BadSubstitution, "Can't understand '#{value}'" 155 | end 156 | end 157 | private :map_scalar 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/xeger/docker-compose.svg)](https://travis-ci.org/xeger/docker-compose) [![Coverage Status](https://coveralls.io/repos/github/xeger/docker-compose/badge.svg?branch=coveralls)](https://coveralls.io/github/xeger/docker-compose?branch=coveralls) [![Docs](https://img.shields.io/badge/docs-rubydoc-blue.svg)](http://www.rubydoc.info/gems/docker-compose) 2 | 3 | # Docker::Compose 4 | 5 | This is a Ruby OOP wrapper for the [docker-compose](https://github.com/docker/compose) 6 | container orchestration tool from Docker Inc. 7 | 8 | In addition to wrapping the CLI, this gem provides an environment-variable mapping 9 | feature that allows you to export environment variables into your _host_ that point 10 | to network services exposed by containers. This allows you to run an application on 11 | your host for quicker and easier development, but run all of its dependencies -- 12 | database, cache, adjacent services -- in containers. The dependencies can even run 13 | on another machine, e.g. a cloud instance or a container cluster, provided your 14 | development machine has TCP connectivity to every port exposed by a container. 15 | 16 | Throughout this documentation we will refer to this gem as `Docker::Compose` 17 | as opposed to the `docker-compose` tool that this gem wraps. 18 | 19 | ## Installation 20 | 21 | Add this line to your application's Gemfile: 22 | 23 | ```ruby 24 | gem 'docker-compose' 25 | ``` 26 | 27 | And then execute: 28 | 29 | $ bundle 30 | 31 | Or install it yourself as: 32 | 33 | $ gem install docker-compose 34 | 35 | ## Usage 36 | 37 | ### Invoking from Ruby code 38 | 39 | ```ruby 40 | require 'docker/compose' 41 | 42 | # Create a new session in Dir.pwd using the file "docker-compose.yml". 43 | # For fine-grained control over options, see Docker::Compose::Session#new 44 | compose = Docker::Compose.new 45 | 46 | compose.version 47 | 48 | compose.up(detached:true) 49 | 50 | exited = compose.ps.where { |c| !c.up? } 51 | puts "We have some exited containers: " + exited.join(', ') 52 | 53 | sum = compose.ps.inject(0) { |a,c| a + c.size } 54 | puts format("Composition is using %.1f MiB disk space", sum/1024.0**2) 55 | ``` 56 | 57 | ### Invoking from Rake 58 | 59 | Open your Rakefile and add the Docker::Compose tasks. 60 | 61 | ```ruby 62 | require 'docker/compose/rake_tasks' 63 | 64 | Docker::Compose::RakeTasks.new do |tasks| 65 | # customize by calling setter methods of tasks; 66 | # see the class documentation for details 67 | end 68 | 69 | ``` 70 | 71 | Notice that `rake -T` now has a few additional tasks for invoking gem 72 | functionality. You can `docker:compose:env` to print shell exports for 73 | host-to-container environment mapping, or you can `docker:compose:host[foo]`. 74 | 75 | ### Hosting a Command 76 | 77 | To run a process on your host and allow it to talk to containers, use 78 | the `docker:compose:host` task. For example, I could enter a shell 79 | with `rake docker:compose:host[bash]`. 80 | 81 | Before "hosting" your command, the Rake task exports some environment 82 | variables that your command can use to discover services running in 83 | containers. Your Rakefile specifies which variables your app needs 84 | (the `host_env` option) and which container information each variable should 85 | map to. 86 | 87 | By hosting commands, you benefit from easier debugging and code editing of 88 | the app you're working on, but still get to rely on containers to provide 89 | the companion services your app requires to run. 90 | 91 | ### Mapping container IPs and ports 92 | 93 | As a trivial example, let's say that your `docker-compose.yml` contains one 94 | service, the database that your app needs in order to run. 95 | 96 | ```yaml 97 | db: 98 | image: mysql:latest 99 | environment: 100 | MYSQL_DATABASE: myapp_development 101 | MYSQL_ROOT_PASSWORD: opensesame 102 | ports: 103 | - "3306" 104 | ``` 105 | 106 | Your app needs two inputs, `DATABASE_HOST` and `DATABASE_PORT`. You can specify 107 | this with the host_env option of the Rake task: 108 | 109 | ```ruby 110 | Docker::Compose::RakeTasks.new do |tasks| 111 | tasks.host_env = { 112 | 'DATABASE_HOST' => 'db:[3306]', 113 | 'DATABASE_PORT' => '[db]:3306', 114 | } 115 | end 116 | ``` 117 | 118 | Now, I can run my services, ask Docker::Compose to map the environment values 119 | to the actual IP and port that `db` has been published to, and run my app: 120 | 121 | ```bash 122 | # First, bring up the containers we will be interested in 123 | user@machine$ docker-compose up -d 124 | 125 | # The rake task prints bash code resembling the following: 126 | # export DATABASE_HOST='127.0.0.1' 127 | # export DATABASE_PORT='34387' 128 | # We eval it, which makes the variables available to our shell and to all 129 | # subprocesses. 130 | user@machine$ eval "$(bundle exec rake docker:compose:env)" 131 | 132 | user@machine$ bundle exec rackup 133 | ``` 134 | 135 | The `host_env` option also handles substitution of URLs, and arrays of values 136 | (which are serialized back to the environment as JSON) 137 | For example: 138 | 139 | ```ruby 140 | tasks.host_env = { 141 | 'DATABASE_URL' => 'mysql://db:3306/myapp_development', 142 | 'MIXED_FRUIT' => ['db:[3306]', '[db]:3306'] 143 | } 144 | ``` 145 | 146 | This would result in the following exports: 147 | 148 | ```bash 149 | export DATABASE_URL='mysql://127.0.0.1:34387/myapp_development' 150 | export MIXED_FRUIT='["127.0.0.1", "34387"]' 151 | ``` 152 | 153 | To learn more about mapping, read the class documentation for 154 | `Docker::Compose::Mapper`. 155 | 156 | ## Development 157 | 158 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 159 | 160 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, [create a GitHub release](https://github.com/xeger/docker-compose/releases/new) and the `.gem` file will be automatically uploaded to RubyGems and GitHub Packages. 161 | 162 | The automated publication workflow renders `rake release` obsolete, and also makes the committed contents of `version.rb` irrelevant -- the version file is updated on the fly during the publish workflow. As a convention, we leave the committed file set to `X.99.0-dev` to signify a development copy of the gem's source code but preserve major-version compatibility. This facilitates testing of prerelease versions of this gem in other local codebases using the Gemfile `path:` option. 163 | 164 | ## Contributing 165 | 166 | Bug reports and pull requests are welcome on GitHub at https://github.com/xeger/docker-compose. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](contributor-covenant.org) code of conduct. 167 | 168 | ## License 169 | 170 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 171 | -------------------------------------------------------------------------------- /spec/docker/compose/session_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Docker::Compose::Session do 4 | let(:shell) { double('shell', interactive: false, 'interactive=': true, 'chdir=': true) } 5 | subject(:session) { described_class.new(shell) } 6 | 7 | let(:exitstatus) { 0 } 8 | let(:status) { double('exit status', to_s: "pid 12345 exit #{exitstatus}", to_i: exitstatus) } 9 | let(:output) { '' } 10 | let(:command) do 11 | double('command', 12 | status: status, 13 | captured_output: output, 14 | captured_error: '') 15 | end 16 | 17 | before do 18 | allow(status).to receive(:success?).and_return(exitstatus == 0) 19 | allow(shell).to receive(:run).and_return(command) 20 | allow(command).to receive(:join).and_return(command) 21 | end 22 | 23 | describe '.new' do 24 | it 'allows project_name override' do 25 | s1 = described_class.new(shell, project_name: 'test_name') 26 | expect(shell).to receive(:run).with('docker-compose', { project_name: 'test_name' }, anything, anything, anything) 27 | s1.up 28 | end 29 | 30 | it 'allows file override' do 31 | s1 = described_class.new(shell, file: 'foo.yml') 32 | expect(shell).to receive(:run).with('docker-compose', { file: 'foo.yml' }, anything, anything, anything) 33 | s1.up 34 | end 35 | end 36 | 37 | describe '#build' do 38 | it 'creates images' do 39 | expect(shell).to receive(:run).with('docker-compose', 'build', {}, %w[alice bob]).once 40 | session.build('alice', 'bob') 41 | expect(shell).to receive(:run).with('docker-compose', 'build', { force_rm: true, no_cache: true, pull: true }, []).once 42 | session.build(force_rm: true, no_cache: true, pull: true) 43 | end 44 | end 45 | 46 | describe '#ps' do 47 | # hashes is overridden in nested contexts. 48 | let(:hashes) { ['corned_beef'] } 49 | 50 | # output is used by command (defined in top context). 51 | let(:output) { hashes.join("\n") } 52 | 53 | # Mock some additional calls to run! that the ps method makes in order 54 | # to get info about each container 55 | before do 56 | hashes.each do |h| 57 | cmd = double('command', 58 | status: status, 59 | captured_output: "(#{h}) (xeger/#{h}:latest) (1.0MB (virtual 7.3MB)) (Up 1 second) (#{h}) () ()", 60 | captured_error: '') 61 | allow(cmd).to receive(:join).and_return(cmd) 62 | expect(shell).to receive(:run).with('docker', 'ps', hash_including(f: "id=#{h}", no_trunc: true)).and_return(cmd) 63 | allow(shell).to receive(:interactive=) 64 | end 65 | end 66 | 67 | it 'reports accurate size' do 68 | cont = session.ps 69 | expect(cont).not_to be_empty 70 | expect(cont[0].size).to eq(1_048_576) 71 | end 72 | 73 | context 'given no filter' do 74 | let(:hashes) { %w[corned_beef sweet_potato afghan_black] } 75 | 76 | it 'lists containers' do 77 | session.ps 78 | end 79 | end 80 | 81 | context 'given a filter' do 82 | let(:hashes) { %w[sweet_potato afghan_black] } 83 | 84 | it 'lists containers' do 85 | expect(shell).to receive(:run).with('docker-compose', 'ps', hash_including, %w[service1 service2]) 86 | expect(shell).not_to receive(:run).with('docker', 'ps', hash_including(f: 'id=corned_beef')) 87 | cont = session.ps('service1', 'service2') 88 | expect(cont.size).to eq(2) 89 | end 90 | end 91 | end 92 | 93 | describe '#up' do 94 | it 'runs containers' do 95 | expect(shell).to receive(:run).with('docker-compose', 'up', {}, []) 96 | expect(shell).to receive(:run).with('docker-compose', 'up', hash_including(d: true, timeout: 3), []) 97 | expect(shell).to receive(:run).with('docker-compose', 'up', hash_including(no_start: true), []) 98 | expect(shell).to receive(:run).with('docker-compose', 'up', hash_including(exit_code_from: 'foo'), []) 99 | expect(shell).to receive(:run).with('docker-compose', 'up', hash_including(abort_on_container_exit: true), []) 100 | session.up 101 | session.up detached: true, timeout: 3 102 | session.up no_start: true 103 | session.up exit_code_from: 'foo' 104 | session.up abort_on_container_exit: true 105 | end 106 | end 107 | 108 | describe '#down' do 109 | it 'brings down containers' do 110 | expect(shell).to receive(:run).with('docker-compose', 'down', {}) 111 | expect(shell).to receive(:run).with('docker-compose', 'down', hash_including(v: true)) 112 | session.down 113 | session.down remove_volumes: true 114 | end 115 | end 116 | 117 | describe '#run' do 118 | it 'runs containers' do 119 | expect(shell).to receive(:run).with('docker-compose', 'run', {}, 'service1', []) 120 | expect(shell).to receive(:run).with('docker-compose', 'run', hash_including(d: true, T: true), 'service1', %w[command command_args]) 121 | expect(shell).to receive(:run).with('docker-compose', 'run', hash_including(u: 'user_id:group_id'), 'service1', []) 122 | session.run('service1') 123 | session.run('service1', 'command', 'command_args', no_tty: true, detached: true) 124 | session.run('service1', user: 'user_id:group_id') 125 | end 126 | 127 | it 'runs containers with env vars' do 128 | expect(shell).to receive(:run).with('docker-compose', 'run', {}, { e: 'VAR1=val1' }, 'service1', []) 129 | session.run('service1', env: ['VAR1=val1']) 130 | expect(shell).to receive(:run).with('docker-compose', 'run', {}, { e: 'VAR1=val1' }, { e: 'VAR2=val2' }, 'service1', []) 131 | session.run('service1', env: ['VAR1=val1', 'VAR2=val2']) 132 | end 133 | 134 | it 'runs containers with mounted volumes' do 135 | expect(shell).to receive(:run).with('docker-compose', 'run', {}, { v: '/host1:/container1' }, 'service1', []) 136 | session.run('service1', volumes: ['/host1:/container1']) 137 | expect(shell).to receive(:run).with('docker-compose', 'run', {}, { v: '/host1:/container1' }, { v: '/host2:/container2' }, 'service1', []) 138 | session.run('service1', volumes: ['/host1:/container1', '/host2:/container2']) 139 | end 140 | 141 | it 'runs containers with service ports' do 142 | expect(shell).to receive(:run).with('docker-compose', 'run', { service_ports: true }, 'service1', %w[command command_args]) 143 | session.run('service1', 'command', 'command_args', service_ports: true) 144 | end 145 | end 146 | 147 | describe '#scale' do 148 | it 'scales containers' do 149 | expect(shell).to receive(:run).with('docker-compose', 'scale', {}, 'service1=2') 150 | expect(shell).to receive(:run).with('docker-compose', 'scale', {}, 'service1=3', 'service2=4') 151 | expect(shell).to receive(:run).with('docker-compose', 'scale', { timeout: 3 }, 'service1=1') 152 | session.scale({ service1: 2 }) 153 | session.scale({ service1: 3, service2: 4 }) 154 | session.scale({ service1: 1 }, timeout: 3) 155 | end 156 | end 157 | 158 | describe '#rm' do 159 | it 'removes containers' do 160 | expect(shell).to receive(:run).with('docker-compose', 'rm', {}, []) 161 | expect(shell).to receive(:run).with('docker-compose', 'rm', {}, ['joebob']) 162 | expect(shell).to receive(:run).with('docker-compose', 'rm', hash_including(f: true, v: true), []) 163 | session.rm 164 | session.rm 'joebob' 165 | session.rm force: true, volumes: true 166 | end 167 | end 168 | 169 | describe '#port' do 170 | context 'given color output' do 171 | let(:output) { "\033[37m0.0.0.0:32769\n" } 172 | it 'maps ports' do 173 | expect(session.port('svc1', 8080)).to eq('0.0.0.0:32769') 174 | end 175 | end 176 | 177 | context 'given a running service' do 178 | let(:output) { "0.0.0.0:32769\n" } 179 | it 'maps ports' do 180 | expect(session.port('svc1', 8080)).to eq('0.0.0.0:32769') 181 | end 182 | end 183 | 184 | context 'given a stopped service' do 185 | let(:output) { "\n" } 186 | it 'returns nil' do 187 | expect(session.port('svc1', 8080)).to eq(nil) 188 | end 189 | end 190 | end 191 | 192 | describe '#run!' do 193 | it 'omits "--file" when possible' do 194 | fancypants = described_class.new(shell, file: 'docker-compose.yml') 195 | expect(shell).to receive(:run).with('docker-compose', 'foo') 196 | fancypants.instance_eval { run!('foo') } 197 | end 198 | 199 | it 'handles project_name overrides' do 200 | fancypants = described_class.new(shell, project_name: 'test_name') 201 | expect(shell).to receive(:run).with('docker-compose', { project_name: 'test_name' }, 'foo') 202 | fancypants.instance_eval { run!('foo') } 203 | end 204 | 205 | it 'handles file overrides' do 206 | fancypants = described_class.new(shell, file: 'docker-decompose.yml') 207 | expect(shell).to receive(:run).with('docker-compose', { file: 'docker-decompose.yml' }, 'foo') 208 | fancypants.instance_eval { run!('foo') } 209 | end 210 | 211 | it 'handles multiple files' do 212 | fancypants = described_class.new(shell, file: ['orange.yml', 'apple.yml']) 213 | expect(shell).to receive(:run).with('docker-compose', { file: 'orange.yml' }, { file: 'apple.yml' }, 'foo') 214 | fancypants.instance_eval { run!('foo') } 215 | end 216 | 217 | it 'handles weird input' do 218 | fancypants = described_class.new(shell, file: 42) 219 | expect(shell).to receive(:run).with('docker-compose', { file: '42' }, 'foo') 220 | fancypants.instance_eval { run!('foo') } 221 | 222 | fancypants = described_class.new(shell, file: Pathname.new('/tmp/moo.yml')) 223 | expect(shell).to receive(:run).with('docker-compose', { file: '/tmp/moo.yml' }, 'foo') 224 | fancypants.instance_eval { run!('foo') } 225 | end 226 | 227 | it 'stores the last executed runner command' do 228 | fancypants = described_class.new(shell) 229 | expect(fancypants.last_command).to be_nil 230 | fancypants.instance_eval { run!('foo') } 231 | expect(fancypants.last_command).to eq(command) 232 | end 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /lib/docker/compose/session.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'backticks' 3 | require 'yaml' 4 | 5 | module Docker::Compose 6 | # A Ruby OOP interface to a docker-compose session. A session is bound to 7 | # a particular directory and docker-compose file (which are set at initialize 8 | # time) and invokes whichever docker-compose command is resident in $PATH. 9 | # 10 | # Run docker-compose commands by calling instance methods of this class and 11 | # passing kwargs that are equivalent to the CLI options you would pass to 12 | # the command-line tool. 13 | # 14 | # Note that the Ruby command methods usually expose a _subset_ of the options 15 | # allowed by the docker-compose CLI, and that options are sometimes renamed 16 | # for clarity, e.g. the "-d" flag always becomes the "detached:" kwarg. 17 | class Session 18 | # A Regex that matches all ANSI escape sequences. 19 | ANSI = /\033\[([0-9];?)+[a-z]/ 20 | 21 | # Working directory (determines compose project name); default is Dir.pwd 22 | attr_reader :dir 23 | 24 | # Project name; default is not to pass a custom name 25 | attr_reader :project_name 26 | 27 | # Project file; default is 'docker-compose.yml' 28 | attr_reader :file 29 | 30 | # Reference to the last executed command. 31 | attr_reader :last_command 32 | 33 | def initialize(shell = Backticks::Runner.new(buffered: [:stderr], interactive: true), 34 | dir: Dir.pwd, project_name: nil, file: 'docker-compose.yml') 35 | @shell = shell 36 | @project_name = project_name 37 | @dir = dir 38 | @file = file 39 | @last_command = nil 40 | end 41 | 42 | # Validate docker-compose file and return it as Hash 43 | # @return [Hash] the docker-compose config file 44 | # @raise [Error] if command fails 45 | def config(*args) 46 | config = strip_ansi(run!('config', *args)) 47 | YAML.load(config) 48 | end 49 | 50 | # Monitor the logs of one or more containers. 51 | # @param [Array] services list of String service names to show logs for 52 | # @return [true] always returns true 53 | # @raise [Error] if command fails 54 | def logs(*services) 55 | run!('logs', services) 56 | true 57 | end 58 | 59 | def ps(*services) 60 | inter = @shell.interactive 61 | @shell.interactive = false 62 | 63 | lines = strip_ansi(run!('ps', {q: true}, services)).split(/[\r\n]+/) 64 | containers = Collection.new 65 | 66 | lines.each do |id| 67 | containers << docker_ps(strip_ansi(id)) 68 | end 69 | 70 | containers 71 | ensure 72 | @shell.interactive = inter 73 | end 74 | 75 | # Idempotently up the given services in the project. 76 | # @param [Array] services list of String service names to run 77 | # @param [Boolean] detached if true, to start services in the background; 78 | # otherwise, monitor logs in the foreground and shutdown on Ctrl+C 79 | # @param [Integer] timeout how long to wait for each service to start 80 | # @param [Boolean] build if true, build images before starting containers 81 | # @param [Boolean] no_build if true, don't build images, even if they're 82 | # missing 83 | # @param [Boolean] no_deps if true, just run specified services without 84 | # running the services that they depend on 85 | # @return [true] always returns true 86 | # @raise [Error] if command fails 87 | def up(*services, 88 | abort_on_container_exit: false, 89 | detached: false, timeout: 10, build: false, 90 | exit_code_from: nil, 91 | no_build: false, no_deps: false, no_start: false) 92 | o = opts( 93 | abort_on_container_exit: [abort_on_container_exit, false], 94 | d: [detached, false], 95 | timeout: [timeout, 10], 96 | build: [build, false], 97 | exit_code_from: [exit_code_from, nil], 98 | no_build: [no_build, false], 99 | no_deps: [no_deps, false], 100 | no_start: [no_start, false] 101 | ) 102 | run!('up', o, services) 103 | true 104 | end 105 | 106 | # Idempotently scales the number of containers for given services in the project. 107 | # @param [Hash] container_count per service, e.g. {web: 2, worker: 3} 108 | # @param [Integer] timeout how long to wait for each service to scale 109 | def scale(container_count, timeout: 10) 110 | args = container_count.map {|service, count| "#{service}=#{count}"} 111 | o = opts(timeout: [timeout, 10]) 112 | run!('scale', o, *args) 113 | end 114 | 115 | # Take the stack down 116 | def down(remove_volumes: false) 117 | run!('down', opts(v: [!!remove_volumes, false])) 118 | end 119 | 120 | # Pull images of services 121 | # @param [Array] services list of String service names to pull 122 | def pull(*services) 123 | run!('pull', *services) 124 | end 125 | 126 | def rm(*services, force: false, volumes: false) 127 | o = opts(f: [force, false], v: [volumes, false]) 128 | run!('rm', o, services) 129 | end 130 | 131 | # Idempotently run an arbitrary command with a service container. 132 | # @param [String] service name to run 133 | # @param [String] cmd command statement to run 134 | # @param [Boolean] detached if true, to start services in the background; 135 | # otherwise, monitor logs in the foreground and shutdown on Ctrl+C 136 | # @param [Boolean] no_deps if true, just run specified services without 137 | # running the services that they depend on 138 | # @param [Array] env a list of environment variables (see: -e flag) 139 | # @param [Array] volumes a list of volumes to bind mount (see: -v flag) 140 | # @param [Boolean] rm remove the container when done 141 | # @param [Boolean] no_tty disable pseudo-tty allocation (see: -T flag) 142 | # @param [String] user run as specified username or uid (see: -u flag) 143 | # @raise [Error] if command fails 144 | def run(service, *cmd, detached: false, no_deps: false, volumes: [], env: [], rm: false, no_tty: false, user: nil, service_ports: false) 145 | o = opts(d: [detached, false], no_deps: [no_deps, false], rm: [rm, false], T: [no_tty, false], u: [user, nil], service_ports: [service_ports, false]) 146 | env_params = env.map { |v| { e: v } } 147 | volume_params = volumes.map { |v| { v: v } } 148 | run!('run', o, *env_params, *volume_params, service, cmd) 149 | end 150 | 151 | def restart(*services, timeout:10) 152 | o = opts(timeout: [timeout, 10]) 153 | run!('restart', o, *services) 154 | end 155 | 156 | # Pause running services. 157 | # @param [Array] services list of String service names to run 158 | def pause(*services) 159 | run!('pause', *services) 160 | end 161 | 162 | # Unpause running services. 163 | # @param [Array] services list of String service names to run 164 | def unpause(*services) 165 | run!('unpause', *services) 166 | end 167 | 168 | # Stop running services. 169 | # @param [Array] services list of String service names to stop 170 | # @param [Integer] timeout how long to wait for each service to stop 171 | # @raise [Error] if command fails 172 | def stop(*services, timeout: 10) 173 | o = opts(timeout: [timeout, 10]) 174 | run!('stop', o, services) 175 | end 176 | 177 | # Forcibly stop running services. 178 | # @param [Array] services list of String service names to stop 179 | # @param [String] name of murderous signal to use, default is 'KILL' 180 | # @see Signal.list for a list of acceptable signal names 181 | def kill(*services, signal: 'KILL') 182 | o = opts(signal: [signal, 'KILL']) 183 | run!('kill', o, services) 184 | end 185 | 186 | # Figure out which interface(s) and port a given service port has been published to. 187 | # 188 | # **NOTE**: if Docker Compose is communicating with a remote Docker host, this method 189 | # returns IP addresses from the point of view of *that* host and its interfaces. If 190 | # you need to know the address as reachable from localhost, you probably want to use 191 | # `Mapper`. 192 | # 193 | # @see Docker::Compose::Mapper 194 | # 195 | # @param [String] service name of service from docker-compose.yml 196 | # @param [Integer] port number of port 197 | # @param [String] protocol 'tcp' or 'udp' 198 | # @param [Integer] index of container (if multiple instances running) 199 | # @return [String,nil] an ip:port pair such as "0.0.0.0:32176" or nil if the service is not running 200 | # @raise [Error] if command fails 201 | def port(service, port, protocol: 'tcp', index: 1) 202 | inter = @shell.interactive 203 | @shell.interactive = false 204 | 205 | o = opts(protocol: [protocol, 'tcp'], index: [index, 1]) 206 | s = strip_ansi(run!('port', o, service, port).strip) 207 | (!s.empty? && s) || nil 208 | rescue Error => e 209 | # Deal with docker-compose v1.11+ 210 | if e.detail =~ /No container found/i 211 | nil 212 | else 213 | raise 214 | end 215 | ensure 216 | @shell.interactive = inter 217 | end 218 | 219 | # Determine the installed version of docker-compose. 220 | # @param [Boolean] short whether to return terse version information 221 | # @return [String, Hash] if short==true, returns a version string; 222 | # otherwise, returns a Hash of component-name strings to version strings 223 | # @raise [Error] if command fails 224 | def version(short: false) 225 | o = opts(short: [short, false]) 226 | result = run!('version', o, file: false, dir: false) 227 | 228 | if short 229 | result.strip 230 | else 231 | lines = result.split(/[\r\n]+/) 232 | lines.inject({}) do |h, line| 233 | kv = line.split(/: +/, 2) 234 | h[kv.first] = kv.last 235 | h 236 | end 237 | end 238 | end 239 | 240 | def build(*services, force_rm: false, no_cache: false, pull: false) 241 | o = opts(force_rm: [force_rm, false], 242 | no_cache: [no_cache, false], 243 | pull: [pull, false]) 244 | result = run!('build', o, services) 245 | end 246 | 247 | # Run a docker-compose command without validating that the CLI parameters 248 | # make sense. Prepend project and file options if suitable. 249 | # 250 | # @see Docker::Compose::Shell#command 251 | # 252 | # @param [Array] args command-line arguments in the format accepted by 253 | # Backticks::Runner#command 254 | # @return [String] output of the command 255 | # @raise [Error] if command fails 256 | def run!(*args) 257 | project_name_args = if @project_name 258 | [{ project_name: @project_name }] 259 | else 260 | [] 261 | end 262 | file_args = case @file 263 | when 'docker-compose.yml' 264 | [] 265 | when Array 266 | # backticks sugar can't handle array values; build a list of hashes 267 | # IMPORTANT: preserve the order of the files so overrides work correctly 268 | file_args = @file.map { |filepath| { :file => filepath } } 269 | else 270 | # a single String (or Pathname, etc); use normal sugar to add it 271 | [{ file: @file.to_s }] 272 | end 273 | 274 | @shell.chdir = dir 275 | @last_command = @shell.run('docker-compose', *project_name_args, *file_args, *args).join 276 | status = @last_command.status 277 | out = @last_command.captured_output 278 | err = @last_command.captured_error 279 | status.success? || fail(Error.new(args.first, status, out+err)) 280 | out 281 | end 282 | 283 | private 284 | 285 | def docker_ps(id) 286 | cmd = @shell.run('docker', 'ps', a: true, f: "id=#{id}", no_trunc: true, format: Container::PS_FMT).join 287 | status, out, err = cmd.status, cmd.captured_output, cmd.captured_error 288 | raise Error.new('docker ps', status, out+err) unless status.success? 289 | lines = out.split(/[\r\n]+/) 290 | return nil if lines.empty? 291 | l = strip_ansi(lines.shift) 292 | m = parse(l) 293 | raise Error.new('docker ps', status, "Cannot parse output: '#{l}'") unless m 294 | raise Error.new('docker ps', status, "Cannot parse output: '#{l}'") unless m.size == 7 295 | return Container.new(*m) 296 | end 297 | 298 | # strip default-valued options. the value of each kw should be a pair: 299 | # [0] is present value 300 | # [1] is default value 301 | def opts(**kws) 302 | res = {} 303 | kws.each_pair do |kw, v| 304 | res[kw] = v[0] unless v[0] == v[1] 305 | end 306 | res 307 | end 308 | 309 | # strip all ANSI escape sequences from str 310 | def strip_ansi(str) 311 | str.gsub(ANSI, '') 312 | end 313 | 314 | # Parse a string that consists of a sequence of values enclosed within parentheses. 315 | # Ignore any bytes that are outside of parentheses. Values may include nested parentheses. 316 | # 317 | # @param [String] str e.g. "(foo) ((bar)) ... (baz)" 318 | # @return [Array] e.g. ["foo", "bar", "baz"] 319 | def parse(str) 320 | fields = [] 321 | nest = 0 322 | field = '' 323 | str.each_char do |ch| 324 | got = false 325 | if nest == 0 326 | if ch == '(' 327 | nest += 1 328 | end 329 | else 330 | if ch == '(' 331 | nest += 1 332 | field << ch 333 | elsif ch == ')' 334 | nest -= 1 335 | if nest == 0 336 | got = true 337 | else 338 | field << ch 339 | end 340 | else 341 | field << ch 342 | end 343 | end 344 | 345 | if got 346 | fields << field 347 | field = '' 348 | end 349 | end 350 | 351 | fields 352 | end 353 | end 354 | end 355 | --------------------------------------------------------------------------------