├── .rspec ├── Dockerfile ├── .gitignore ├── lib ├── docker-api.rb ├── docker │ ├── version.rb │ ├── messages_stack.rb │ ├── rake_task.rb │ ├── base.rb │ ├── volume.rb │ ├── error.rb │ ├── messages.rb │ ├── network.rb │ ├── event.rb │ ├── exec.rb │ ├── connection.rb │ ├── util.rb │ ├── container.rb │ └── image.rb ├── excon │ └── middlewares │ │ └── hijack.rb └── docker.rb ├── spec ├── fixtures │ ├── build_from_dir │ │ └── Dockerfile │ ├── load.tar │ ├── export.tar │ └── top │ │ └── Dockerfile ├── cov_spec.rb ├── docker │ ├── messages_stack.rb │ ├── volume_spec.rb │ ├── messages_spec.rb │ ├── connection_spec.rb │ ├── network_spec.rb │ ├── event_spec.rb │ ├── exec_spec.rb │ ├── util_spec.rb │ ├── image_spec.rb │ └── container_spec.rb ├── spec_helper.rb └── docker_spec.rb ├── Gemfile ├── .simplecov ├── script ├── install_podman.sh ├── install_docker.sh ├── docker.conf └── docker ├── .travis.yml ├── docker-api.gemspec ├── LICENSE ├── Rakefile ├── TESTING.md ├── .github └── workflows │ └── unit_test.yml └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --order rand 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | ADD Dockerfile /Dockerfile 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.swp 3 | *.gem 4 | Gemfile.lock 5 | .ruby-* 6 | -------------------------------------------------------------------------------- /lib/docker-api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'docker' 4 | -------------------------------------------------------------------------------- /spec/fixtures/build_from_dir/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:stable 2 | ADD . / 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'http://rubygems.org' 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /spec/fixtures/load.tar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upserve/docker-api/HEAD/spec/fixtures/load.tar -------------------------------------------------------------------------------- /spec/fixtures/export.tar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/upserve/docker-api/HEAD/spec/fixtures/export.tar -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SimpleCov.start do 4 | add_group 'Library', 'lib' 5 | add_group 'Specs', 'spec' 6 | end 7 | -------------------------------------------------------------------------------- /lib/docker/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Docker 4 | # The version of the docker-api gem. 5 | VERSION = '2.4.0' 6 | end 7 | -------------------------------------------------------------------------------- /spec/fixtures/top/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:stable 2 | RUN apt-get update 3 | RUN apt-get install -y procps 4 | RUN printf '#! /bin/sh\nwhile true\ndo\ntrue\ndone\n' > /while && chmod +x /while 5 | -------------------------------------------------------------------------------- /script/install_podman.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -ex 3 | 4 | . /etc/os-release 5 | 6 | curl -L https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_${VERSION_ID}/Release.key | sudo apt-key add - 7 | 8 | echo "deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_${VERSION_ID}/ /" > /etc/apt/sources.list.d/podman.list 9 | 10 | apt-get update 11 | 12 | apt-get install -y podman 13 | -------------------------------------------------------------------------------- /spec/cov_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | SingleCov.not_covered! 6 | 7 | describe "Coverage" do 8 | it "has coverage for all tests" do 9 | SingleCov.assert_used 10 | end 11 | 12 | it "has tests for all files" do 13 | SingleCov.assert_tested untested: %w[ 14 | lib/docker/base.rb 15 | lib/docker/error.rb 16 | lib/docker/messages_stack.rb 17 | lib/docker/rake_task.rb 18 | lib/docker/version.rb 19 | lib/docker-api.rb 20 | lib/excon/middlewares/hijack.rb 21 | ] 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: linux 2 | dist: bionic 3 | language: ruby 4 | cache: bundler 5 | rvm: 6 | - 2.7 7 | - 2.6 8 | - 2.5 9 | - 2.4 10 | - 2.3 11 | - 2.2 12 | env: 13 | - DOCKER_VERSION=5:19.03.8~3-0~ubuntu-bionic 14 | - DOCKER_VERSION=5:18.09.9~3-0~ubuntu-bionic 15 | - DOCKER_VERSION=18.06.3~ce~3-0~ubuntu 16 | jobs: 17 | fast_finish: true 18 | before_install: 19 | - docker --version 20 | - gem install bundler -v '~> 1.17.3' 21 | before_script: 22 | - sudo ./script/install_docker.sh ${DOCKER_VERSION} ${DOCKER_CE} 23 | - uname -a 24 | - docker --version 25 | - docker info 26 | -------------------------------------------------------------------------------- /lib/docker/messages_stack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class represents a messages stack 4 | class Docker::MessagesStack 5 | 6 | attr_accessor :messages 7 | 8 | # Initialize stack with optional size 9 | # 10 | # @param size [Integer] 11 | def initialize(size = -1) 12 | @messages = [] 13 | @size = size 14 | end 15 | 16 | # Append messages to stack 17 | # 18 | # @param messages [Docker::Messages] 19 | def append(messages) 20 | return if @size == 0 21 | 22 | messages.all_messages.each do |msg| 23 | @messages << msg 24 | @messages.shift if @size > -1 && @messages.size > @size 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/docker/rake_task.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class allows image-based tasks to be created. 4 | class Docker::ImageTask < Rake::Task 5 | def self.scope_name(_scope, task_name) 6 | task_name 7 | end 8 | 9 | def needed? 10 | !has_repo_tag? 11 | end 12 | 13 | private 14 | 15 | def has_repo_tag? 16 | images.any? { |image| image.info['RepoTags'].include?(repo_tag) } 17 | end 18 | 19 | def images 20 | @images ||= Docker::Image.all(:all => true) 21 | end 22 | 23 | def repo 24 | name.split(':')[0] 25 | end 26 | 27 | def tag 28 | name.split(':')[1] || 'latest' 29 | end 30 | 31 | def repo_tag 32 | "#{repo}:#{tag}" 33 | end 34 | end 35 | 36 | # Monkeypatch Rake to add the `image` task. 37 | module Rake::DSL 38 | def image(*args, &block) 39 | Docker::ImageTask.define_task(*args, &block) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/docker/messages_stack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | SingleCov.covered! 6 | 7 | describe Docker::MessagesStack do 8 | describe '#append' do 9 | context 'without limits' do |variable| 10 | it 'does not limit stack size by default' do 11 | data = ['foo', 'bar'] 12 | msg = Docker::Messages.new(data, [], data) 13 | expect(subject.messages).not_to receive(:shift) 14 | 1000.times { subject.append(msg) } 15 | end 16 | end 17 | 18 | context 'with size limit' do 19 | let(:subject) { described_class.new(100) } 20 | 21 | it 'limits stack to given size' do 22 | data = ['foo', 'bar'] 23 | msg = Docker::Messages.new(data, [], data) 24 | expect(subject.messages).to receive(:shift).exactly(1900).times 25 | 1000.times { subject.append(msg) } 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /docker-api.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # frozen_string_literal: true 3 | 4 | require File.expand_path('../lib/docker/version', __FILE__) 5 | 6 | Gem::Specification.new do |gem| 7 | gem.authors = ['Swipely, Inc.'] 8 | gem.email = 'tomhulihan@swipely.com bright@swipely.com toddlunter@swipely.com' 9 | gem.description = gem.summary = 'A simple REST client for the Docker Remote API' 10 | gem.homepage = 'https://github.com/upserve/docker-api' 11 | gem.license = 'MIT' 12 | gem.files = `git ls-files lib README.md LICENSE`.split($\) 13 | gem.name = 'docker-api' 14 | gem.version = Docker::VERSION 15 | gem.add_dependency 'excon', '>= 0.64.0' 16 | gem.add_dependency 'multi_json' 17 | gem.add_development_dependency 'rake' 18 | gem.add_development_dependency 'rspec', '~> 3.0' 19 | gem.add_development_dependency 'rspec-its' 20 | gem.add_development_dependency 'pry' 21 | gem.add_development_dependency 'single_cov' 22 | gem.add_development_dependency 'webmock' 23 | gem.add_development_dependency 'parallel' 24 | end 25 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | 5 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 6 | 7 | require 'rspec/its' 8 | 9 | require 'single_cov' 10 | 11 | # avoid coverage failure from lower docker versions not running all tests 12 | SingleCov.setup :rspec 13 | 14 | require 'docker' 15 | 16 | ENV['DOCKER_API_USER'] ||= 'debbie_docker' 17 | ENV['DOCKER_API_PASS'] ||= '*************' 18 | ENV['DOCKER_API_EMAIL'] ||= 'debbie_docker@example.com' 19 | 20 | RSpec.shared_context "local paths" do 21 | def project_dir 22 | File.expand_path(File.join(File.dirname(__FILE__), '..')) 23 | end 24 | end 25 | 26 | module SpecHelpers 27 | def skip_without_auth 28 | skip "Disabled because of missing auth" if ENV['DOCKER_API_USER'] == 'debbie_docker' 29 | end 30 | end 31 | 32 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 33 | 34 | RSpec.configure do |config| 35 | config.mock_with :rspec 36 | config.color = true 37 | config.formatter = :documentation 38 | config.tty = true 39 | config.include SpecHelpers 40 | end 41 | -------------------------------------------------------------------------------- /lib/docker/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class is a base class for Docker Container and Image. 4 | # It is implementing accessor methods for the models attributes. 5 | module Docker::Base 6 | include Docker::Error 7 | 8 | attr_accessor :connection, :info 9 | attr_reader :id 10 | 11 | # The private new method accepts a connection and a hash of options that must include an id. 12 | def initialize(connection, hash={}) 13 | unless connection.is_a?(Docker::Connection) 14 | raise ArgumentError, "Expected a Docker::Connection, got: #{connection}." 15 | end 16 | normalize_hash(hash) 17 | @connection, @info, @id = connection, hash, hash['id'] 18 | raise ArgumentError, "Must have id, got: #{hash}" unless @id 19 | end 20 | 21 | # The docker-api will some time return "ID" other times it will return "Id" 22 | # and other times it will return "id". This method normalize it to "id" 23 | # The volumes endpoint returns Name instead of ID, added in the normalize function 24 | def normalize_hash(hash) 25 | hash["id"] ||= hash.delete("ID") || hash.delete("Id") 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Swipely, Inc. 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 | 23 | -------------------------------------------------------------------------------- /script/install_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | declare -a SEMVER 5 | 6 | # argv[0] 7 | DOCKER_VERSION=$1 8 | # argv[1] 9 | DOCKER_CE=$2 10 | 11 | # disable travis default installation 12 | systemctl stop docker.service 13 | apt-get -y --purge remove docker docker-engine docker.io containerd runc 14 | 15 | # install gpg key for docker rpo 16 | curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - 17 | apt-key fingerprint 0EBFCD88 18 | 19 | # enable docker repo 20 | add-apt-repository \ 21 | "deb [arch=amd64] https://download.docker.com/linux/ubuntu \ 22 | $(lsb_release -cs) \ 23 | stable" 24 | apt-get update 25 | apt-cache gencaches 26 | 27 | set +e 28 | # install package 29 | apt-get install docker-ce=${DOCKER_VERSION} 30 | 31 | if [ $? -ne 0 ]; then 32 | echo "Error: Could not install ${DOCKER_VERSION}" 33 | echo "Available docker versions:" 34 | apt-cache madison docker-ce 35 | exit 1 36 | fi 37 | set -e 38 | 39 | systemctl stop docker.service 40 | 41 | echo 'DOCKER_OPTS="-H unix:///var/run/docker.sock --pidfile=/var/run/docker.pid"' > /etc/default/docker 42 | cat /etc/default/docker 43 | 44 | systemctl start docker.service 45 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | 5 | ENV['PATH'] = "/opt/docker/:#{ENV['PATH']}" if ENV['CI'] == 'true' 6 | 7 | require 'docker' 8 | require 'rspec/core/rake_task' 9 | 10 | desc 'Run the full test suite from scratch' 11 | task :default => [:unpack, :rspec] 12 | 13 | RSpec::Core::RakeTask.new do |t| 14 | t.pattern = 'spec/**/*_spec.rb' 15 | end 16 | 17 | desc 'Download the necessary base images' 18 | task :unpack do 19 | %w( swipely/base registry busybox:uclibc tianon/true debian:stable ).each do |image| 20 | system "docker pull #{image}" 21 | end 22 | end 23 | 24 | desc 'Run spec tests with a registry' 25 | task :rspec do 26 | begin 27 | registry = Docker::Container.create( 28 | 'name' => 'registry', 29 | 'Image' => 'registry', 30 | 'Env' => ["GUNICORN_OPTS=[--preload]"], 31 | 'ExposedPorts' => { 32 | '5000/tcp' => {} 33 | }, 34 | 'HostConfig' => { 35 | 'PortBindings' => { '5000/tcp' => [{ 'HostPort' => '5000' }] } 36 | } 37 | ) 38 | registry.start 39 | Rake::Task["spec"].invoke 40 | ensure 41 | registry.kill!.remove unless registry.nil? 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/docker/volume.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # class represents a Docker Volume 4 | class Docker::Volume 5 | include Docker::Base 6 | 7 | # /volumes/volume_name doesnt return anything 8 | def remove(opts = {}, conn = Docker.connection) 9 | conn.delete("/volumes/#{id}") 10 | end 11 | 12 | def normalize_hash(hash) 13 | hash['id'] ||= hash['Name'] 14 | end 15 | 16 | class << self 17 | 18 | # get details for a single volume 19 | def get(name, conn = Docker.connection) 20 | resp = conn.get("/volumes/#{name}") 21 | hash = Docker::Util.parse_json(resp) || {} 22 | new(conn, hash) 23 | end 24 | 25 | # /volumes endpoint returns an array of hashes incapsulated in an Volumes tag 26 | def all(opts = {}, conn = Docker.connection) 27 | resp = conn.get('/volumes') 28 | json = Docker::Util.parse_json(resp) || {} 29 | hashes = json['Volumes'] || [] 30 | hashes.map { |hash| new(conn, hash) } 31 | end 32 | 33 | # creates a volume with an arbitrary name 34 | def create(name, opts = {}, conn = Docker.connection) 35 | opts['Name'] = name 36 | resp = conn.post('/volumes/create', {}, body: MultiJson.dump(opts)) 37 | hash = Docker::Util.parse_json(resp) || {} 38 | new(conn, hash) 39 | end 40 | 41 | def prune(conn = Docker.connection) 42 | conn.post("/volumes/prune") 43 | end 44 | 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/docker/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This module holds the Errors for the gem. 4 | module Docker::Error 5 | 6 | # The default error. It's never actually raised, but can be used to catch all 7 | # gem-specific errors that are thrown as they all subclass from this. 8 | class DockerError < StandardError; end 9 | 10 | # Raised when invalid arguments are passed to a method. 11 | class ArgumentError < DockerError; end 12 | 13 | # Raised when a request returns a 400. 14 | class ClientError < DockerError; end 15 | 16 | # Raised when a request returns a 401. 17 | class UnauthorizedError < DockerError; end 18 | 19 | # Raised when a request returns a 404. 20 | class NotFoundError < DockerError; end 21 | 22 | # Raised when a request returns a 409. 23 | class ConflictError < DockerError; end 24 | 25 | # Raised when a request returns a 500. 26 | class ServerError < DockerError; end 27 | 28 | # Raised when there is an unexpected response code / body. 29 | class UnexpectedResponseError < DockerError; end 30 | 31 | # Raised when there is an incompatible version of Docker. 32 | class VersionError < DockerError; end 33 | 34 | # Raised when a request times out. 35 | class TimeoutError < DockerError; end 36 | 37 | # Raised when login fails. 38 | class AuthenticationError < DockerError; end 39 | 40 | # Raised when an IO action fails. 41 | class IOError < DockerError; end 42 | end 43 | -------------------------------------------------------------------------------- /spec/docker/volume_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | SingleCov.covered! uncovered: 1 6 | 7 | # Volume requests are actually slow enough to occasionally not work 8 | # Use sleep statements to manage that 9 | describe Docker::Volume, :docker_1_9 do 10 | let(:name) { "ArbitraryNameForTheRakeTestVolume" } 11 | 12 | describe '.create' do 13 | let(:volume) { Docker::Volume.create(name) } 14 | 15 | after { volume.remove } 16 | 17 | it 'creates a volume' do 18 | expect(volume.id).to eq(name) 19 | end 20 | end 21 | 22 | describe '.get' do 23 | let(:volume) { Docker::Volume.get(name) } 24 | 25 | before { Docker::Volume.create(name); sleep 1 } 26 | after { volume.remove } 27 | 28 | it 'gets volume details' do 29 | expect(volume.id).to eq(name) 30 | expect(volume.info).to_not be_empty 31 | end 32 | end 33 | 34 | describe '.all' do 35 | after { Docker::Volume.get(name).remove } 36 | 37 | it 'gets a list of volumes' do 38 | expect { Docker::Volume.create(name); sleep 1 }.to change { Docker::Volume.all.length }.by(1) 39 | end 40 | end 41 | 42 | describe '.prune', :docker_17_03 => true do 43 | it 'prune volumes' do 44 | expect { Docker::Volume.prune }.not_to raise_error 45 | end 46 | end 47 | 48 | describe '#remove' do 49 | it 'removes a volume' do 50 | volume = Docker::Volume.create(name) 51 | sleep 1 52 | expect { volume.remove }.to change { Docker::Volume.all.length }.by(-1) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /TESTING.md: -------------------------------------------------------------------------------- 1 | # Prerequisites 2 | To develop on this gem, you must the following installed: 3 | * a sane Ruby 1.9+ environment with `bundler` 4 | ```shell 5 | $ gem install bundler 6 | ``` 7 | * Docker v1.3.1 or greater 8 | 9 | 10 | 11 | # Getting Started 12 | 1. Clone the git repository from Github: 13 | ```shell 14 | $ git clone git@github.com:upserve/docker-api.git 15 | ``` 16 | 2. Install the dependencies using Bundler 17 | ```shell 18 | $ bundle install 19 | ``` 20 | 3. Create a branch for your changes 21 | ```shell 22 | $ git checkout -b my_bug_fix 23 | ``` 24 | 4. Make any changes 25 | 5. Write tests to support those changes. 26 | 6. Run the tests: 27 | * `bundle exec rake` 28 | 7. Assuming the tests pass, open a Pull Request on Github. 29 | 30 | # Using Rakefile Commands 31 | This repository comes with five Rake commands to assist in your testing of the code. 32 | 33 | ## `rake rspec` 34 | This command will run Rspec tests normally on your local system. You must have all the required base images pulled. 35 | 36 | ## `rake unpack` 37 | Pulls down all the required base images for testing. 38 | 39 | ### Setting Up Environment Variables 40 | Certain Rspec tests will require your credentials to the Docker Hub. If you do not have a Docker Hub account, you can sign up for one [here](https://hub.docker.com/account/signup/). To avoid hard-coding credentials into the code the test suite leverages three Environment Variables: `DOCKER_API_USER`, `DOCKER_API_PASS`, and `DOCKER_API_EMAIL`. You will need to configure your work environment (shell profile, IDE, etc) with these values in order to successfully run certain tests. 41 | 42 | ```shell 43 | export DOCKER_API_USER='your_docker_hub_user' 44 | export DOCKER_API_PASS='your_docker_hub_password' 45 | export DOCKER_API_EMAIL='your_docker_hub_email_address' 46 | ``` 47 | -------------------------------------------------------------------------------- /lib/docker/messages.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class represents all the messages either received by chunks from attach 4 | class Docker::Messages 5 | 6 | attr_accessor :buffer, :stdout_messages, :stderr_messages, :all_messages 7 | 8 | def initialize(stdout_messages=[], 9 | stderr_messages=[], 10 | all_messages=[], 11 | buffer="") 12 | @stdout_messages = stdout_messages 13 | @stderr_messages = stderr_messages 14 | @all_messages = all_messages 15 | @buffer = buffer 16 | end 17 | 18 | def add_message(source, message) 19 | case source 20 | when 1 21 | stdout_messages << message 22 | when 2 23 | stderr_messages << message 24 | end 25 | all_messages << message 26 | end 27 | 28 | def get_message(raw_text) 29 | header = raw_text.slice!(0,8) 30 | if header.length < 8 31 | @buffer = header 32 | return 33 | end 34 | type, length = header.unpack("CxxxN") 35 | 36 | message = raw_text.slice!(0,length) 37 | if message.length < length 38 | @buffer = header + message 39 | else 40 | add_message(type, message) 41 | end 42 | end 43 | 44 | def append(messages) 45 | @stdout_messages += messages.stdout_messages 46 | @stderr_messages += messages.stderr_messages 47 | @all_messages += messages.all_messages 48 | messages.clear 49 | 50 | @all_messages 51 | end 52 | 53 | def clear 54 | stdout_messages.clear 55 | stderr_messages.clear 56 | all_messages.clear 57 | end 58 | 59 | # Method to break apart application/vnd.docker.raw-stream headers 60 | def decipher_messages(body) 61 | raw_text = buffer + body.dup 62 | messages = Docker::Messages.new 63 | while !raw_text.empty? 64 | messages.get_message(raw_text) 65 | end 66 | 67 | messages 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/excon/middlewares/hijack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Excon 4 | module Middleware 5 | # Hijack is an Excon middleware which parses response headers and then 6 | # yields the underlying TCP socket for raw TCP communication (used to 7 | # attach to STDIN of containers). 8 | class Hijack < Base 9 | def self.valid_parameter_keys 10 | [:hijack_block].freeze 11 | end 12 | 13 | def build_response(status, socket) 14 | response = { 15 | :body => '', 16 | :headers => Excon::Headers.new, 17 | :status => status, 18 | :remote_ip => socket.respond_to?(:remote_ip) && 19 | socket.remote_ip, 20 | } 21 | if socket.data[:scheme] =~ /^(https?|tcp)$/ 22 | response.merge({ 23 | :local_port => socket.respond_to?(:local_port) && 24 | socket.local_port, 25 | :local_address => socket.respond_to?(:local_address) && 26 | socket.local_address 27 | }) 28 | end 29 | response 30 | end 31 | 32 | def response_call(datum) 33 | if datum[:hijack_block] 34 | # Need to process the response headers here rather than in 35 | # Excon::Middleware::ResponseParser as the response parser will 36 | # block trying to read the body. 37 | socket = datum[:connection].send(:socket) 38 | 39 | # c.f. Excon::Response.parse 40 | until match = /^HTTP\/\d+\.\d+\s(\d{3})\s/.match(socket.readline); end 41 | status = match[1].to_i 42 | 43 | datum[:response] = build_response(status, socket) 44 | 45 | Excon::Response.parse_headers(socket, datum) 46 | datum[:hijack_block].call socket.instance_variable_get(:@socket) 47 | end 48 | 49 | @stack.response_call(datum) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /script/docker.conf: -------------------------------------------------------------------------------- 1 | description "Docker daemon" 2 | 3 | start on (local-filesystems and net-device-up IFACE!=lo) 4 | stop on runlevel [!2345] 5 | limit nofile 524288 1048576 6 | limit nproc 524288 1048576 7 | 8 | respawn 9 | 10 | kill timeout 20 11 | 12 | pre-start script 13 | # see also https://github.com/tianon/cgroupfs-mount/blob/master/cgroupfs-mount 14 | if grep -v '^#' /etc/fstab | grep -q cgroup \ 15 | || [ ! -e /proc/cgroups ] \ 16 | || [ ! -d /sys/fs/cgroup ]; then 17 | exit 0 18 | fi 19 | if ! mountpoint -q /sys/fs/cgroup; then 20 | mount -t tmpfs -o uid=0,gid=0,mode=0755 cgroup /sys/fs/cgroup 21 | fi 22 | ( 23 | cd /sys/fs/cgroup 24 | for sys in $(awk '!/^#/ { if ($4 == 1) print $1 }' /proc/cgroups); do 25 | mkdir -p $sys 26 | if ! mountpoint -q $sys; then 27 | if ! mount -n -t cgroup -o $sys cgroup $sys; then 28 | rmdir $sys || true 29 | fi 30 | fi 31 | done 32 | ) 33 | end script 34 | 35 | script 36 | # modify these in /etc/default/$UPSTART_JOB (/etc/default/docker) 37 | DOCKER=/usr/bin/$UPSTART_JOB 38 | DOCKER_OPTS= 39 | if [ -f /etc/default/$UPSTART_JOB ]; then 40 | . /etc/default/$UPSTART_JOB 41 | fi 42 | exec "$DOCKER" -d $DOCKER_OPTS 43 | end script 44 | 45 | # Don't emit "started" event until docker.sock is ready. 46 | # See https://github.com/docker/docker/issues/6647 47 | post-start script 48 | DOCKER_OPTS= 49 | if [ -f /etc/default/$UPSTART_JOB ]; then 50 | . /etc/default/$UPSTART_JOB 51 | fi 52 | if ! printf "%s" "$DOCKER_OPTS" | grep -qE -e '-H|--host'; then 53 | while ! [ -e /var/run/docker.sock ]; do 54 | initctl status $UPSTART_JOB | grep -qE "(stop|respawn)/" && exit 1 55 | echo "Waiting for /var/run/docker.sock" 56 | sleep 0.1 57 | done 58 | echo "/var/run/docker.sock is up" 59 | fi 60 | end script 61 | 62 | -------------------------------------------------------------------------------- /lib/docker/network.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class represents a Docker Network. 4 | class Docker::Network 5 | include Docker::Base 6 | 7 | def connect(container, opts = {}, body_opts = {}) 8 | body = MultiJson.dump({ container: container }.merge(body_opts)) 9 | Docker::Util.parse_json( 10 | connection.post(path_for('connect'), opts, body: body) 11 | ) 12 | reload 13 | end 14 | 15 | def disconnect(container, opts = {}) 16 | body = MultiJson.dump(container: container) 17 | Docker::Util.parse_json( 18 | connection.post(path_for('disconnect'), opts, body: body) 19 | ) 20 | reload 21 | end 22 | 23 | def remove(opts = {}) 24 | connection.delete(path_for, opts) 25 | nil 26 | end 27 | alias_method :delete, :remove 28 | 29 | def json(opts = {}) 30 | Docker::Util.parse_json(connection.get(path_for, opts)) 31 | end 32 | 33 | def to_s 34 | "Docker::Network { :id => #{id}, :info => #{info.inspect}, "\ 35 | ":connection => #{connection} }" 36 | end 37 | 38 | def reload 39 | network_json = @connection.get("/networks/#{@id}") 40 | hash = Docker::Util.parse_json(network_json) || {} 41 | @info = hash 42 | end 43 | 44 | class << self 45 | def create(name, opts = {}, conn = Docker.connection) 46 | default_opts = MultiJson.dump({ 47 | 'Name' => name, 48 | 'CheckDuplicate' => true 49 | }.merge(opts)) 50 | resp = conn.post('/networks/create', {}, body: default_opts) 51 | response_hash = Docker::Util.parse_json(resp) || {} 52 | get(response_hash['Id'], {}, conn) || {} 53 | end 54 | 55 | def get(id, opts = {}, conn = Docker.connection) 56 | network_json = conn.get("/networks/#{id}", opts) 57 | hash = Docker::Util.parse_json(network_json) || {} 58 | new(conn, hash) 59 | end 60 | 61 | def all(opts = {}, conn = Docker.connection) 62 | hashes = Docker::Util.parse_json(conn.get('/networks', opts)) || [] 63 | hashes.map { |hash| new(conn, hash) } 64 | end 65 | 66 | def remove(id, opts = {}, conn = Docker.connection) 67 | conn.delete("/networks/#{id}", opts) 68 | nil 69 | end 70 | alias_method :delete, :remove 71 | 72 | def prune(conn = Docker.connection) 73 | conn.post("/networks/prune", {}) 74 | nil 75 | end 76 | end 77 | 78 | # Convenience method to return the path for a particular resource. 79 | def path_for(resource = nil) 80 | ["/networks/#{id}", resource].compact.join('/') 81 | end 82 | 83 | private :path_for 84 | end 85 | -------------------------------------------------------------------------------- /.github/workflows/unit_test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | on: 3 | push: 4 | branches: 5 | # A test branch for seeing if your tests will pass in your personal fork 6 | - test_me_github 7 | pull_request: 8 | branches: 9 | - main 10 | - master 11 | jobs: 12 | docker-rspec: 13 | runs-on: 14 | - ubuntu-latest 15 | strategy: 16 | matrix: 17 | ruby: 18 | - 3.3 19 | - 3.2 20 | - 3.1 21 | - '3.0' 22 | - 2.7 23 | - 2.6 24 | - 2.5 25 | - 2.4 26 | docker_version: 27 | - ':26.' 28 | - ':27.' 29 | fail-fast: false 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: ruby/setup-ruby@v1 33 | with: 34 | ruby-version: ${{ matrix.ruby }} 35 | bundler-cache: true 36 | - name: Update gems 37 | run: bundle update 38 | - name: install docker 39 | env: 40 | DOCKER_VERSION: ${{ matrix.docker_version }} 41 | run: | 42 | set -x 43 | sudo apt-get remove -y docker docker-engine docker.io containerd runc ||: 44 | sudo apt-get update -y 45 | sudo apt-get install -y apt-transport-https ca-certificates curl gnupg-agent software-properties-common 46 | curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add - 47 | sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" 48 | sudo apt-get update -y 49 | sudo apt-cache gencaches 50 | sudo apt-get install -y docker-ce=$( apt-cache madison docker-ce | grep -e $DOCKER_VERSION | cut -f 2 -d '|' | head -1 | sed 's/\s//g' ) 51 | if [ $? -ne 0 ]; then 52 | echo "Error: Could not install ${DOCKER_VERSION}" 53 | echo "Available docker versions:" 54 | apt-cache madison docker-ce 55 | exit 1 56 | fi 57 | sudo systemctl start docker 58 | - name: spec tests 59 | run: bundle exec rake 60 | 61 | podman-rspec: 62 | runs-on: 63 | - ubuntu-latest 64 | strategy: 65 | matrix: 66 | ruby: 67 | - 3.3 68 | - 3.2 69 | - 3.1 70 | - '3.0' 71 | - 2.7 72 | - 2.6 73 | - 2.5 74 | - 2.4 75 | fail-fast: false 76 | steps: 77 | - uses: actions/checkout@v4 78 | - uses: ruby/setup-ruby@v1 79 | with: 80 | ruby-version: ${{ matrix.ruby }} 81 | bundler-cache: true 82 | - name: Update gems 83 | run: bundle update 84 | - name: install podman 85 | run: sudo ./script/install_podman.sh 86 | - name: spec tests 87 | run: bundle exec rake 88 | -------------------------------------------------------------------------------- /spec/docker/messages_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | SingleCov.covered! uncovered: 4 6 | 7 | describe Docker::Messages do 8 | shared_examples_for "two equal messages" do 9 | it "has the same messages as we expect" do 10 | expect(messages.all_messages).to eq(expected.all_messages) 11 | expect(messages.stdout_messages).to eq(expected.stdout_messages) 12 | expect(messages.stderr_messages).to eq(expected.stderr_messages) 13 | expect(messages.buffer).to eq(expected.buffer) 14 | end 15 | end 16 | 17 | describe '.decipher_messages' do 18 | shared_examples_for "decipher_messages of raw_test" do 19 | let(:messages) { 20 | subject.decipher_messages(raw_text) 21 | } 22 | 23 | it_behaves_like "two equal messages" 24 | end 25 | 26 | context 'given both standard out and standard error' do 27 | let(:raw_text) { 28 | "\x01\x00\x00\x00\x00\x00\x00\x01a\x02\x00\x00\x00\x00\x00\x00\x01b" 29 | } 30 | let(:expected) { 31 | Docker::Messages.new(["a"], ["b"], ["a","b"], "") 32 | } 33 | 34 | it_behaves_like "decipher_messages of raw_test" 35 | end 36 | 37 | context 'given a single header' do 38 | let(:raw_text) { "\x01\x00\x00\x00\x00\x00\x00\x01a" } 39 | let(:expected) { 40 | Docker::Messages.new(["a"], [], ["a"], "") 41 | } 42 | 43 | it_behaves_like "decipher_messages of raw_test" 44 | end 45 | 46 | context 'given two headers' do 47 | let(:raw_text) { 48 | "\x01\x00\x00\x00\x00\x00\x00\x01a\x01\x00\x00\x00\x00\x00\x00\x01b" 49 | } 50 | 51 | let(:expected) { 52 | Docker::Messages.new(["a", "b"], [], ["a","b"], "") 53 | } 54 | 55 | it_behaves_like "decipher_messages of raw_test" 56 | end 57 | 58 | context 'given a header for text longer then 255 characters' do 59 | let(:raw_text) { 60 | "\x01\x00\x00\x00\x00\x00\x01\x01" + ("a" * 257) 61 | } 62 | let(:expected) { 63 | Docker::Messages.new([("a" * 257)], [], [("a" * 257)], "") 64 | } 65 | 66 | it_behaves_like "decipher_messages of raw_test" 67 | end 68 | end 69 | 70 | describe "#append" do 71 | context "appending one set of messages on another" do 72 | let(:messages) { 73 | Docker::Messages.new([], [], [], "") 74 | } 75 | 76 | before do 77 | messages.append(new_messages) 78 | end 79 | 80 | context "with a buffer" do 81 | let(:new_messages) { 82 | Docker::Messages.new(["a"], [], ["a"], "b") 83 | } 84 | let(:expected) { 85 | Docker::Messages.new(["a"], [], ["a"], "") 86 | } 87 | it_behaves_like "two equal messages" 88 | end 89 | 90 | context "without a buffer" do 91 | let(:new_messages) { 92 | Docker::Messages.new(["a"], [], ["a"], "") 93 | } 94 | let(:expected) { 95 | Docker::Messages.new(["a"], [], ["a"], "") 96 | } 97 | it_behaves_like "two equal messages" 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/docker/event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class represents a Docker Event. 4 | class Docker::Event 5 | include Docker::Error 6 | 7 | # Represents the actor object nested within an event 8 | class Actor 9 | attr_accessor :ID, :Attributes 10 | 11 | def initialize(actor_attributes = {}) 12 | [:ID, :Attributes].each do |sym| 13 | value = actor_attributes[sym] 14 | if value.nil? 15 | value = actor_attributes[sym.to_s] 16 | end 17 | send("#{sym}=", value) 18 | end 19 | 20 | if self.Attributes.nil? 21 | self.Attributes = {} 22 | end 23 | end 24 | 25 | alias_method :id, :ID 26 | alias_method :attributes, :Attributes 27 | end 28 | 29 | class << self 30 | include Docker::Error 31 | 32 | def stream(opts = {}, conn = Docker.connection, &block) 33 | conn.get('/events', opts, :response_block => lambda { |b, r, t| 34 | b.each_line do |line| 35 | block.call(new_event(line, r, t)) 36 | end 37 | }) 38 | end 39 | 40 | def since(since, opts = {}, conn = Docker.connection, &block) 41 | stream(opts.merge(:since => since), conn, &block) 42 | end 43 | 44 | def new_event(body, remaining, total) 45 | return if body.nil? || body.empty? 46 | json = Docker::Util.parse_json(body) 47 | Docker::Event.new(json) 48 | end 49 | end 50 | 51 | attr_accessor :Type, :Action, :time, :timeNano 52 | attr_reader :Actor 53 | # Deprecated interface 54 | attr_accessor :status, :from 55 | 56 | def initialize(event_attributes = {}) 57 | [:Type, :Action, :Actor, :time, :timeNano, :status, :from].each do |sym| 58 | value = event_attributes[sym] 59 | if value.nil? 60 | value = event_attributes[sym.to_s] 61 | end 62 | send("#{sym}=", value) 63 | end 64 | 65 | if @Actor.nil? 66 | value = event_attributes[:id] 67 | if value.nil? 68 | value = event_attributes['id'] 69 | end 70 | self.Actor = Actor.new(ID: value) 71 | end 72 | end 73 | 74 | def ID 75 | self.actor.ID 76 | end 77 | 78 | def Actor=(actor) 79 | return if actor.nil? 80 | if actor.is_a? Actor 81 | @Actor = actor 82 | else 83 | @Actor = Actor.new(actor) 84 | end 85 | end 86 | 87 | alias_method :type, :Type 88 | alias_method :action, :Action 89 | alias_method :actor, :Actor 90 | alias_method :time_nano, :timeNano 91 | alias_method :id, :ID 92 | 93 | def to_s 94 | if type.nil? && action.nil? 95 | to_s_legacy 96 | else 97 | to_s_actor_style 98 | end 99 | end 100 | 101 | private 102 | 103 | def to_s_legacy 104 | attributes = [] 105 | attributes << "from=#{from}" unless from.nil? 106 | 107 | unless attributes.empty? 108 | attribute_string = "(#{attributes.join(', ')}) " 109 | end 110 | 111 | "Docker::Event { #{time} #{status} #{id} #{attribute_string}}" 112 | end 113 | 114 | def to_s_actor_style 115 | most_accurate_time = time_nano || time 116 | 117 | attributes = [] 118 | actor.attributes.each do |attribute, value| 119 | attributes << "#{attribute}=#{value}" 120 | end 121 | 122 | unless attributes.empty? 123 | attribute_string = "(#{attributes.join(', ')}) " 124 | end 125 | 126 | "Docker::Event { #{most_accurate_time} #{type} #{action} #{actor.id} #{attribute_string}}" 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/docker/exec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class represents a Docker Exec Instance. 4 | class Docker::Exec 5 | include Docker::Base 6 | 7 | # Convert details about the object into a string 8 | # 9 | # @return [String] String representation of the Exec instance object 10 | def to_s 11 | "Docker::Exec { :id => #{self.id}, :connection => #{self.connection} }" 12 | end 13 | 14 | # Create a new Exec instance in a running container. Please note, this does 15 | # NOT execute the instance - you must run #start. Also, each instance is 16 | # one-time use only. 17 | # 18 | # @param options [Hash] Parameters to pass in to the API. 19 | # @param conn [Docker::Connection] Connection to Docker Remote API 20 | # 21 | # @return [Docker::Exec] self 22 | def self.create(options = {}, conn = Docker.connection) 23 | container = options.delete('Container') 24 | 25 | # Podman does not attach these by default but does require them to be attached 26 | if ::Docker.podman?(conn) 27 | options['AttachStderr'] = true if options['AttachStderr'].nil? 28 | options['AttachStdout'] = true if options['AttachStdout'].nil? 29 | end 30 | 31 | resp = conn.post("/containers/#{container}/exec", {}, 32 | body: MultiJson.dump(options)) 33 | hash = Docker::Util.parse_json(resp) || {} 34 | new(conn, hash) 35 | end 36 | 37 | # Get info about the Exec instance 38 | # 39 | def json 40 | Docker::Util.parse_json(connection.get(path_for(:json), {})) 41 | end 42 | 43 | # Start the Exec instance. The Exec instance is deleted after this so this 44 | # command can only be run once. 45 | # 46 | # @param options [Hash] Options to dictate behavior of the instance 47 | # @option options [Object] :stdin (nil) The object to pass to STDIN. 48 | # @option options [TrueClass, FalseClass] :detach (false) Whether to attach 49 | # to STDOUT/STDERR. 50 | # @option options [TrueClass, FalseClass] :tty (false) Whether to attach using 51 | # a pseudo-TTY. 52 | # 53 | # @return [Array, Array, Int] The STDOUT, STDERR and exit code 54 | def start!(options = {}, &block) 55 | 56 | # Parse the Options 57 | tty = !!options.delete(:tty) 58 | detached = !!options.delete(:detach) 59 | stdin = options[:stdin] 60 | read_timeout = options[:wait] 61 | 62 | # Create API Request Body 63 | body = MultiJson.dump( 64 | 'Tty' => tty, 65 | 'Detach' => detached 66 | ) 67 | excon_params = { body: body } 68 | 69 | msgs = Docker::Messages.new 70 | unless detached 71 | if stdin 72 | excon_params[:hijack_block] = Docker::Util.hijack_for(stdin, block, 73 | msgs, tty) 74 | else 75 | excon_params[:response_block] = Docker::Util.attach_for(block, 76 | msgs, tty) 77 | end 78 | end 79 | 80 | excon_params[:read_timeout] = read_timeout unless read_timeout.nil? 81 | 82 | connection.post(path_for(:start), nil, excon_params) 83 | [msgs.stdout_messages, msgs.stderr_messages, self.json['ExitCode']] 84 | end 85 | 86 | # #start! performs the associated action and returns the output. 87 | # #start does the same, but rescues from ServerErrors. 88 | [:start].each do |method| 89 | define_method(method) do |*args| 90 | begin; public_send(:"#{method}!", *args); rescue ServerError; self end 91 | end 92 | end 93 | 94 | # Resize the TTY associated with the Exec instance 95 | # 96 | # @param query [Hash] API query parameters 97 | # @option query [Fixnum] h Height of the TTY 98 | # @option query [Fixnum] w Width of the TTY 99 | # 100 | # @return [Docker::Exec] self 101 | def resize(query = {}) 102 | connection.post(path_for(:resize), query) 103 | self 104 | end 105 | 106 | # Get the request URI for the given endpoint 107 | # 108 | # @param endpoint [Symbol] The endpoint to grab 109 | # @return [String] The full Remote API endpoint with ID 110 | def path_for(endpoint) 111 | "/exec/#{self.id}/#{endpoint}" 112 | end 113 | 114 | private :path_for 115 | private_class_method :new 116 | end 117 | -------------------------------------------------------------------------------- /spec/docker/connection_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | SingleCov.covered! uncovered: 12 6 | 7 | describe Docker::Connection do 8 | subject { described_class.new('http://localhost:4243', {}) } 9 | 10 | describe '#initialize' do 11 | let(:url) { 'http://localhost:4243' } 12 | let(:options) { {} } 13 | subject { described_class.new(url, options) } 14 | 15 | context 'when the first argument is not a String' do 16 | let(:url) { :lol_not_a_string } 17 | 18 | it 'raises an error' do 19 | expect { subject }.to raise_error(Docker::Error::ArgumentError) 20 | end 21 | end 22 | 23 | context 'when the first argument is a String' do 24 | context 'and the url is a unix socket' do 25 | let(:url) { ::Docker.env_url || ::Docker.default_socket_url } 26 | 27 | it 'sets the socket path in the options' do 28 | expect(subject.url).to eq('unix:///') 29 | expect(subject.options).to include(:socket => url.split('//').last) 30 | end 31 | end 32 | 33 | context 'but the second argument is not a Hash' do 34 | let(:options) { :lol_not_a_hash } 35 | 36 | it 'raises an error' do 37 | expect { subject }.to raise_error(Docker::Error::ArgumentError) 38 | end 39 | end 40 | 41 | context 'and the second argument is a Hash' do 42 | it 'sets the url and options' do 43 | expect(subject.url).to eq url 44 | expect(subject.options).to eq options 45 | end 46 | end 47 | end 48 | 49 | context 'url conversion to uri' do 50 | context 'when the url does not contain a scheme' do 51 | let(:url) { 'localhost:4243' } 52 | 53 | it 'adds the scheme to the url' do 54 | expect(subject.url).to eq "http://#{url}" 55 | end 56 | end 57 | 58 | context 'when the url is a complete uri' do 59 | let(:url) { 'http://localhost:4243' } 60 | 61 | it 'leaves the url intact' do 62 | expect(subject.url).to eq url 63 | end 64 | end 65 | end 66 | end 67 | 68 | describe '#resource' do 69 | its(:resource) { should be_a Excon::Connection } 70 | end 71 | 72 | describe '#request' do 73 | let(:method) { :get } 74 | let(:path) { '/test' } 75 | let(:query) { { :all => true } } 76 | let(:options) { { :expects => 201, :lol => true } } 77 | let(:body) { rand(10000000) } 78 | let(:resource) { double(:resource) } 79 | let(:response) { double(:response, :body => body) } 80 | let(:expected_hash) { 81 | { 82 | :method => method, 83 | :path => path, 84 | :query => query, 85 | :headers => { 'Content-Type' => 'text/plain', 86 | 'User-Agent' => "Swipely/Docker-API #{Docker::VERSION}", 87 | }, 88 | :expects => 201, 89 | :idempotent => true, 90 | :lol => true 91 | } 92 | } 93 | 94 | before do 95 | allow(subject).to receive(:resource).and_return(resource) 96 | expect(resource).to receive(:request). 97 | with(expected_hash). 98 | and_return(response) 99 | end 100 | 101 | it 'sends #request to #resource with the compiled params' do 102 | expect(subject.request(method, path, query, options)).to eq body 103 | end 104 | end 105 | 106 | [:get, :put, :post, :delete].each do |method| 107 | describe "##{method}" do 108 | it 'is delegated to #request' do 109 | expect(subject).to receive(:request).with(method) 110 | subject.public_send(method) 111 | end 112 | end 113 | end 114 | 115 | describe '#to_s' do 116 | let(:url) { 'http://google.com:4000' } 117 | let(:options) { {} } 118 | let(:expected_string) { 119 | "Docker::Connection { :url => #{url}, :options => #{options} }" 120 | } 121 | subject { described_class.new(url, options) } 122 | 123 | it 'returns a pretty version with the url and port' do 124 | expect(subject.to_s).to eq expected_string 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/docker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'cgi' 4 | require 'multi_json' 5 | require 'excon' 6 | require 'tempfile' 7 | require 'base64' 8 | require 'find' 9 | require 'rubygems/package' 10 | require 'uri' 11 | require 'open-uri' 12 | 13 | # Add the Hijack middleware at the top of the middleware stack so it can 14 | # potentially hijack HTTP sockets (when attaching to stdin) before other 15 | # middlewares try and parse the response. 16 | require 'excon/middlewares/hijack' 17 | Excon.defaults[:middlewares].unshift Excon::Middleware::Hijack 18 | 19 | Excon.defaults[:middlewares] << Excon::Middleware::RedirectFollower 20 | 21 | # The top-level module for this gem. Its purpose is to hold global 22 | # configuration variables that are used as defaults in other classes. 23 | module Docker 24 | attr_accessor :creds, :logger 25 | 26 | require 'docker/error' 27 | require 'docker/connection' 28 | require 'docker/base' 29 | require 'docker/container' 30 | require 'docker/network' 31 | require 'docker/event' 32 | require 'docker/exec' 33 | require 'docker/image' 34 | require 'docker/messages_stack' 35 | require 'docker/messages' 36 | require 'docker/util' 37 | require 'docker/version' 38 | require 'docker/volume' 39 | require 'docker/rake_task' if defined?(Rake::Task) 40 | 41 | def default_socket_url 42 | 'unix:///var/run/docker.sock' 43 | end 44 | 45 | def env_url 46 | ENV['DOCKER_URL'] || ENV['DOCKER_HOST'] 47 | end 48 | 49 | def env_options 50 | if cert_path = ENV['DOCKER_CERT_PATH'] 51 | { 52 | client_cert: File.join(cert_path, 'cert.pem'), 53 | client_key: File.join(cert_path, 'key.pem'), 54 | ssl_ca_file: File.join(cert_path, 'ca.pem'), 55 | scheme: 'https' 56 | }.merge(ssl_options) 57 | else 58 | {} 59 | end 60 | end 61 | 62 | def ssl_options 63 | if ENV['DOCKER_SSL_VERIFY'] == 'false' 64 | { 65 | ssl_verify_peer: false 66 | } 67 | else 68 | {} 69 | end 70 | end 71 | 72 | def url 73 | @url ||= env_url || default_socket_url 74 | # docker uses a default notation tcp:// which means tcp://localhost:2375 75 | if @url == 'tcp://' 76 | @url = 'tcp://localhost:2375' 77 | end 78 | @url 79 | end 80 | 81 | def options 82 | @options ||= env_options 83 | end 84 | 85 | def url=(new_url) 86 | @url = new_url 87 | reset_connection! 88 | end 89 | 90 | def options=(new_options) 91 | @options = env_options.merge(new_options || {}) 92 | reset_connection! 93 | end 94 | 95 | def connection 96 | @connection ||= Connection.new(url, options) 97 | end 98 | 99 | def reset! 100 | @url = nil 101 | @options = nil 102 | reset_connection! 103 | end 104 | 105 | def reset_connection! 106 | @connection = nil 107 | end 108 | 109 | # Get the version of Go, Docker, and optionally the Git commit. 110 | def version(connection = self.connection) 111 | connection.version 112 | end 113 | 114 | # Get more information about the Docker server. 115 | def info(connection = self.connection) 116 | connection.info 117 | end 118 | 119 | # Ping the Docker server. 120 | def ping(connection = self.connection) 121 | connection.ping 122 | end 123 | 124 | # Determine if the server is podman or docker. 125 | def podman?(connection = self.connection) 126 | connection.podman? 127 | end 128 | 129 | # Determine if the session is rootless. 130 | def rootless?(connection = self.connection) 131 | connection.rootless? 132 | end 133 | 134 | # Login to the Docker registry. 135 | def authenticate!(options = {}, connection = self.connection) 136 | creds = MultiJson.dump(options) 137 | connection.post('/auth', {}, body: creds) 138 | @creds = creds 139 | true 140 | rescue Docker::Error::ServerError, Docker::Error::UnauthorizedError 141 | raise Docker::Error::AuthenticationError 142 | end 143 | 144 | module_function :default_socket_url, :env_url, :url, :url=, :env_options, 145 | :options, :options=, :creds, :creds=, :logger, :logger=, 146 | :connection, :reset!, :reset_connection!, :version, :info, 147 | :ping, :podman?, :rootless?, :authenticate!, :ssl_options 148 | end 149 | -------------------------------------------------------------------------------- /spec/docker/network_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | unless ::Docker.podman? 6 | SingleCov.covered! uncovered: 2 7 | 8 | describe Docker::Network, docker_1_9: true do 9 | let(:name) do |example| 10 | example.description.downcase.gsub(/\s/, '-') 11 | end 12 | 13 | describe '#to_s' do 14 | subject { described_class.new(Docker.connection, info) } 15 | let(:connection) { Docker.connection } 16 | 17 | let(:id) do 18 | 'a6c5ffd25e07a6c906accf804174b5eb6a9d2f9e07bccb8f5aa4f4de5be6d01d' 19 | end 20 | 21 | let(:info) do 22 | { 23 | 'Name' => 'bridge', 24 | 'Scope' => 'local', 25 | 'Driver' => 'bridge', 26 | 'IPAM' => { 27 | 'Driver' => 'default', 28 | 'Config' => [{ 'Subnet' => '172.17.0.0/16' }] 29 | }, 30 | 'Containers' => {}, 31 | 'Options' => { 32 | 'com.docker.network.bridge.default_bridge' => 'true', 33 | 'com.docker.network.bridge.enable_icc' => 'true', 34 | 'com.docker.network.bridge.enable_ip_masquerade' => 'true', 35 | 'com.docker.network.bridge.host_binding_ipv4' => '0.0.0.0', 36 | 'com.docker.network.bridge.name' => 'docker0', 37 | 'com.docker.network.driver.mtu' => '1500' 38 | }, 39 | 'id' => id 40 | } 41 | end 42 | 43 | let(:expected_string) do 44 | "Docker::Network { :id => #{id}, :info => #{info.inspect}, "\ 45 | ":connection => #{connection} }" 46 | end 47 | 48 | its(:to_s) { should == expected_string } 49 | end 50 | 51 | describe '.create' do 52 | let!(:id) { subject.id } 53 | subject { described_class.create(name) } 54 | after { described_class.remove(id) } 55 | 56 | it 'creates a Network' do 57 | expect(Docker::Network.all.map(&:id)).to include(id) 58 | end 59 | end 60 | 61 | describe '.remove' do 62 | let(:id) { subject.id } 63 | subject { described_class.create(name) } 64 | 65 | it 'removes the Network' do 66 | described_class.remove(id) 67 | expect(Docker::Network.all.map(&:id)).to_not include(id) 68 | end 69 | end 70 | 71 | describe '.get' do 72 | after do 73 | described_class.remove(name) 74 | end 75 | 76 | let!(:network) { described_class.create(name) } 77 | 78 | it 'returns a network' do 79 | expect(Docker::Network.get(name).id).to eq(network.id) 80 | end 81 | end 82 | 83 | describe '.all' do 84 | let!(:networks) do 85 | 5.times.map { |i| described_class.create("#{name}-#{i}") } 86 | end 87 | 88 | after do 89 | networks.each(&:remove) 90 | end 91 | 92 | it 'should return all networks' do 93 | expect(Docker::Network.all.map(&:id)).to include(*networks.map(&:id)) 94 | end 95 | end 96 | 97 | describe '.prune', :docker_17_03 => true do 98 | it 'prune networks' do 99 | expect { Docker::Network.prune }.not_to raise_error 100 | end 101 | end 102 | 103 | describe '#connect' do 104 | let!(:container) do 105 | Docker::Container.create( 106 | 'Cmd' => %w(sleep 10), 107 | 'Image' => 'debian:stable' 108 | ) 109 | end 110 | subject { described_class.create(name) } 111 | 112 | before(:each) { container.start } 113 | after(:each) do 114 | container.kill!.remove 115 | subject.remove 116 | end 117 | 118 | it 'connects a container to a network' do 119 | subject.connect(container.id) 120 | expect(subject.info['Containers']).to include(container.id) 121 | end 122 | end 123 | 124 | describe '#disconnect' do 125 | let!(:container) do 126 | Docker::Container.create( 127 | 'Cmd' => %w(sleep 10), 128 | 'Image' => 'debian:stable' 129 | ) 130 | end 131 | 132 | subject { described_class.create(name) } 133 | 134 | before(:each) do 135 | container.start 136 | sleep 1 137 | subject.connect(container.id) 138 | end 139 | 140 | after(:each) do 141 | container.kill!.remove 142 | subject.remove 143 | end 144 | 145 | it 'connects a container to a network' do 146 | subject.disconnect(container.id) 147 | expect(subject.info['Containers']).not_to include(container.id) 148 | end 149 | end 150 | 151 | describe '#remove' do 152 | let(:id) { subject.id } 153 | let(:name) { 'test-network-remove' } 154 | subject { described_class.create(name) } 155 | 156 | it 'removes the Network' do 157 | subject.remove 158 | expect(Docker::Network.all.map(&:id)).to_not include(id) 159 | end 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /spec/docker/event_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | SingleCov.covered! uncovered: 5 6 | 7 | describe Docker::Event do 8 | let(:api_response) do 9 | { 10 | 'Action' => 'start', 11 | 'Actor' => { 12 | 'Attributes' => { 13 | 'image' => 'tianon/true', 14 | 'name' => 'true-dat' 15 | }, 16 | 'ID' => 'bb2c783a32330b726f18d1eb44d80c899ef45771b4f939326e0fefcfc7e05db8' 17 | }, 18 | 'Type' => 'container', 19 | 'from' => 'tianon/true', 20 | 'id' => 'bb2c783a32330b726f18d1eb44d80c899ef45771b4f939326e0fefcfc7e05db8', 21 | 'status' => 'start', 22 | 'time' => 1461083270, 23 | 'timeNano' => 1461083270652069004 24 | } 25 | end 26 | 27 | describe "#to_s" do 28 | context 'with an old event' do 29 | let(:event) do 30 | described_class.new( 31 | status: status, 32 | id: id, 33 | from: from, 34 | time: time 35 | ) 36 | end 37 | 38 | let(:status) { "start" } 39 | let(:id) { "398c9f77b5d2" } 40 | let(:from) { "debian:stable" } 41 | let(:time) { 1381956164 } 42 | 43 | let(:expected_string) { 44 | "Docker::Event { #{time} #{status} #{id} (from=#{from}) }" 45 | } 46 | 47 | it "equals the expected string" do 48 | expect(event.to_s).to eq(expected_string) 49 | end 50 | end 51 | 52 | context 'with a new event' do 53 | let(:event) { described_class.new(api_response) } 54 | 55 | let(:expected_string) do 56 | 'Docker::Event { 1461083270652069004 container start '\ 57 | 'bb2c783a32330b726f18d1eb44d80c899ef45771b4f939326e0fefcfc7e05db8 '\ 58 | '(image=tianon/true, name=true-dat) }' 59 | end 60 | 61 | it 'equals the expected string' do 62 | expect(event.to_s).to eq(expected_string) 63 | end 64 | end 65 | end 66 | 67 | describe ".stream" do 68 | it 'receives at least 4 events' do 69 | events = 0 70 | 71 | stream_thread = Thread.new do 72 | Docker::Event.stream do |event| 73 | puts "#{event}" 74 | events += 1 75 | 76 | break if events >= 4 77 | end 78 | end 79 | 80 | container = Docker::Image.create('fromImage' => 'debian:stable') 81 | .run('bash') 82 | .tap(&:wait) 83 | 84 | stream_thread.join(10) || stream_thread.kill 85 | 86 | expect(events).to be >= 4 87 | 88 | container.remove 89 | end 90 | end 91 | 92 | describe ".since" do 93 | let(:time) { Time.now.to_i + 1 } 94 | 95 | it 'receives at least 4 events' do 96 | skip('Not supported on podman') if ::Docker.podman? 97 | events = 0 98 | 99 | stream_thread = Thread.new do 100 | Docker::Event.since(time) do |event| 101 | puts "#{event}" 102 | events += 1 103 | 104 | break if events >= 4 105 | end 106 | end 107 | 108 | container = Docker::Image.create('fromImage' => 'debian:stable') 109 | .run('bash') 110 | .tap(&:wait) 111 | 112 | stream_thread.join(10) || stream_thread.kill 113 | 114 | expect(events).to be >= 4 115 | 116 | container.remove 117 | end 118 | end 119 | 120 | describe ".new_event" do 121 | context 'with an old api response' do 122 | let(:event) { Docker::Event.new_event(response_body, nil, nil) } 123 | let(:status) { "start" } 124 | let(:id) { "398c9f77b5d2" } 125 | let(:from) { "debian:stable" } 126 | let(:time) { 1381956164 } 127 | let(:response_body) { 128 | "{\"status\":\"#{status}\",\"id\":\"#{id}\""\ 129 | ",\"from\":\"#{from}\",\"time\":#{time}}" 130 | } 131 | 132 | it "returns a Docker::Event" do 133 | expect(event).to be_kind_of(Docker::Event) 134 | expect(event.status).to eq(status) 135 | expect(event.id).to eq(id) 136 | expect(event.from).to eq(from) 137 | expect(event.time).to eq(time) 138 | end 139 | end 140 | 141 | context 'with a new api response' do 142 | let(:event) do 143 | Docker::Event.new_event( 144 | MultiJson.dump(api_response), 145 | nil, 146 | nil 147 | ) 148 | end 149 | 150 | it 'returns a Docker::Event' do 151 | expect(event).to be_kind_of(Docker::Event) 152 | expect(event.type).to eq('container') 153 | expect(event.action).to eq('start') 154 | expect( 155 | event.actor.id 156 | ).to eq('bb2c783a32330b726f18d1eb44d80c899ef45771b4f939326e0fefcfc7e05db8') 157 | expect(event.actor.attributes).to eq('image' => 'tianon/true', 'name' => 'true-dat') 158 | expect(event.time).to eq 1461083270 159 | expect(event.time_nano).to eq 1461083270652069004 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /script/docker: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | ### BEGIN INIT INFO 5 | # Provides: docker 6 | # Required-Start: $syslog $remote_fs 7 | # Required-Stop: $syslog $remote_fs 8 | # Should-Start: cgroupfs-mount cgroup-lite 9 | # Should-Stop: cgroupfs-mount cgroup-lite 10 | # Default-Start: 2 3 4 5 11 | # Default-Stop: 0 1 6 12 | # Short-Description: Create lightweight, portable, self-sufficient containers. 13 | # Description: 14 | # Docker is an open-source project to easily create lightweight, portable, 15 | # self-sufficient containers from any application. The same container that a 16 | # developer builds and tests on a laptop can run at scale, in production, on 17 | # VMs, bare metal, OpenStack clusters, public clouds and more. 18 | ### END INIT INFO 19 | 20 | export PATH=/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin 21 | 22 | BASE=$(basename $0) 23 | 24 | # modify these in /etc/default/$BASE (/etc/default/docker) 25 | DOCKER=/usr/bin/$BASE 26 | # This is the pid file managed by docker itself 27 | DOCKER_PIDFILE=/var/run/$BASE.pid 28 | # This is the pid file created/managed by start-stop-daemon 29 | DOCKER_SSD_PIDFILE=/var/run/$BASE-ssd.pid 30 | DOCKER_LOGFILE=/var/log/$BASE.log 31 | DOCKER_OPTS= 32 | DOCKER_DESC="Docker" 33 | 34 | # Get lsb functions 35 | . /lib/lsb/init-functions 36 | 37 | if [ -f /etc/default/$BASE ]; then 38 | . /etc/default/$BASE 39 | fi 40 | 41 | # Check docker is present 42 | if [ ! -x $DOCKER ]; then 43 | log_failure_msg "$DOCKER not present or not executable" 44 | exit 1 45 | fi 46 | 47 | check_init() { 48 | # see also init_is_upstart in /lib/lsb/init-functions (which isn't available in Ubuntu 12.04, or we'd use it directly) 49 | if [ -x /sbin/initctl ] && /sbin/initctl version 2>/dev/null | grep -q upstart; then 50 | log_failure_msg "$DOCKER_DESC is managed via upstart, try using service $BASE $1" 51 | exit 1 52 | fi 53 | } 54 | 55 | fail_unless_root() { 56 | if [ "$(id -u)" != '0' ]; then 57 | log_failure_msg "$DOCKER_DESC must be run as root" 58 | exit 1 59 | fi 60 | } 61 | 62 | cgroupfs_mount() { 63 | # see also https://github.com/tianon/cgroupfs-mount/blob/master/cgroupfs-mount 64 | if grep -v '^#' /etc/fstab | grep -q cgroup \ 65 | || [ ! -e /proc/cgroups ] \ 66 | || [ ! -d /sys/fs/cgroup ]; then 67 | return 68 | fi 69 | if ! mountpoint -q /sys/fs/cgroup; then 70 | mount -t tmpfs -o uid=0,gid=0,mode=0755 cgroup /sys/fs/cgroup 71 | fi 72 | ( 73 | cd /sys/fs/cgroup 74 | for sys in $(awk '!/^#/ { if ($4 == 1) print $1 }' /proc/cgroups); do 75 | mkdir -p $sys 76 | if ! mountpoint -q $sys; then 77 | if ! mount -n -t cgroup -o $sys cgroup $sys; then 78 | rmdir $sys || true 79 | fi 80 | fi 81 | done 82 | ) 83 | } 84 | 85 | case "$1" in 86 | start) 87 | check_init 88 | 89 | fail_unless_root 90 | 91 | cgroupfs_mount 92 | 93 | touch "$DOCKER_LOGFILE" 94 | chgrp docker "$DOCKER_LOGFILE" 95 | 96 | ulimit -n 1048576 97 | if [ "$BASH" ]; then 98 | ulimit -u 1048576 99 | else 100 | ulimit -p 1048576 101 | fi 102 | 103 | log_begin_msg "Starting $DOCKER_DESC: $BASE" 104 | start-stop-daemon --start --background \ 105 | --no-close \ 106 | --exec "$DOCKER" \ 107 | --pidfile "$DOCKER_SSD_PIDFILE" \ 108 | --make-pidfile \ 109 | -- \ 110 | -d -p "$DOCKER_PIDFILE" \ 111 | $DOCKER_OPTS \ 112 | >> "$DOCKER_LOGFILE" 2>&1 113 | log_end_msg $? 114 | ;; 115 | 116 | stop) 117 | check_init 118 | fail_unless_root 119 | log_begin_msg "Stopping $DOCKER_DESC: $BASE" 120 | start-stop-daemon --stop --pidfile "$DOCKER_SSD_PIDFILE" 121 | log_end_msg $? 122 | ;; 123 | 124 | restart) 125 | check_init 126 | fail_unless_root 127 | docker_pid=`cat "$DOCKER_SSD_PIDFILE" 2>/dev/null` 128 | [ -n "$docker_pid" ] \ 129 | && ps -p $docker_pid > /dev/null 2>&1 \ 130 | && $0 stop 131 | $0 start 132 | ;; 133 | 134 | force-reload) 135 | check_init 136 | fail_unless_root 137 | $0 restart 138 | ;; 139 | 140 | status) 141 | check_init 142 | status_of_proc -p "$DOCKER_SSD_PIDFILE" "$DOCKER" "$DOCKER_DESC" 143 | ;; 144 | 145 | *) 146 | echo "Usage: service docker {start|stop|restart|status}" 147 | exit 1 148 | ;; 149 | esac 150 | -------------------------------------------------------------------------------- /lib/docker/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class represents a Connection to a Docker server. The Connection is 4 | # immutable in that once the url and options is set they cannot be changed. 5 | class Docker::Connection 6 | require 'docker/util' 7 | require 'docker/error' 8 | 9 | include Docker::Error 10 | 11 | attr_reader :url, :options 12 | 13 | # Create a new Connection. This method takes a url (String) and options 14 | # (Hash). These are passed to Excon, so any options valid for `Excon.new` 15 | # can be passed here. 16 | def initialize(url, opts) 17 | case 18 | when !url.is_a?(String) 19 | raise ArgumentError, "Expected a String, got: '#{url}'" 20 | when !opts.is_a?(Hash) 21 | raise ArgumentError, "Expected a Hash, got: '#{opts}'" 22 | else 23 | uri = URI.parse(url) 24 | if uri.scheme == "unix" 25 | @url, @options = 'unix:///', {:socket => uri.path}.merge(opts) 26 | elsif uri.scheme =~ /^(https?|tcp)$/ 27 | @url, @options = url, opts 28 | else 29 | @url, @options = "http://#{uri}", opts 30 | end 31 | end 32 | end 33 | 34 | # The actual client that sends HTTP methods to the Docker server. This value 35 | # is not cached, since doing so may cause socket errors after bad requests. 36 | def resource 37 | Excon.new(url, options) 38 | end 39 | private :resource 40 | 41 | # Send a request to the server with the ` 42 | def request(*args, &block) 43 | retries ||= 0 44 | request = compile_request_params(*args, &block) 45 | log_request(request) 46 | begin 47 | resource.request(request).body 48 | rescue Excon::Errors::BadRequest => ex 49 | if retries < 2 50 | response_cause = '' 51 | begin 52 | response_cause = JSON.parse(ex.response.body)['cause'] 53 | rescue JSON::ParserError 54 | #noop 55 | end 56 | 57 | if response_cause.is_a?(String) 58 | # The error message will tell the application type given and then the 59 | # application type that the message should be 60 | # 61 | # This is not perfect since it relies on processing a message that 62 | # could change in the future. However, it should be a good stop-gap 63 | # until all methods are updated to pass in the appropriate content 64 | # type. 65 | # 66 | # A current example message is: 67 | # * 'Content-Type: application/json is not supported. Should be "application/x-tar"' 68 | matches = response_cause.delete('"\'').scan(%r{(application/\S+)}) 69 | unless matches.count < 2 70 | Docker.logger.warn( 71 | <<~RETRY_WARNING 72 | Automatically retrying with content type '#{response_cause}' 73 | Original Error: #{ex} 74 | RETRY_WARNING 75 | ) if Docker.logger 76 | 77 | request[:headers]['Content-Type'] = matches.last.first 78 | retries += 1 79 | retry 80 | end 81 | end 82 | end 83 | raise ClientError, ex.response.body 84 | rescue Excon::Errors::Unauthorized => ex 85 | raise UnauthorizedError, ex.response.body 86 | rescue Excon::Errors::NotFound => ex 87 | raise NotFoundError, ex.response.body 88 | rescue Excon::Errors::Conflict => ex 89 | raise ConflictError, ex.response.body 90 | rescue Excon::Errors::InternalServerError => ex 91 | raise ServerError, ex.response.body 92 | rescue Excon::Errors::Timeout => ex 93 | raise TimeoutError, ex.message 94 | end 95 | end 96 | 97 | def log_request(request) 98 | if Docker.logger 99 | Docker.logger.debug( 100 | [request[:method], request[:path], request[:query], request[:body]] 101 | ) 102 | end 103 | end 104 | 105 | def to_s 106 | "Docker::Connection { :url => #{url}, :options => #{options} }" 107 | end 108 | 109 | # Delegate all HTTP methods to the #request. 110 | [:get, :put, :post, :delete].each do |method| 111 | define_method(method) { |*args, &block| request(method, *args, &block) } 112 | end 113 | 114 | # Common attribute requests 115 | def info 116 | Docker::Util.parse_json(get('/info')) 117 | end 118 | 119 | def ping 120 | get('/_ping') 121 | end 122 | 123 | def podman? 124 | @podman ||= !( 125 | Array(version['Components']).find do |component| 126 | component['Name'].include?('Podman') 127 | end 128 | ).nil? 129 | end 130 | 131 | def rootless? 132 | @rootless ||= (info['Rootless'] == true) 133 | end 134 | 135 | def version 136 | @version ||= Docker::Util.parse_json(get('/version')) 137 | end 138 | 139 | private 140 | # Given an HTTP method, path, optional query, extra options, and block, 141 | # compiles a request. 142 | def compile_request_params(http_method, path, query = nil, opts = nil, &block) 143 | query ||= {} 144 | opts ||= {} 145 | headers = opts.delete(:headers) || {} 146 | content_type = opts[:body].nil? ? 'text/plain' : 'application/json' 147 | user_agent = "Swipely/Docker-API #{Docker::VERSION}" 148 | { 149 | :method => http_method, 150 | :path => path, 151 | :query => query, 152 | :headers => { 'Content-Type' => content_type, 153 | 'User-Agent' => user_agent, 154 | }.merge(headers), 155 | :expects => (200..204).to_a << 301 << 304, 156 | :idempotent => http_method == :get, 157 | :request_block => block, 158 | }.merge(opts).reject { |_, v| v.nil? } 159 | end 160 | end 161 | -------------------------------------------------------------------------------- /spec/docker/exec_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | SingleCov.covered! uncovered: 5 6 | 7 | describe Docker::Exec do 8 | let(:container) { 9 | Docker::Container.create( 10 | 'Cmd' => %w(sleep 300), 11 | 'Image' => 'debian:stable' 12 | ).start! 13 | } 14 | 15 | describe '#to_s' do 16 | subject { 17 | described_class.send(:new, Docker.connection, 'id' => rand(10000).to_s) 18 | } 19 | 20 | let(:id) { 'bf119e2' } 21 | let(:connection) { Docker.connection } 22 | let(:expected_string) { 23 | "Docker::Exec { :id => #{id}, :connection => #{connection} }" 24 | } 25 | before do 26 | { 27 | :@id => id, 28 | :@connection => connection 29 | }.each { |k, v| subject.instance_variable_set(k, v) } 30 | end 31 | 32 | its(:to_s) { should == expected_string } 33 | end 34 | 35 | describe '.create' do 36 | subject { described_class } 37 | 38 | context 'when the HTTP request returns a 201' do 39 | let(:options) do 40 | { 41 | 'AttachStdin' => false, 42 | 'AttachStdout' => false, 43 | 'AttachStderr' => false, 44 | 'Tty' => false, 45 | 'Cmd' => [ 46 | 'date' 47 | ], 48 | 'Container' => container.id 49 | } 50 | end 51 | let(:process) { subject.create(options) } 52 | after { container.kill!.remove } 53 | 54 | it 'sets the id' do 55 | expect(process).to be_a Docker::Exec 56 | expect(process.id).to_not be_nil 57 | expect(process.connection).to_not be_nil 58 | end 59 | end 60 | 61 | context 'when the parent container does not exist' do 62 | before do 63 | Docker.options = { :mock => true } 64 | Excon.stub({ :method => :get}, { :status => 404 }) # For Podman 65 | Excon.stub({ :method => :post}, { :status => 404 }) 66 | end 67 | after do 68 | Excon.stubs.shift 69 | Docker.options = {} 70 | end 71 | 72 | it 'raises an error' do 73 | expect { subject.create }.to raise_error(Docker::Error::NotFoundError) 74 | end 75 | end 76 | end 77 | 78 | describe '#json' do 79 | subject { 80 | described_class.create( 81 | 'Container' => container.id, 82 | 'Detach' => true, 83 | 'Cmd' => %w[true] 84 | ) 85 | } 86 | 87 | let(:description) { subject.json } 88 | before { subject.start! } 89 | after { container.kill!.remove } 90 | 91 | it 'returns the description as a Hash' do 92 | expect(description).to be_a Hash 93 | expect(description['ID']).to start_with(subject.id) 94 | end 95 | end 96 | 97 | describe '#start!' do 98 | context 'when the exec instance does not exist' do 99 | subject do 100 | described_class.send(:new, Docker.connection, 'id' => rand(10000).to_s) 101 | end 102 | 103 | it 'raises an error' do 104 | expect { subject.start! }.to raise_error(Docker::Error::NotFoundError) 105 | end 106 | end 107 | 108 | context 'when :detach is set to false' do 109 | subject { 110 | described_class.create( 111 | 'Container' => container.id, 112 | 'AttachStdout' => true, 113 | 'Cmd' => ['bash','-c','sleep 2; echo hello'] 114 | ) 115 | } 116 | after { container.kill!.remove } 117 | 118 | it 'returns the stdout and stderr messages' do 119 | expect(subject.start!).to eq([["hello\n"],[],0]) 120 | end 121 | 122 | context 'block is passed' do 123 | it 'attaches to the stream' do 124 | chunk = nil 125 | result = subject.start! do |stream, c| 126 | chunk ||= c 127 | end 128 | expect(chunk).to eq("hello\n") 129 | expect(result).to eq([["hello\n"], [], 0]) 130 | end 131 | end 132 | end 133 | 134 | context 'when :detach is set to true' do 135 | subject { 136 | described_class.create('Container' => container.id, 'Cmd' => %w[date]) 137 | } 138 | after { container.kill!.remove } 139 | 140 | it 'returns empty stdout/stderr messages with exitcode' do 141 | expect(subject.start!(:detach => true).length).to eq(3) 142 | end 143 | end 144 | 145 | context 'when :wait set long time value' do 146 | subject { 147 | described_class.create( 148 | 'Container' => container.id, 149 | 'AttachStdout' => true, 150 | 'Cmd' => %w[true] 151 | ) 152 | } 153 | after { container.kill!.remove } 154 | 155 | it 'returns empty stdout and stderr messages with exitcode' do 156 | expect(subject.start!(:wait => 100)).to eq([[], [], 0]) 157 | end 158 | end 159 | 160 | context 'when :wait set short time value' do 161 | subject { 162 | described_class.create( 163 | 'Container' => container.id, 164 | 'AttachStdout' => true, 165 | 'Cmd' => ['bash', '-c', 'sleep 2; echo hello'] 166 | ) 167 | } 168 | after { container.kill!.remove } 169 | 170 | it 'raises an error' do 171 | expect { subject.start!(:wait => 1) }.to raise_error(Docker::Error::TimeoutError) 172 | end 173 | end 174 | 175 | context 'when the HTTP request returns a 201' do 176 | subject { 177 | described_class.create('Container' => container.id, 'Cmd' => ['date']) 178 | } 179 | after { container.kill!.remove } 180 | 181 | it 'starts the exec instance' do 182 | expect { subject.start! }.not_to raise_error 183 | end 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /spec/docker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | SingleCov.covered! uncovered: 8 6 | 7 | describe Docker do 8 | subject { Docker } 9 | 10 | it { should be_a Module } 11 | 12 | context 'default url and connection' do 13 | context "when the DOCKER_* ENV variables aren't set" do 14 | before do 15 | allow(ENV).to receive(:[]).with('DOCKER_URL').and_return(nil) 16 | allow(ENV).to receive(:[]).with('DOCKER_HOST').and_return(nil) 17 | allow(ENV).to receive(:[]).with('DOCKER_CERT_PATH').and_return(nil) 18 | Docker.reset! 19 | end 20 | after { Docker.reset! } 21 | 22 | its(:options) { should == {} } 23 | its(:url) { should == 'unix:///var/run/docker.sock' } 24 | its(:connection) { should be_a Docker::Connection } 25 | end 26 | 27 | context "when the DOCKER_* ENV variables are set" do 28 | before do 29 | allow(ENV).to receive(:[]).with('DOCKER_URL') 30 | .and_return('unixs:///var/run/not-docker.sock') 31 | allow(ENV).to receive(:[]).with('DOCKER_HOST').and_return(nil) 32 | allow(ENV).to receive(:[]).with('DOCKER_CERT_PATH').and_return(nil) 33 | Docker.reset! 34 | end 35 | after { Docker.reset! } 36 | 37 | its(:options) { should == {} } 38 | its(:url) { should == 'unixs:///var/run/not-docker.sock' } 39 | its(:connection) { should be_a Docker::Connection } 40 | end 41 | 42 | context "when the DOCKER_HOST is set and uses default tcp://" do 43 | before do 44 | allow(ENV).to receive(:[]).with('DOCKER_URL').and_return(nil) 45 | allow(ENV).to receive(:[]).with('DOCKER_HOST').and_return('tcp://') 46 | allow(ENV).to receive(:[]).with('DOCKER_CERT_PATH').and_return(nil) 47 | Docker.reset! 48 | end 49 | after { Docker.reset! } 50 | 51 | its(:options) { should == {} } 52 | its(:url) { should == 'tcp://localhost:2375' } 53 | its(:connection) { should be_a Docker::Connection } 54 | end 55 | 56 | context "when the DOCKER_HOST ENV variable is set" do 57 | before do 58 | allow(ENV).to receive(:[]).with('DOCKER_URL').and_return(nil) 59 | allow(ENV).to receive(:[]).with('DOCKER_HOST') 60 | .and_return('tcp://someserver:8103') 61 | allow(ENV).to receive(:[]).with('DOCKER_CERT_PATH').and_return(nil) 62 | Docker.reset! 63 | end 64 | after { Docker.reset! } 65 | 66 | its(:options) { should == {} } 67 | its(:url) { should == 'tcp://someserver:8103' } 68 | its(:connection) { should be_a Docker::Connection } 69 | end 70 | 71 | context "DOCKER_URL should take precedence over DOCKER_HOST" do 72 | before do 73 | allow(ENV).to receive(:[]).with('DOCKER_URL') 74 | .and_return('tcp://someotherserver:8103') 75 | allow(ENV).to receive(:[]).with('DOCKER_HOST') 76 | .and_return('tcp://someserver:8103') 77 | allow(ENV).to receive(:[]).with('DOCKER_CERT_PATH').and_return(nil) 78 | Docker.reset! 79 | end 80 | after { Docker.reset! } 81 | 82 | its(:options) { should == {} } 83 | its(:url) { should == 'tcp://someotherserver:8103' } 84 | its(:connection) { should be_a Docker::Connection } 85 | end 86 | 87 | context "when the DOCKER_CERT_PATH and DOCKER_HOST ENV variables are set" do 88 | before do 89 | allow(ENV).to receive(:[]).with('DOCKER_URL').and_return(nil) 90 | allow(ENV).to receive(:[]).with('DOCKER_HOST') 91 | .and_return('tcp://someserver:8103') 92 | allow(ENV).to receive(:[]).with('DOCKER_CERT_PATH') 93 | .and_return('/boot2dockert/cert/path') 94 | allow(ENV).to receive(:[]).with('DOCKER_SSL_VERIFY').and_return(nil) 95 | Docker.reset! 96 | end 97 | after { Docker.reset! } 98 | 99 | its(:options) { 100 | should == { 101 | client_cert: '/boot2dockert/cert/path/cert.pem', 102 | client_key: '/boot2dockert/cert/path/key.pem', 103 | ssl_ca_file: '/boot2dockert/cert/path/ca.pem', 104 | scheme: 'https' 105 | } 106 | } 107 | its(:url) { should == 'tcp://someserver:8103' } 108 | its(:connection) { should be_a Docker::Connection } 109 | end 110 | 111 | context "when the DOCKER_CERT_PATH and DOCKER_SSL_VERIFY ENV variables are set" do 112 | before do 113 | allow(ENV).to receive(:[]).with('DOCKER_URL').and_return(nil) 114 | allow(ENV).to receive(:[]).with('DOCKER_HOST') 115 | .and_return('tcp://someserver:8103') 116 | allow(ENV).to receive(:[]).with('DOCKER_CERT_PATH') 117 | .and_return('/boot2dockert/cert/path') 118 | allow(ENV).to receive(:[]).with('DOCKER_SSL_VERIFY') 119 | .and_return('false') 120 | Docker.reset! 121 | end 122 | after { Docker.reset! } 123 | 124 | its(:options) { 125 | should == { 126 | client_cert: '/boot2dockert/cert/path/cert.pem', 127 | client_key: '/boot2dockert/cert/path/key.pem', 128 | ssl_ca_file: '/boot2dockert/cert/path/ca.pem', 129 | scheme: 'https', 130 | ssl_verify_peer: false 131 | } 132 | } 133 | its(:url) { should == 'tcp://someserver:8103' } 134 | its(:connection) { should be_a Docker::Connection } 135 | end 136 | 137 | end 138 | 139 | describe '#reset_connection!' do 140 | before { subject.connection } 141 | it 'sets the @connection to nil' do 142 | expect { subject.reset_connection! } 143 | .to change { subject.instance_variable_get(:@connection) } 144 | .to nil 145 | end 146 | end 147 | 148 | [:options=, :url=].each do |method| 149 | describe "##{method}" do 150 | before { Docker.reset! } 151 | 152 | it 'calls #reset_connection!' do 153 | expect(subject).to receive(:reset_connection!) 154 | subject.public_send(method, nil) 155 | end 156 | end 157 | end 158 | 159 | describe '#version' do 160 | before { Docker.reset! } 161 | 162 | let(:expected) { 163 | %w[ApiVersion Arch GitCommit GoVersion KernelVersion Os Version] 164 | } 165 | 166 | let(:version) { subject.version } 167 | it 'returns the version as a Hash' do 168 | expect(version).to be_a Hash 169 | expect(version.keys.sort).to include(*expected) 170 | end 171 | end 172 | 173 | describe '#info' do 174 | before { Docker.reset! } 175 | 176 | let(:info) { subject.info } 177 | let(:keys) do 178 | %w(Containers Debug DockerRootDir Driver DriverStatus ID IPv4Forwarding 179 | Images IndexServerAddress KernelVersion Labels MemTotal MemoryLimit 180 | NCPU NEventsListener NFd NGoroutines Name OperatingSystem SwapLimit) 181 | end 182 | 183 | it 'returns the info as a Hash' do 184 | expect(info).to be_a Hash 185 | expect(info.keys.sort).to include(*keys) 186 | end 187 | end 188 | 189 | describe '#ping' do 190 | before { Docker.reset! } 191 | 192 | let(:ping) { subject.ping} 193 | 194 | it 'returns the status as a String' do 195 | expect(ping).to eq('OK') 196 | end 197 | end 198 | 199 | describe '#authenticate!' do 200 | subject { described_class } 201 | 202 | let(:authentication) { 203 | subject.authenticate!(credentials) 204 | } 205 | 206 | after { Docker.creds = nil } 207 | 208 | context 'with valid credentials' do 209 | let(:credentials) { 210 | { 211 | :username => ENV['DOCKER_API_USER'], 212 | :password => ENV['DOCKER_API_PASS'], 213 | :email => ENV['DOCKER_API_EMAIL'], 214 | :serveraddress => 'https://index.docker.io/v1/' 215 | } 216 | } 217 | 218 | it 'logs in and sets the creds' do 219 | skip_without_auth 220 | expect(authentication).to be true 221 | expect(Docker.creds).to eq(MultiJson.dump(credentials)) 222 | end 223 | end 224 | 225 | context 'with invalid credentials' do 226 | let(:credentials) { 227 | { 228 | :username => 'test', 229 | :password => 'password', 230 | :email => 'test@example.com', 231 | :serveraddress => 'https://index.docker.io/v1/' 232 | } 233 | } 234 | 235 | it "raises an error and doesn't set the creds" do 236 | skip('Not supported on podman') if ::Docker.podman? 237 | expect { 238 | authentication 239 | }.to raise_error(Docker::Error::AuthenticationError) 240 | expect(Docker.creds).to be_nil 241 | end 242 | end 243 | end 244 | end 245 | -------------------------------------------------------------------------------- /lib/docker/util.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'set' 4 | 5 | # This module holds shared logic that doesn't really belong anywhere else in the 6 | # gem. 7 | module Docker::Util 8 | # http://www.tldp.org/LDP/GNU-Linux-Tools-Summary/html/x11655.htm#STANDARD-WILDCARDS 9 | GLOB_WILDCARDS = /[\?\*\[\{\]\}]/ 10 | 11 | include Docker::Error 12 | 13 | module_function 14 | 15 | # Attaches to a HTTP stream 16 | # 17 | # @param block 18 | # @param msg_stack [Docker::Messages] 19 | # @param tty [boolean] 20 | def attach_for(block, msg_stack, tty = false) 21 | # If TTY is enabled expect raw data and append to stdout 22 | if tty 23 | attach_for_tty(block, msg_stack) 24 | else 25 | attach_for_multiplex(block, msg_stack) 26 | end 27 | end 28 | 29 | def attach_for_tty(block, msg_stack) 30 | messages = Docker::Messages.new 31 | lambda do |c,r,t| 32 | messages.stdout_messages << c 33 | messages.all_messages << c 34 | msg_stack.append(messages) 35 | 36 | block.call c if block 37 | end 38 | end 39 | 40 | def attach_for_multiplex(block, msg_stack) 41 | messages = Docker::Messages.new 42 | lambda do |c,r,t| 43 | messages = messages.decipher_messages(c) 44 | 45 | unless block.nil? 46 | messages.stdout_messages.each do |msg| 47 | block.call(:stdout, msg) 48 | end 49 | messages.stderr_messages.each do |msg| 50 | block.call(:stderr, msg) 51 | end 52 | end 53 | 54 | msg_stack.append(messages) 55 | end 56 | end 57 | 58 | def debug(msg) 59 | Docker.logger.debug(msg) if Docker.logger 60 | end 61 | 62 | def hijack_for(stdin, block, msg_stack, tty) 63 | attach_block = attach_for(block, msg_stack, tty) 64 | 65 | lambda do |socket| 66 | debug "hijack: hijacking the HTTP socket" 67 | threads = [] 68 | 69 | debug "hijack: starting stdin copy thread" 70 | threads << Thread.start do 71 | debug "hijack: copying stdin => socket" 72 | IO.copy_stream stdin, socket 73 | 74 | debug "hijack: closing write end of hijacked socket" 75 | close_write(socket) 76 | end 77 | 78 | debug "hijack: starting hijacked socket read thread" 79 | threads << Thread.start do 80 | debug "hijack: reading from hijacked socket" 81 | 82 | begin 83 | while chunk = socket.readpartial(512) 84 | debug "hijack: got #{chunk.bytesize} bytes from hijacked socket" 85 | attach_block.call chunk, nil, nil 86 | end 87 | rescue EOFError 88 | end 89 | 90 | debug "hijack: killing stdin copy thread" 91 | threads.first.kill 92 | end 93 | 94 | threads.each(&:join) 95 | end 96 | end 97 | 98 | def close_write(socket) 99 | if socket.respond_to?(:close_write) 100 | socket.close_write 101 | elsif socket.respond_to?(:io) 102 | socket.io.close_write 103 | else 104 | raise IOError, 'Cannot close socket' 105 | end 106 | end 107 | 108 | def parse_json(body) 109 | MultiJson.load(body) unless body.nil? || body.empty? || (body == 'null') 110 | rescue MultiJson::ParseError => ex 111 | raise UnexpectedResponseError, ex.message 112 | end 113 | 114 | def parse_repo_tag(str) 115 | if match = str.match(/\A(.*):([^:]*)\z/) 116 | match.captures 117 | else 118 | [str, ''] 119 | end 120 | end 121 | 122 | def fix_json(body) 123 | parse_json("[#{body.gsub(/}\s*{/, '},{')}]") 124 | end 125 | 126 | def create_tar(hash = {}) 127 | output = StringIO.new 128 | Gem::Package::TarWriter.new(output) do |tar| 129 | hash.each do |file_name, file_details| 130 | permissions = file_details.is_a?(Hash) ? file_details[:permissions] : 0640 131 | tar.add_file(file_name, permissions) do |tar_file| 132 | content = file_details.is_a?(Hash) ? file_details[:content] : file_details 133 | tar_file.write(content) 134 | end 135 | end 136 | end 137 | output.tap(&:rewind).string 138 | end 139 | 140 | def create_dir_tar(directory) 141 | tempfile = create_temp_file 142 | directory += '/' unless directory.end_with?('/') 143 | 144 | create_relative_dir_tar(directory, tempfile) 145 | 146 | File.new(tempfile.path, 'r') 147 | end 148 | 149 | 150 | # return the set of files that form the docker context 151 | # implement this logic https://docs.docker.com/engine/reference/builder/#dockerignore-file 152 | def docker_context(directory) 153 | all_files = glob_all_files(File.join(directory, "**/*")) 154 | dockerignore = File.join(directory, '.dockerignore') 155 | return all_files unless all_files.include?(dockerignore) 156 | 157 | # Iterate over valid lines, starting with the initial glob as working set 158 | File 159 | .read(dockerignore) # https://docs.docker.com/engine/reference/builder/#dockerignore-file 160 | .each_line # "a newline-separated list of patterns" 161 | .map(&:strip) # "A preprocessing step removes leading and trailing whitespace" 162 | .reject(&:empty?) # "Lines that are blank after preprocessing are ignored" 163 | .reject { |p| p.start_with?('#') } # "if [a line starts with `#`], then this line is considered as a comment" 164 | .each_with_object(Set.new(all_files)) do |p, working_set| 165 | # determine the pattern (p) and whether it is to be added or removed from context 166 | add = p.start_with?("!") 167 | # strip leading "!" from pattern p, then prepend the base directory 168 | matches = dockerignore_compatible_glob(File.join(directory, add ? p[1..-1] : p)) 169 | # add or remove the matched items as indicated in the ignore file 170 | add ? working_set.merge(matches) : working_set.replace(working_set.difference(matches)) 171 | end 172 | .to_a 173 | end 174 | 175 | def create_relative_dir_tar(directory, output) 176 | Gem::Package::TarWriter.new(output) do |tar| 177 | files = docker_context(directory) 178 | 179 | files.each do |prefixed_file_name| 180 | stat = File.stat(prefixed_file_name) 181 | next unless stat.file? 182 | 183 | unprefixed_file_name = prefixed_file_name[directory.length..-1] 184 | add_file_to_tar( 185 | tar, unprefixed_file_name, stat.mode, stat.size, stat.mtime 186 | ) do |tar_file| 187 | IO.copy_stream(File.open(prefixed_file_name, 'rb'), tar_file) 188 | end 189 | end 190 | end 191 | end 192 | 193 | def add_file_to_tar(tar, name, mode, size, mtime) 194 | tar.check_closed 195 | 196 | io = tar.instance_variable_get(:@io) 197 | 198 | name, prefix = tar.split_name(name) 199 | 200 | header = Gem::Package::TarHeader.new(:name => name, :mode => mode, 201 | :size => size, :prefix => prefix, 202 | :mtime => mtime).to_s 203 | 204 | io.write header 205 | os = Gem::Package::TarWriter::BoundedStream.new io, size 206 | 207 | yield os if block_given? 208 | 209 | min_padding = size - os.written 210 | io.write("\0" * min_padding) 211 | 212 | remainder = (512 - (size % 512)) % 512 213 | io.write("\0" * remainder) 214 | 215 | tar 216 | end 217 | 218 | def create_temp_file 219 | tempfile_name = Dir::Tmpname.create('out') {} 220 | File.open(tempfile_name, 'wb+') 221 | end 222 | 223 | def extract_id(body) 224 | body.lines.reverse_each do |line| 225 | if (id = line.match(/Successfully built ([a-f0-9]+)/)) && !id[1].empty? 226 | return id[1] 227 | end 228 | end 229 | raise UnexpectedResponseError, "Couldn't find id: #{body}" 230 | end 231 | 232 | # Convenience method to get the file hash corresponding to an array of 233 | # local paths. 234 | def file_hash_from_paths(local_paths) 235 | local_paths.each_with_object({}) do |local_path, file_hash| 236 | unless File.exist?(local_path) 237 | raise ArgumentError, "#{local_path} does not exist." 238 | end 239 | 240 | basename = File.basename(local_path) 241 | if File.directory?(local_path) 242 | tar = create_dir_tar(local_path) 243 | file_hash[basename] = { 244 | content: tar.read, 245 | permissions: filesystem_permissions(local_path) 246 | } 247 | tar.close 248 | FileUtils.rm(tar.path) 249 | else 250 | file_hash[basename] = { 251 | content: File.read(local_path, mode: 'rb'), 252 | permissions: filesystem_permissions(local_path) 253 | } 254 | end 255 | end 256 | end 257 | 258 | def filesystem_permissions(path) 259 | mode = sprintf("%o", File.stat(path).mode) 260 | mode[(mode.length - 3)...mode.length].to_i(8) 261 | end 262 | 263 | def build_auth_header(credentials) 264 | credentials = MultiJson.dump(credentials) if credentials.is_a?(Hash) 265 | encoded_creds = Base64.urlsafe_encode64(credentials) 266 | { 267 | 'X-Registry-Auth' => encoded_creds 268 | } 269 | end 270 | 271 | def build_config_header(credentials) 272 | if credentials.is_a?(String) 273 | credentials = MultiJson.load(credentials, symbolize_keys: true) 274 | end 275 | 276 | header = MultiJson.dump( 277 | credentials[:serveraddress].to_s => { 278 | 'username' => credentials[:username].to_s, 279 | 'password' => credentials[:password].to_s, 280 | 'email' => credentials[:email].to_s 281 | } 282 | ) 283 | 284 | encoded_header = Base64.urlsafe_encode64(header) 285 | 286 | { 287 | 'X-Registry-Config' => encoded_header 288 | } 289 | end 290 | 291 | # do a directory glob that matches .dockerignore behavior 292 | # specifically: matched directories are considered a recursive match 293 | def dockerignore_compatible_glob(pattern) 294 | begin 295 | some_dirs, some_files = glob_all_files(pattern).partition { |f| File.directory?(f) } 296 | # since all directories will be re-processed with a /**/* glob, we can preemptively 297 | # eliminate any whose parent directory is already in this set. This saves significant time. 298 | some_files + some_dirs.reject { |d| some_dirs.any? { |pd| d.start_with?(pd) && d != pd } } 299 | end.each_with_object(Set.new) do |f, acc| 300 | # expand any directories by globbing; flatten results 301 | acc.merge(File.directory?(f) ? glob_all_files("#{f}/**/*") : [f]) 302 | end 303 | end 304 | 305 | def glob_all_files(pattern) 306 | # globs of "a_dir/**/*" can return "a_dir/.", so explicitly reject those 307 | (Dir.glob(pattern, File::FNM_DOTMATCH) - ['..', '.']).reject { |p| p.end_with?("/.") } 308 | end 309 | 310 | end 311 | -------------------------------------------------------------------------------- /spec/docker/util_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'tempfile' 5 | require 'fileutils' 6 | 7 | SingleCov.covered! uncovered: 71 8 | 9 | describe Docker::Util do 10 | subject { described_class } 11 | 12 | describe '.parse_json' do 13 | subject { described_class.parse_json(arg) } 14 | 15 | context 'when the argument is nil' do 16 | let(:arg) { nil } 17 | 18 | it { should be_nil } 19 | end 20 | 21 | context 'when the argument is empty' do 22 | let(:arg) { '' } 23 | 24 | it { should be_nil } 25 | end 26 | 27 | context 'when the argument is \'null\'' do 28 | let(:arg) { 'null' } 29 | 30 | it { should be_nil } 31 | end 32 | 33 | context 'when the argument is not valid JSON' do 34 | let(:arg) { '~~lol not valid json~~' } 35 | 36 | it 'raises an error' do 37 | expect { subject }.to raise_error Docker::Error::UnexpectedResponseError 38 | end 39 | end 40 | 41 | context 'when the argument is valid JSON' do 42 | let(:arg) { '{"yolo":"swag"}' } 43 | 44 | it 'parses the JSON into a Hash' do 45 | expect(subject).to eq 'yolo' => 'swag' 46 | end 47 | end 48 | end 49 | 50 | describe '.fix_json' do 51 | let(:response) { '{"this":"is"}{"not":"json"}' } 52 | subject { Docker::Util.fix_json(response) } 53 | 54 | it 'fixes the "JSON" response that Docker returns' do 55 | expect(subject).to eq [ 56 | { 57 | 'this' => 'is' 58 | }, 59 | { 60 | 'not' => 'json' 61 | } 62 | ] 63 | end 64 | end 65 | 66 | describe '.create_dir_tar' do 67 | attr_accessor :tmpdir 68 | 69 | def files_in_tar(tar) 70 | Gem::Package::TarReader.new(tar) { |content| return content.map(&:full_name).sort } 71 | end 72 | 73 | # @param base_dir [String] the path to the directory where the structure should be written 74 | # @param dockerignore_entries [Array] the lines of the desired .dockerignore file 75 | def structure_context_dir(dockerignore_entries = nil) 76 | FileUtils.mkdir_p("#{tmpdir}/a_dir/a_subdir") 77 | [ 78 | '#edge', 79 | 'a_file', 80 | 'a_file2', 81 | 'a_dir/a_file', 82 | 'a_dir/a_subdir/a_file', 83 | ].each { |f| File.write("#{tmpdir}/#{f}", 'x') } 84 | 85 | File.write("#{tmpdir}/.dockerignore", dockerignore_entries.join("\n")) unless dockerignore_entries.nil? 86 | end 87 | 88 | def expect_tar_entries(*entries) 89 | expect(files_in_tar(tar)).to contain_exactly(*entries) 90 | end 91 | 92 | let(:tar) { subject.create_dir_tar tmpdir } 93 | 94 | around do |example| 95 | Dir.mktmpdir do |tmpdir| 96 | self.tmpdir = tmpdir 97 | example.call 98 | FileUtils.rm tar 99 | end 100 | end 101 | 102 | it 'creates a tarball' do 103 | tar = subject.create_dir_tar tmpdir 104 | expect(files_in_tar(tar)).to eq [] 105 | end 106 | 107 | it 'packs regular files' do 108 | File.write("#{tmpdir}/foo", 'bar') 109 | expect(files_in_tar(tar)).to eq ['foo'] 110 | end 111 | 112 | it 'packs nested files, but not directory entries' do 113 | FileUtils.mkdir("#{tmpdir}/foo") 114 | File.write("#{tmpdir}/foo/bar", 'bar') 115 | expect(files_in_tar(tar)).to eq ['foo/bar'] 116 | end 117 | 118 | describe '.dockerignore' do 119 | it 'passes all files when there is no .dockerignore' do 120 | structure_context_dir 121 | expect_tar_entries('#edge', 'a_dir/a_file', 'a_dir/a_subdir/a_file', 'a_file', 'a_file2') 122 | end 123 | 124 | it 'passes all files when there is an empty .dockerignore' do 125 | structure_context_dir(['']) 126 | expect_tar_entries('#edge', '.dockerignore', 'a_dir/a_file', 'a_dir/a_subdir/a_file', 'a_file', 'a_file2') 127 | end 128 | 129 | it 'does not interpret comments' do 130 | structure_context_dir(['#edge']) 131 | expect_tar_entries('#edge', '.dockerignore', 'a_dir/a_file', 'a_dir/a_subdir/a_file', 'a_file', 'a_file2') 132 | end 133 | 134 | it 'ignores files' do 135 | structure_context_dir(['a_file']) 136 | expect_tar_entries('#edge', '.dockerignore', 'a_dir/a_file', 'a_dir/a_subdir/a_file', 'a_file2') 137 | end 138 | 139 | it 'ignores files with wildcard' do 140 | structure_context_dir(['a_file']) 141 | expect_tar_entries('#edge', '.dockerignore', 'a_dir/a_file', 'a_dir/a_subdir/a_file', 'a_file2') 142 | end 143 | 144 | it 'ignores files with dir wildcard' do 145 | structure_context_dir(['**/a_file']) 146 | expect_tar_entries('#edge', '.dockerignore', 'a_file2') 147 | end 148 | 149 | it 'ignores files with dir wildcard but handles exceptions' do 150 | structure_context_dir(['**/a_file', '!a_dir/a_file']) 151 | expect_tar_entries('#edge', '.dockerignore', 'a_dir/a_file', 'a_file2') 152 | end 153 | 154 | it 'ignores directories' do 155 | structure_context_dir(['a_dir']) 156 | expect_tar_entries('#edge', '.dockerignore', 'a_file', 'a_file2') 157 | end 158 | 159 | it 'ignores directories with dir wildcard' do 160 | structure_context_dir(['*/a_subdir']) 161 | expect_tar_entries('#edge', '.dockerignore', 'a_dir/a_file', 'a_file', 'a_file2') 162 | end 163 | 164 | it 'ignores directories with dir double wildcard' do 165 | structure_context_dir(['**/a_subdir']) 166 | expect_tar_entries('#edge', '.dockerignore', 'a_dir/a_file', 'a_file', 'a_file2') 167 | end 168 | 169 | it 'ignores directories with dir wildcard' do 170 | structure_context_dir(['a_dir', '!a_dir/a_subdir']) 171 | expect_tar_entries('#edge', '.dockerignore', 'a_dir/a_subdir/a_file', 'a_file', 'a_file2') 172 | end 173 | 174 | it 'ignores files' do 175 | File.write("#{tmpdir}/foo", 'bar') 176 | File.write("#{tmpdir}/baz", 'bar') 177 | 178 | File.write("#{tmpdir}/.dockerignore", "foo") 179 | 180 | expect(files_in_tar(tar)).to eq ['.dockerignore', 'baz'] 181 | end 182 | 183 | it 'ignores folders' do 184 | FileUtils.mkdir("#{tmpdir}/foo") 185 | File.write("#{tmpdir}/foo/bar", 'bar') 186 | 187 | File.write("#{tmpdir}/.dockerignore", "foo") 188 | 189 | expect(files_in_tar(tar)).to eq ['.dockerignore'] 190 | end 191 | 192 | it 'ignores based on wildcards' do 193 | File.write("#{tmpdir}/bar", 'bar') 194 | File.write("#{tmpdir}/baz", 'bar') 195 | 196 | File.write("#{tmpdir}/.dockerignore", "*z") 197 | 198 | expect(files_in_tar(tar)).to eq ['.dockerignore', 'bar'] 199 | end 200 | 201 | it 'ignores comments' do 202 | File.write("#{tmpdir}/foo", 'bar') 203 | File.write("#{tmpdir}/baz", 'bar') 204 | 205 | File.write("#{tmpdir}/.dockerignore", "# nothing here\nfoo") 206 | 207 | expect(files_in_tar(tar)).to eq ['.dockerignore', 'baz'] 208 | end 209 | 210 | it 'ignores whitespace' do 211 | File.write("#{tmpdir}/foo", 'bar') 212 | File.write("#{tmpdir}/baz", 'bar') 213 | 214 | File.write("#{tmpdir}/.dockerignore", "foo \n \n\n") 215 | 216 | expect(files_in_tar(tar)).to eq ['.dockerignore', 'baz'] 217 | end 218 | 219 | it 'ignores multiple patterns' do 220 | File.write("#{tmpdir}/foo", 'bar') 221 | File.write("#{tmpdir}/baz", 'bar') 222 | File.write("#{tmpdir}/zig", 'bar') 223 | 224 | File.write("#{tmpdir}/.dockerignore", "fo*\nba*") 225 | 226 | expect(files_in_tar(tar)).to eq ['.dockerignore', 'zig'] 227 | end 228 | end 229 | end 230 | 231 | describe '.build_auth_header' do 232 | subject { described_class } 233 | 234 | let(:credentials) { 235 | { 236 | :username => 'test', 237 | :password => 'password', 238 | :email => 'test@example.com', 239 | :serveraddress => 'https://registry.com/' 240 | } 241 | } 242 | let(:credential_string) { MultiJson.dump(credentials) } 243 | let(:encoded_creds) { Base64.urlsafe_encode64(credential_string) } 244 | let(:expected_header) { 245 | { 246 | 'X-Registry-Auth' => encoded_creds 247 | } 248 | } 249 | 250 | context 'given credentials as a Hash' do 251 | it 'returns an X-Registry-Auth header encoded' do 252 | expect(subject.build_auth_header(credentials)).to eq(expected_header) 253 | end 254 | end 255 | 256 | context 'given credentials as a String' do 257 | it 'returns an X-Registry-Auth header encoded' do 258 | expect( 259 | subject.build_auth_header(credential_string) 260 | ).to eq(expected_header) 261 | end 262 | end 263 | 264 | it 'does not contain newlines' do 265 | h = subject.build_auth_header(credentials).fetch('X-Registry-Auth') 266 | expect(h).not_to include("\n") 267 | end 268 | end 269 | 270 | describe '.build_config_header' do 271 | subject { described_class } 272 | 273 | let(:credentials) { 274 | { 275 | :username => 'test', 276 | :password => 'password', 277 | :email => 'test@example.com', 278 | :serveraddress => 'https://registry.com/' 279 | } 280 | } 281 | 282 | let(:credentials_object) do 283 | MultiJson.dump( 284 | :'https://registry.com/' => { 285 | username: 'test', 286 | password: 'password', 287 | email: 'test@example.com' 288 | } 289 | ) 290 | end 291 | 292 | let(:encoded_creds) { Base64.urlsafe_encode64(credentials_object) } 293 | let(:expected_header) { 294 | { 295 | 'X-Registry-Config' => encoded_creds 296 | } 297 | } 298 | 299 | context 'given credentials as a Hash' do 300 | it 'returns an X-Registry-Config header encoded' do 301 | expect(subject.build_config_header(credentials)).to eq(expected_header) 302 | end 303 | end 304 | 305 | context 'given credentials as a String' do 306 | it 'returns an X-Registry-Config header encoded' do 307 | expect( 308 | subject.build_config_header(MultiJson.dump(credentials)) 309 | ).to eq(expected_header) 310 | end 311 | end 312 | 313 | it 'does not contain newlines' do 314 | h = subject.build_config_header(credentials).fetch('X-Registry-Config') 315 | expect(h).not_to include("\n") 316 | end 317 | end 318 | 319 | describe '.filesystem_permissions' do 320 | it 'returns the permissions on a file' do 321 | file = Tempfile.new('test_file') 322 | file.close 323 | expected_permissions = 0600 324 | File.chmod(expected_permissions, file.path) 325 | 326 | actual_permissions = subject.filesystem_permissions(file.path) 327 | 328 | file.unlink 329 | expect(actual_permissions).to eql(expected_permissions) 330 | end 331 | end 332 | 333 | end 334 | -------------------------------------------------------------------------------- /lib/docker/container.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class represents a Docker Container. It's important to note that nothing 4 | # is cached so that the information is always up to date. 5 | class Docker::Container 6 | include Docker::Base 7 | 8 | # Update the @info hash, which is the only mutable state in this object. 9 | # e.g. if you would like a live status from the #info hash, call #refresh! first. 10 | def refresh! 11 | other = Docker::Container.all({all: true}, connection).find { |c| 12 | c.id.start_with?(self.id) || self.id.start_with?(c.id) 13 | } 14 | 15 | info.merge!(self.json) 16 | other && info.merge!(other.info) { |key, info_value, other_value| info_value } 17 | self 18 | end 19 | 20 | # Return a List of Hashes that represents the top running processes. 21 | def top(opts = {}) 22 | format = opts.delete(:format) { :array } 23 | resp = Docker::Util.parse_json(connection.get(path_for(:top), opts)) 24 | if resp['Processes'].nil? 25 | format == :array ? [] : {} 26 | else 27 | format == :array ? resp['Processes'].map { |ary| Hash[resp['Titles'].zip(ary)] } : resp 28 | end 29 | end 30 | 31 | # Wait for the current command to finish executing. Default wait time is 32 | # `Excon.options[:read_timeout]`. 33 | def wait(time = nil) 34 | excon_params = { :read_timeout => time } 35 | resp = connection.post(path_for(:wait), nil, excon_params) 36 | Docker::Util.parse_json(resp) 37 | end 38 | 39 | # Given a command and an optional number of seconds to wait for the currently 40 | # executing command, creates a new Container to run the specified command. If 41 | # the command that is currently executing does not return a 0 status code, an 42 | # UnexpectedResponseError is raised. 43 | def run(cmd, time = 1000) 44 | if (code = tap(&:start).wait(time)['StatusCode']).zero? 45 | commit.run(cmd) 46 | else 47 | raise UnexpectedResponseError, "Command returned status code #{code}." 48 | end 49 | end 50 | 51 | # Create an Exec instance inside the container 52 | # 53 | # @param command [String, Array] The command to run inside the Exec instance 54 | # @param options [Hash] The options to pass to Docker::Exec 55 | # 56 | # @return [Docker::Exec] The Exec instance 57 | def exec(command, options = {}, &block) 58 | # Establish values 59 | tty = options.delete(:tty) || false 60 | detach = options.delete(:detach) || false 61 | user = options.delete(:user) 62 | stdin = options.delete(:stdin) 63 | stdout = options.delete(:stdout) || !detach 64 | stderr = options.delete(:stderr) || !detach 65 | wait = options.delete(:wait) 66 | 67 | opts = { 68 | 'Container' => self.id, 69 | 'User' => user, 70 | 'AttachStdin' => !!stdin, 71 | 'AttachStdout' => stdout, 72 | 'AttachStderr' => stderr, 73 | 'Tty' => tty, 74 | 'Cmd' => command 75 | }.merge(options) 76 | 77 | # Create Exec Instance 78 | instance = Docker::Exec.create( 79 | opts, 80 | self.connection 81 | ) 82 | 83 | start_opts = { 84 | :tty => tty, 85 | :stdin => stdin, 86 | :detach => detach, 87 | :wait => wait 88 | } 89 | 90 | if detach 91 | instance.start!(start_opts) 92 | return instance 93 | else 94 | instance.start!(start_opts, &block) 95 | end 96 | end 97 | 98 | # Export the Container as a tar. 99 | def export(&block) 100 | connection.get(path_for(:export), {}, :response_block => block) 101 | self 102 | end 103 | 104 | # Attach to a container's standard streams / logs. 105 | def attach(options = {}, excon_params = {}, &block) 106 | stdin = options.delete(:stdin) 107 | tty = options.delete(:tty) 108 | 109 | opts = { 110 | :stream => true, :stdout => true, :stderr => true 111 | }.merge(options) 112 | # Creates list to store stdout and stderr messages 113 | msgs = Docker::Messages.new 114 | 115 | if stdin 116 | # If attaching to stdin, we must hijack the underlying TCP connection 117 | # so we can stream stdin to the remote Docker process 118 | opts[:stdin] = true 119 | excon_params[:hijack_block] = Docker::Util.hijack_for(stdin, block, 120 | msgs, tty) 121 | else 122 | excon_params[:response_block] = Docker::Util.attach_for(block, msgs, tty) 123 | end 124 | 125 | connection.post( 126 | path_for(:attach), 127 | opts, 128 | excon_params 129 | ) 130 | [msgs.stdout_messages, msgs.stderr_messages] 131 | end 132 | 133 | # Create an Image from a Container's change.s 134 | def commit(options = {}) 135 | options.merge!('container' => self.id[0..7]) 136 | # [code](https://github.com/dotcloud/docker/blob/v0.6.3/commands.go#L1115) 137 | # Based on the link, the config passed as run, needs to be passed as the 138 | # body of the post so capture it, remove from the options, and pass it via 139 | # the post body 140 | config = MultiJson.dump(options.delete('run')) 141 | hash = Docker::Util.parse_json( 142 | connection.post('/commit', options, body: config) 143 | ) 144 | Docker::Image.send(:new, self.connection, hash) 145 | end 146 | 147 | # Return a String representation of the Container. 148 | def to_s 149 | "Docker::Container { :id => #{self.id}, :connection => #{self.connection} }" 150 | end 151 | 152 | # #json returns information about the Container, #changes returns a list of 153 | # the changes the Container has made to the filesystem. 154 | [:json, :changes].each do |method| 155 | define_method(method) do |opts = {}| 156 | Docker::Util.parse_json(connection.get(path_for(method), opts)) 157 | end 158 | end 159 | 160 | def logs(opts = {}) 161 | connection.get(path_for(:logs), opts) 162 | end 163 | 164 | def stats(options = {}) 165 | if block_given? 166 | options[:read_timeout] ||= 10 167 | options[:idempotent] ||= false 168 | parser = lambda do |chunk, remaining_bytes, total_bytes| 169 | yield Docker::Util.parse_json(chunk) 170 | end 171 | begin 172 | connection.get(path_for(:stats), nil, {response_block: parser}.merge(options)) 173 | rescue Docker::Error::TimeoutError 174 | # If the container stops, the docker daemon will hold the connection 175 | # open forever, but stop sending events. 176 | # So this Timeout indicates the stream is over. 177 | end 178 | else 179 | Docker::Util.parse_json(connection.get(path_for(:stats), {stream: 0}.merge(options))) 180 | end 181 | end 182 | 183 | def rename(new_name) 184 | query = {} 185 | query['name'] = new_name 186 | connection.post(path_for(:rename), query) 187 | end 188 | 189 | def update(opts) 190 | connection.post(path_for(:update), {}, body: MultiJson.dump(opts)) 191 | end 192 | 193 | def streaming_logs(opts = {}, &block) 194 | stack_size = opts.delete('stack_size') || opts.delete(:stack_size) || -1 195 | tty = opts.delete('tty') || opts.delete(:tty) || false 196 | msgs = Docker::MessagesStack.new(stack_size) 197 | excon_params = {response_block: Docker::Util.attach_for(block, msgs, tty), idempotent: false} 198 | 199 | connection.get(path_for(:logs), opts, excon_params) 200 | msgs.messages.join 201 | end 202 | 203 | def start!(opts = {}) 204 | connection.post(path_for(:start), {}, body: MultiJson.dump(opts)) 205 | self 206 | end 207 | 208 | def kill!(opts = {}) 209 | connection.post(path_for(:kill), opts) 210 | self 211 | end 212 | 213 | # #start! and #kill! both perform the associated action and 214 | # return the Container. #start and #kill do the same, 215 | # but rescue from ServerErrors. 216 | [:start, :kill].each do |method| 217 | define_method(method) do |*args| 218 | begin; public_send(:"#{method}!", *args); rescue ServerError; self end 219 | end 220 | end 221 | 222 | # #stop! and #restart! both perform the associated action and 223 | # return the Container. #stop and #restart do the same, 224 | # but rescue from ServerErrors. 225 | [:stop, :restart].each do |method| 226 | define_method(:"#{method}!") do |opts = {}| 227 | timeout = opts.delete('timeout') 228 | query = {} 229 | request_options = { 230 | :body => MultiJson.dump(opts) 231 | } 232 | if timeout 233 | query['t'] = timeout 234 | # Ensure request does not timeout before Docker timeout 235 | request_options.merge!( 236 | read_timeout: timeout.to_i + 5, 237 | write_timeout: timeout.to_i + 5 238 | ) 239 | end 240 | connection.post(path_for(method), query, request_options) 241 | self 242 | end 243 | 244 | define_method(method) do |*args| 245 | begin; public_send(:"#{method}!", *args); rescue ServerError; self end 246 | end 247 | end 248 | 249 | # remove container 250 | def remove(options = {}) 251 | connection.delete("/containers/#{self.id}", options) 252 | nil 253 | end 254 | alias_method :delete, :remove 255 | 256 | # pause and unpause containers 257 | # #pause! and #unpause! both perform the associated action and 258 | # return the Container. #pause and #unpause do the same, 259 | # but rescue from ServerErrors. 260 | [:pause, :unpause].each do |method| 261 | define_method(:"#{method}!") do 262 | connection.post path_for(method) 263 | self 264 | end 265 | 266 | define_method(method) do 267 | begin; public_send(:"#{method}!"); rescue ServerError; self; end 268 | end 269 | end 270 | 271 | def archive_out(path, &block) 272 | connection.get( 273 | path_for(:archive), 274 | { 'path' => path }, 275 | :response_block => block 276 | ) 277 | self 278 | end 279 | 280 | def archive_in(inputs, output_path, opts = {}) 281 | file_hash = Docker::Util.file_hash_from_paths([*inputs]) 282 | tar = StringIO.new(Docker::Util.create_tar(file_hash)) 283 | archive_in_stream(output_path, opts) do 284 | tar.read(Excon.defaults[:chunk_size]).to_s 285 | end 286 | end 287 | 288 | def archive_in_stream(output_path, opts = {}, &block) 289 | overwrite = opts[:overwrite] || opts['overwrite'] || false 290 | 291 | connection.put( 292 | path_for(:archive), 293 | { 'path' => output_path, 'noOverwriteDirNonDir' => !overwrite }, 294 | :headers => { 295 | 'Content-Type' => 'application/x-tar' 296 | }, 297 | &block 298 | ) 299 | self 300 | end 301 | 302 | def read_file(path) 303 | content = StringIO.new 304 | archive_out(path) do |chunk| 305 | content.write chunk 306 | end 307 | 308 | content.rewind 309 | 310 | Gem::Package::TarReader.new(content) do |tar| 311 | tar.each do |tarfile| 312 | return tarfile.read 313 | end 314 | end 315 | end 316 | 317 | def store_file(path, file_content) 318 | output_io = StringIO.new( 319 | Docker::Util.create_tar( 320 | path => file_content 321 | ) 322 | ) 323 | 324 | archive_in_stream("/", overwrite: true) { output_io.read } 325 | end 326 | 327 | # Create a new Container. 328 | def self.create(opts = {}, conn = Docker.connection) 329 | query = opts.select {|key| ['name', :name].include?(key) } 330 | clean_opts = opts.reject {|key| ['name', :name].include?(key) } 331 | resp = conn.post('/containers/create', query, :body => MultiJson.dump(clean_opts)) 332 | hash = Docker::Util.parse_json(resp) || {} 333 | new(conn, hash) 334 | end 335 | 336 | # Return the container with specified ID 337 | def self.get(id, opts = {}, conn = Docker.connection) 338 | container_json = conn.get("/containers/#{id}/json", opts) 339 | hash = Docker::Util.parse_json(container_json) || {} 340 | new(conn, hash) 341 | end 342 | 343 | # Return all of the Containers. 344 | def self.all(opts = {}, conn = Docker.connection) 345 | hashes = Docker::Util.parse_json(conn.get('/containers/json', opts)) || [] 346 | hashes.map { |hash| new(conn, hash) } 347 | end 348 | 349 | # Prune images 350 | def self.prune(conn = Docker.connection) 351 | conn.post("/containers/prune", {}) 352 | nil 353 | end 354 | 355 | # Convenience method to return the path for a particular resource. 356 | def path_for(resource) 357 | "/containers/#{self.id}/#{resource}" 358 | end 359 | 360 | private :path_for 361 | private_class_method :new 362 | end 363 | -------------------------------------------------------------------------------- /lib/docker/image.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This class represents a Docker Image. 4 | class Docker::Image 5 | include Docker::Base 6 | 7 | # Given a command and optional list of streams to attach to, run a command on 8 | # an Image. This will not modify the Image, but rather create a new Container 9 | # to run the Image. If the image has an embedded config, no command is 10 | # necessary, but it will fail with 500 if no config is saved with the image 11 | def run(cmd = nil, options = {}) 12 | opts = {'Image' => self.id}.merge(options) 13 | opts["Cmd"] = cmd.is_a?(String) ? cmd.split(/\s+/) : cmd 14 | begin 15 | Docker::Container.create(opts, connection) 16 | .tap(&:start!) 17 | rescue ServerError, ClientError => ex 18 | if cmd 19 | raise ex 20 | else 21 | raise ex, "No command specified." 22 | end 23 | end 24 | end 25 | 26 | # Push the Image to the Docker registry. 27 | def push(creds = nil, options = {}, &block) 28 | repo_tag = options.delete(:repo_tag) || ensure_repo_tags.first 29 | raise ArgumentError, "Image is untagged" if repo_tag.nil? 30 | repo, tag = Docker::Util.parse_repo_tag(repo_tag) 31 | raise ArgumentError, "Image does not have a name to push." if repo.nil? 32 | 33 | body = +"" 34 | credentials = creds || Docker.creds || {} 35 | headers = Docker::Util.build_auth_header(credentials) 36 | opts = {:tag => tag}.merge(options) 37 | connection.post("/images/#{repo}/push", opts, :headers => headers, 38 | :response_block => self.class.response_block(body, &block)) 39 | self 40 | end 41 | 42 | # Tag the Image. 43 | def tag(opts = {}) 44 | self.info['RepoTags'] ||= [] 45 | connection.post(path_for(:tag), opts) 46 | repo = opts['repo'] || opts[:repo] 47 | tag = opts['tag'] || opts[:tag] || 'latest' 48 | self.info['RepoTags'] << "#{repo}:#{tag}" 49 | end 50 | 51 | # Given a path of a local file and the path it should be inserted, creates 52 | # a new Image that has that file. 53 | def insert_local(opts = {}) 54 | local_paths = opts.delete('localPath') 55 | output_path = opts.delete('outputPath') 56 | 57 | local_paths = [ local_paths ] unless local_paths.is_a?(Array) 58 | 59 | file_hash = Docker::Util.file_hash_from_paths(local_paths) 60 | 61 | file_hash['Dockerfile'] = dockerfile_for(file_hash, output_path) 62 | 63 | tar = Docker::Util.create_tar(file_hash) 64 | body = connection.post('/build', opts, :body => tar) 65 | self.class.send(:new, connection, 'id' => Docker::Util.extract_id(body)) 66 | end 67 | 68 | # Remove the Image from the server. 69 | def remove(opts = {}) 70 | name = opts.delete(:name) 71 | 72 | unless name 73 | if ::Docker.podman?(connection) 74 | name = self.id.split(':').last 75 | else 76 | name = self.id 77 | end 78 | end 79 | 80 | connection.delete("/images/#{name}", opts) 81 | end 82 | alias_method :delete, :remove 83 | 84 | # Return a String representation of the Image. 85 | def to_s 86 | "Docker::Image { :id => #{self.id}, :info => #{self.info.inspect}, "\ 87 | ":connection => #{self.connection} }" 88 | end 89 | 90 | # #json returns extra information about an Image, #history returns its 91 | # history. 92 | [:json, :history].each do |method| 93 | define_method(method) do |opts = {}| 94 | Docker::Util.parse_json(connection.get(path_for(method), opts)) 95 | end 96 | end 97 | 98 | # Save the image as a tarball 99 | def save(filename = nil) 100 | self.class.save(self.id, filename, connection) 101 | end 102 | 103 | # Save the image as a tarball to an IO object. 104 | def save_stream(opts = {}, &block) 105 | self.class.save_stream(self.id, opts, connection, &block) 106 | end 107 | 108 | # Update the @info hash, which is the only mutable state in this object. 109 | def refresh! 110 | img = Docker::Image.all({:all => true}, connection).find { |image| 111 | image.id.start_with?(self.id) || self.id.start_with?(image.id) 112 | } 113 | info.merge!(self.json) 114 | img && info.merge!(img.info) 115 | self 116 | end 117 | 118 | class << self 119 | 120 | # Create a new Image. 121 | def create(opts = {}, creds = nil, conn = Docker.connection, &block) 122 | credentials = creds.nil? ? Docker.creds : MultiJson.dump(creds) 123 | headers = credentials && Docker::Util.build_auth_header(credentials) || {} 124 | body = +'' 125 | conn.post( 126 | '/images/create', 127 | opts, 128 | :headers => headers, 129 | :response_block => response_block(body, &block) 130 | ) 131 | # NOTE: see associated tests for why we're looking at image#end_with? 132 | image = opts['fromImage'] || opts[:fromImage] 133 | tag = opts['tag'] || opts[:tag] 134 | image = "#{image}:#{tag}" if tag && !image.end_with?(":#{tag}") 135 | get(image, {}, conn) 136 | end 137 | 138 | # Return a specific image. 139 | def get(id, opts = {}, conn = Docker.connection) 140 | image_json = conn.get("/images/#{id}/json", opts) 141 | hash = Docker::Util.parse_json(image_json) || {} 142 | new(conn, hash) 143 | end 144 | 145 | # Delete a specific image 146 | def remove(id, opts = {}, conn = Docker.connection) 147 | conn.delete("/images/#{id}", opts) 148 | end 149 | alias_method :delete, :remove 150 | 151 | # Prune images 152 | def prune(conn = Docker.connection) 153 | conn.post("/images/prune", {}) 154 | end 155 | 156 | 157 | # Save the raw binary representation or one or more Docker images 158 | # 159 | # @param names [String, Array#String] The image(s) you wish to save 160 | # @param filename [String] The file to export the data to. 161 | # @param conn [Docker::Connection] The Docker connection to use 162 | # 163 | # @return [NilClass, String] If filename is nil, return the string 164 | # representation of the binary data. If the filename is not nil, then 165 | # return nil. 166 | def save(names, filename = nil, conn = Docker.connection) 167 | if filename 168 | File.open(filename, 'wb') do |file| 169 | save_stream(names, {}, conn, &response_block_for_save(file)) 170 | end 171 | nil 172 | else 173 | string = +'' 174 | save_stream(names, {}, conn, &response_block_for_save(string)) 175 | string 176 | end 177 | end 178 | 179 | # Stream the contents of Docker image(s) to a block. 180 | # 181 | # @param names [String, Array#String] The image(s) you wish to save 182 | # @param conn [Docker::Connection] The Docker connection to use 183 | # @yield chunk [String] a chunk of the Docker image(s). 184 | def save_stream(names, opts = {}, conn = Docker.connection, &block) 185 | # By using compare_by_identity we can create a Hash that has 186 | # the same key multiple times. 187 | query = {}.tap(&:compare_by_identity) 188 | Array(names).each { |name| query['names'.dup] = name } 189 | conn.get( 190 | '/images/get', 191 | query, 192 | opts.merge(:response_block => block) 193 | ) 194 | nil 195 | end 196 | 197 | # Load a tar Image 198 | def load(tar, opts = {}, conn = Docker.connection, creds = nil, &block) 199 | headers = build_headers(creds) 200 | io = tar.is_a?(String) ? File.open(tar, 'rb') : tar 201 | body = +"" 202 | conn.post( 203 | '/images/load', 204 | opts, 205 | :headers => headers, 206 | :response_block => response_block(body, &block) 207 | ) { io.read(Excon.defaults[:chunk_size]).to_s } 208 | end 209 | 210 | # Check if an image exists. 211 | def exist?(id, opts = {}, conn = Docker.connection) 212 | get(id, opts, conn) 213 | true 214 | rescue Docker::Error::NotFoundError 215 | false 216 | end 217 | 218 | # Return every Image. 219 | def all(opts = {}, conn = Docker.connection) 220 | hashes = Docker::Util.parse_json(conn.get('/images/json', opts)) || [] 221 | hashes.map { |hash| new(conn, hash) } 222 | end 223 | 224 | # Given a query like `{ :term => 'sshd' }`, queries the Docker Registry for 225 | # a corresponding Image. 226 | def search(query = {}, connection = Docker.connection, creds = nil) 227 | credentials = creds.nil? ? Docker.creds : creds.to_json 228 | headers = credentials && Docker::Util.build_auth_header(credentials) || {} 229 | body = connection.get( 230 | '/images/search', 231 | query, 232 | :headers => headers, 233 | ) 234 | hashes = Docker::Util.parse_json(body) || [] 235 | hashes.map { |hash| new(connection, 'id' => hash['name']) } 236 | end 237 | 238 | # Import an Image from the output of Docker::Container#export. The first 239 | # argument may either be a File or URI. 240 | def import(imp, opts = {}, conn = Docker.connection) 241 | require 'open-uri' 242 | 243 | # This differs after Ruby 2.4 244 | if URI.public_methods.include?(:open) 245 | munged_open = URI.method(:open) 246 | else 247 | munged_open = self.method(:open) 248 | end 249 | 250 | munged_open.call(imp) do |io| 251 | import_stream(opts, conn) do 252 | io.read(Excon.defaults[:chunk_size]).to_s 253 | end 254 | end 255 | rescue StandardError 256 | raise Docker::Error::IOError, "Could not import '#{imp}'" 257 | end 258 | 259 | def import_stream(options = {}, connection = Docker.connection, &block) 260 | body = connection.post( 261 | '/images/create', 262 | options.merge('fromSrc' => '-'), 263 | :headers => { 'Content-Type' => 'application/tar', 264 | 'Transfer-Encoding' => 'chunked' }, 265 | &block 266 | ) 267 | new(connection, 'id'=> Docker::Util.parse_json(body)['status']) 268 | end 269 | 270 | # Given a Dockerfile as a string, builds an Image. 271 | def build(commands, opts = {}, connection = Docker.connection, &block) 272 | body = +"" 273 | connection.post( 274 | '/build', opts, 275 | :body => Docker::Util.create_tar('Dockerfile' => commands), 276 | :response_block => response_block(body, &block) 277 | ) 278 | new(connection, 'id' => Docker::Util.extract_id(body)) 279 | rescue Docker::Error::ServerError 280 | raise Docker::Error::UnexpectedResponseError 281 | end 282 | 283 | # Given File like object containing a tar file, builds an Image. 284 | # 285 | # If a block is passed, chunks of output produced by Docker will be passed 286 | # to that block. 287 | def build_from_tar(tar, opts = {}, connection = Docker.connection, 288 | creds = nil, &block) 289 | 290 | headers = build_headers(creds) 291 | 292 | # The response_block passed to Excon will build up this body variable. 293 | body = +"" 294 | connection.post( 295 | '/build', opts, 296 | :headers => headers, 297 | :response_block => response_block(body, &block) 298 | ) { tar.read(Excon.defaults[:chunk_size]).to_s } 299 | 300 | new(connection, 301 | 'id' => Docker::Util.extract_id(body), 302 | :headers => headers) 303 | end 304 | 305 | # Given a directory that contains a Dockerfile, builds an Image. 306 | # 307 | # If a block is passed, chunks of output produced by Docker will be passed 308 | # to that block. 309 | def build_from_dir(dir, opts = {}, connection = Docker.connection, 310 | creds = nil, &block) 311 | 312 | tar = Docker::Util.create_dir_tar(dir) 313 | build_from_tar tar, opts, connection, creds, &block 314 | ensure 315 | unless tar.nil? 316 | tar.close 317 | FileUtils.rm(tar.path, force: true) 318 | end 319 | end 320 | end 321 | 322 | private 323 | 324 | # A method to build the config header and merge it into the 325 | # headers sent by build_from_dir. 326 | def self.build_headers(creds=nil) 327 | credentials = creds || Docker.creds || {} 328 | config_header = Docker::Util.build_config_header(credentials) 329 | 330 | headers = { 'Content-Type' => 'application/tar', 331 | 'Transfer-Encoding' => 'chunked' } 332 | headers = headers.merge(config_header) if config_header 333 | headers 334 | end 335 | 336 | # Convenience method to return the path for a particular resource. 337 | def path_for(resource) 338 | "/images/#{self.id}/#{resource}" 339 | end 340 | 341 | 342 | # Convience method to get the Dockerfile for a file hash and a path to 343 | # output to. 344 | def dockerfile_for(file_hash, output_path) 345 | dockerfile = +"from #{self.id}\n" 346 | 347 | file_hash.keys.each do |basename| 348 | dockerfile << "add #{basename} #{output_path}\n" 349 | end 350 | 351 | dockerfile 352 | end 353 | 354 | def ensure_repo_tags 355 | refresh! unless info.has_key?('RepoTags') 356 | info['RepoTags'] 357 | end 358 | 359 | # Generates the block to be passed as a reponse block to Excon. The returned 360 | # lambda will append Docker output to the first argument, and yield output to 361 | # the passed block, if a block is given. 362 | def self.response_block(body) 363 | lambda do |chunk, remaining, total| 364 | body << chunk 365 | yield chunk if block_given? 366 | end 367 | end 368 | 369 | # Generates the block to be passed in to the save request. This lambda will 370 | # append the streaming data to the file provided. 371 | def self.response_block_for_save(file) 372 | lambda do |chunk, remianing, total| 373 | file << chunk 374 | end 375 | end 376 | end 377 | -------------------------------------------------------------------------------- /spec/docker/image_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | SingleCov.covered! uncovered: 16 6 | 7 | describe Docker::Image do 8 | describe '#to_s' do 9 | subject { described_class.new(Docker.connection, info) } 10 | 11 | let(:id) { 'bf119e2' } 12 | let(:connection) { Docker.connection } 13 | 14 | let(:info) do 15 | {"id" => "bf119e2", "Repository" => "debian", "Tag" => "stable", 16 | "Created" => 1364102658, "Size" => 24653, "VirtualSize" => 180116135} 17 | end 18 | 19 | let(:expected_string) do 20 | "Docker::Image { :id => #{id}, :info => #{info.inspect}, "\ 21 | ":connection => #{connection} }" 22 | end 23 | 24 | its(:to_s) { should == expected_string } 25 | end 26 | 27 | describe '#remove' do 28 | context 'when no name is given' do 29 | let(:id) { subject.id } 30 | subject { described_class.create('fromImage' => 'busybox:uclibc') } 31 | after { described_class.create('fromImage' => 'busybox:uclibc') } 32 | 33 | it 'removes the Image' do 34 | subject.remove(:force => true) 35 | expect(Docker::Image.all.map(&:id)).to_not include(id) 36 | end 37 | end 38 | 39 | context 'when using the class' do 40 | let(:id) { subject.id } 41 | subject { described_class.create('fromImage' => 'busybox:uclibc') } 42 | after { described_class.create('fromImage' => 'busybox:uclibc') } 43 | 44 | it 'removes the Image' do 45 | Docker::Image.remove(id, force: true) 46 | expect(Docker::Image.all.map(&:id)).to_not include(id) 47 | end 48 | end 49 | 50 | context 'when a valid tag is given' do 51 | it 'untags the Image' 52 | end 53 | 54 | context 'when an invalid tag is given' do 55 | it 'raises an error' 56 | end 57 | end 58 | 59 | describe '#insert_local' do 60 | include_context "local paths" 61 | 62 | subject { described_class.create('fromImage' => 'debian:stable') } 63 | 64 | let(:rm) { false } 65 | let(:new_image) { 66 | opts = {'localPath' => file, 'outputPath' => '/'} 67 | opts[:rm] = true if rm 68 | subject.insert_local(opts) 69 | } 70 | 71 | context 'when the local file does not exist' do 72 | let(:file) { '/lol/not/a/file' } 73 | 74 | it 'raises an error' do 75 | expect { new_image }.to raise_error(Docker::Error::ArgumentError) 76 | end 77 | end 78 | 79 | context 'when the local file does exist' do 80 | let(:file) { File.join(project_dir, 'Gemfile') } 81 | let(:gemfile) { File.read('Gemfile') } 82 | let(:container) { new_image.run('cat /Gemfile').tap(&:wait) } 83 | after do 84 | container.remove 85 | new_image.remove 86 | end 87 | 88 | it 'creates a new Image that has that file' do 89 | begin 90 | output = container.streaming_logs(stdout: true) 91 | expect(output).to eq(gemfile) 92 | rescue Docker::Error::UnexpectedResponseError => ex 93 | skip("Could not communicate with DockerHub: #{ex}") 94 | end 95 | end 96 | end 97 | 98 | context 'when a directory is passed' do 99 | let(:new_image) { 100 | subject.insert_local( 101 | 'localPath' => File.join(project_dir, 'lib'), 102 | 'outputPath' => '/lib' 103 | ) 104 | } 105 | let(:container) { new_image.run('ls -a /lib/docker') } 106 | let(:response) { container.tap(&:wait).streaming_logs(stdout: true) } 107 | after do 108 | container.tap(&:wait).remove 109 | new_image.remove 110 | end 111 | 112 | it 'inserts the directory' do 113 | begin 114 | expect(response.split("\n").sort).to eq(Dir.entries('lib/docker').sort) 115 | rescue Docker::Error::UnexpectedResponseError => ex 116 | skip("Could not communicate with DockerHub: #{ex}") 117 | end 118 | end 119 | end 120 | 121 | context 'when there are multiple files passed' do 122 | let(:file) { 123 | [File.join(project_dir, 'Gemfile'), File.join(project_dir, 'LICENSE')] 124 | } 125 | let(:gemfile) { File.read('Gemfile') } 126 | let(:license) { File.read('LICENSE') } 127 | let(:container) { new_image.run('cat /Gemfile /LICENSE') } 128 | let(:response) { 129 | container.tap(&:wait).streaming_logs(stdout: true) 130 | } 131 | after do 132 | container.remove 133 | new_image.remove 134 | end 135 | 136 | it 'creates a new Image that has each file' do 137 | begin 138 | expect(response).to eq("#{gemfile}#{license}") 139 | rescue Docker::Error::UnexpectedResponseError => ex 140 | skip("Could not communicate with DockerHub: #{ex}") 141 | end 142 | end 143 | end 144 | 145 | context 'when removing intermediate containers' do 146 | let(:rm) { true } 147 | let(:file) { File.join(project_dir, 'Gemfile') } 148 | after(:each) { new_image.remove } 149 | 150 | it 'leave no intermediate containers' do 151 | begin 152 | expect { new_image }.to change { 153 | Docker::Container.all(:all => true).count 154 | }.by 0 155 | rescue Docker::Error::UnexpectedResponseError => ex 156 | skip("Could not communicate with DockerHub: #{ex}") 157 | end 158 | end 159 | 160 | it 'creates a new image' do 161 | begin 162 | expect{new_image}.to change{Docker::Image.all.count}.by 1 163 | rescue Docker::Error::UnexpectedResponseError => ex 164 | skip("Could not communicate with DockerHub: #{ex}") 165 | end 166 | end 167 | end 168 | end 169 | 170 | describe '#push' do 171 | let(:credentials) { 172 | { 173 | 'username' => ENV['DOCKER_API_USER'], 174 | 'password' => ENV['DOCKER_API_PASS'], 175 | 'serveraddress' => 'https://index.docker.io/v1', 176 | 'email' => ENV['DOCKER_API_EMAIL'] 177 | } 178 | } 179 | let(:repo_tag) { "#{ENV['DOCKER_API_USER']}/true" } 180 | let(:image) { 181 | described_class.build("FROM tianon/true\n", "t" => repo_tag).refresh! 182 | } 183 | after { image.remove(:name => repo_tag, :noprune => true) } 184 | 185 | it 'pushes the Image' do 186 | skip_without_auth 187 | image.push(credentials) 188 | end 189 | 190 | it 'streams output from push' do 191 | skip_without_auth 192 | expect { |b| image.push(credentials, &b) } 193 | .to yield_control.at_least(1) 194 | end 195 | 196 | context 'when a tag is specified' do 197 | it 'pushes that specific tag' 198 | end 199 | 200 | context 'when the image was retrived by get' do 201 | let(:image) { 202 | described_class.build("FROM tianon/true\n", "t" => repo_tag).refresh! 203 | described_class.get(repo_tag) 204 | } 205 | 206 | context 'when no tag is specified' do 207 | it 'looks up the first repo tag' do 208 | skip_without_auth 209 | expect { image.push }.to_not raise_error 210 | end 211 | end 212 | end 213 | 214 | context 'when there are no credentials' do 215 | let(:credentials) { nil } 216 | let(:repo_tag) { "localhost:5000/true" } 217 | 218 | it 'still pushes' do 219 | begin 220 | image.push 221 | rescue => ex 222 | if ex.message =~ /connection refused/ 223 | skip("Registry at #{repo_tag} is not available") 224 | else 225 | expect { raise(ex) }.to_not raise_error 226 | end 227 | end 228 | end 229 | end 230 | end 231 | 232 | describe '#tag' do 233 | subject { described_class.create('fromImage' => 'debian:stable') } 234 | after { subject.remove(:name => 'teh:latest', :noprune => true) } 235 | 236 | it 'tags the image with the repo name' do 237 | subject.tag(:repo => :teh, :force => true) 238 | expect(subject.info['RepoTags']).to include 'teh:latest' 239 | end 240 | end 241 | 242 | describe '#json' do 243 | before { skip_without_auth } 244 | 245 | subject { described_class.create('fromImage' => 'debian:stable') } 246 | let(:json) { subject.json } 247 | 248 | it 'returns additional information about image image' do 249 | expect(json).to be_a Hash 250 | expect(json.length).to_not be_zero 251 | end 252 | end 253 | 254 | describe '#history' do 255 | subject { described_class.create('fromImage' => 'debian:stable') } 256 | let(:history) { subject.history } 257 | 258 | it 'returns the history of the Image' do 259 | expect(history).to be_a Array 260 | expect(history.length).to_not be_zero 261 | expect(history).to be_all { |elem| elem.is_a? Hash } 262 | end 263 | end 264 | 265 | describe '#run' do 266 | let(:cmd) { nil } 267 | let(:options) { {} } 268 | 269 | subject do 270 | described_class.create( 271 | {'fromImage' => 'debian:stable'}) 272 | end 273 | 274 | let(:container) { subject.run(cmd, options).tap(&:wait) } 275 | let(:output) { container.streaming_logs(stdout: true) } 276 | 277 | context 'when cmd is a String' do 278 | let(:cmd) { 'ls /lib64/' } 279 | after { container.remove } 280 | 281 | it 'splits the String by spaces and creates a new Container' do 282 | expect(output).to eq("ld-linux-x86-64.so.2\n") 283 | end 284 | end 285 | 286 | context 'when cmd is an Array' do 287 | let(:cmd) { %w[which pwd] } 288 | after { container.remove } 289 | 290 | it 'creates a new Container' do 291 | expect(output).to eq("/usr/bin/pwd\n") 292 | end 293 | end 294 | 295 | context 'when cmd is nil', docker_1_12: true do 296 | let(:cmd) { nil } 297 | 298 | context 'no command configured in image' do 299 | subject { described_class.create('fromImage' => 'swipely/base') } 300 | xit 'should raise an error if no command is specified' do 301 | begin 302 | container 303 | rescue => ex 304 | expect([Docker::Error::ServerError, Docker::Error::ClientError]).to include(ex.class) 305 | expect(ex.message).to match(/No\ command\ specified/) 306 | end 307 | end 308 | end 309 | end 310 | 311 | context "command configured in image" do 312 | let(:cmd) { 'pwd' } 313 | after { container.remove } 314 | 315 | it 'should normally show result if image has Cmd configured' do 316 | expect(output).to eql "/\n" 317 | end 318 | end 319 | 320 | context 'when using cpu shares' do 321 | let(:options) { { 'CpuShares' => 50 } } 322 | after { container.remove } 323 | 324 | # Not working with Docker 27, will determine later when time allows 325 | xit 'returns 50' do 326 | skip('Not supported on podman') if ::Docker.podman? 327 | expect(container.json["HostConfig"]["CpuShares"]).to eq 50 328 | end 329 | end 330 | end 331 | 332 | describe '#save' do 333 | let(:image) { Docker::Image.get('busybox:uclibc') } 334 | 335 | it 'calls the class method' do 336 | expect(Docker::Image).to receive(:save) 337 | .with(image.id, 'busybox.tar', anything) 338 | image.save('busybox.tar') 339 | end 340 | end 341 | 342 | describe '#save_stream' do 343 | let(:image) { Docker::Image.get('busybox:uclibc') } 344 | let(:block) { proc { |chunk| puts chunk } } 345 | 346 | it 'calls the class method' do 347 | expect(Docker::Image).to receive(:save_stream) 348 | .with(image.id, instance_of(Hash), instance_of(Docker::Connection)) 349 | image.save_stream(:chunk_size => 1024 * 1024, &block) 350 | end 351 | end 352 | 353 | describe '#refresh!' do 354 | let(:image) { Docker::Image.create('fromImage' => 'debian:stable') } 355 | 356 | it 'updates the @info hash' do 357 | size = image.info.size 358 | image.refresh! 359 | expect(image.info.size).to be > size 360 | end 361 | 362 | context 'with an explicit connection' do 363 | let(:connection) { Docker::Connection.new(Docker.url, Docker.options) } 364 | let(:image) { 365 | Docker::Image.create({'fromImage' => 'debian:stable'}, nil, connection) 366 | } 367 | 368 | it 'updates using the provided connection' do 369 | image.refresh! 370 | end 371 | end 372 | end 373 | 374 | describe '.load' do 375 | include_context "local paths" 376 | let(:file) { File.join(project_dir, 'spec', 'fixtures', 'load.tar') } 377 | 378 | context 'when the argument is a String' do 379 | it 'loads tianon/true image from the file system' do 380 | result = Docker::Image.load(file) 381 | expect(result).to eq("") 382 | end 383 | end 384 | 385 | context 'when the argument is an IO' do 386 | let(:io) { File.open(file) } 387 | 388 | after { io.close } 389 | 390 | it 'loads tinan/true image from the IO' do 391 | result = Docker::Image.load(io) 392 | expect(result).to eq("") 393 | end 394 | end 395 | end 396 | 397 | describe '.create' do 398 | subject { described_class } 399 | 400 | context 'when the Image does not yet exist and the body is a Hash' do 401 | let(:image) { subject.create('fromImage' => 'swipely/base') } 402 | let(:creds) { 403 | { 404 | :username => ENV['DOCKER_API_USER'], 405 | :password => ENV['DOCKER_API_PASS'], 406 | :email => ENV['DOCKER_API_EMAIL'] 407 | } 408 | } 409 | 410 | before do 411 | skip_without_auth 412 | Docker::Image.create('fromImage' => 'swipely/base').remove 413 | end 414 | after { Docker::Image.create('fromImage' => 'swipely/base') } 415 | 416 | it 'sets the id and sends Docker.creds' do 417 | allow(Docker).to receive(:creds).and_return(creds) 418 | expect(image).to be_a Docker::Image 419 | expect(image.id).to match(/\A(sha256:)?[a-fA-F0-9]+\Z/) 420 | expect(image.id).to_not include('base') 421 | expect(image.id).to_not be_nil 422 | expect(image.id).to_not be_empty 423 | end 424 | end 425 | 426 | context 'image with tag' do 427 | it 'pulls the image (string arguments)' do 428 | image = subject.create('fromImage' => 'busybox', 'tag' => 'uclibc') 429 | image.refresh! 430 | expect(image.info['RepoTags']).to include(/busybox:uclibc$/) 431 | end 432 | 433 | it 'pulls the image (symbol arguments)' do 434 | image = subject.create(fromImage: 'busybox', tag: 'uclibc') 435 | image.refresh! 436 | expect(image.info['RepoTags']).to include(/busybox:uclibc$/) 437 | end 438 | 439 | it 'supports identical fromImage and tag', docker_1_10: true do 440 | # This is here for backwards compatibility. docker-api used to 441 | # complete ignore the "tag" argument, which Docker itself prioritizes 442 | # over a tag found in fromImage, which meant that we had 3 scenarios: 443 | # 444 | # 1 fromImage does not include a tag, and the tag argument is provided 445 | # and isn't the default (i.e. "latest"): docker-api crashes looking 446 | # for fromImage when the image that was pulled is fromImage:tag (or 447 | # returns the wrong image if fromImage:latest exists) 448 | # 2 fromImage does not a include a tag, and the tag argument is absent 449 | # or default (i.e. "latest"): docker-api finds the right image. 450 | # 3 fromImage includes a tag, and the tag argument is absent: docker-api 451 | # also finds the right image. 452 | # 4 fromImage includes a tag, and the tag argument is present: works if 453 | # the tag is the same in both. 454 | # 455 | # Adding support for the tag argument to fix 1 above means we'd break 4 456 | # if we didn't explicitly handle the case where both tags are identical. 457 | # This is what this test checks. 458 | # 459 | # Note that providing the tag inline in fromImage is only supported in 460 | # Docker 1.10 and up. 461 | skip('Not supported on podman') if ::Docker.podman? 462 | image = subject.create(fromImage: 'busybox:uclibc', tag: 'uclibc') 463 | image.refresh! 464 | expect(image.info['RepoTags']).to include('busybox:uclibc') 465 | end 466 | end 467 | 468 | context 'with a block capturing create output' do 469 | let(:create_output) { +"" } 470 | let(:block) { Proc.new { |chunk| create_output << chunk } } 471 | 472 | before do 473 | Docker.creds = nil 474 | subject.create('fromImage' => 'busybox:uclibc').remove(force: true) 475 | end 476 | 477 | it 'calls the block and passes build output' do 478 | subject.create('fromImage' => 'busybox:uclibc', &block) 479 | expect(create_output).to match(/ulling.*busybox/) 480 | end 481 | end 482 | end 483 | 484 | describe '.get' do 485 | subject { described_class } 486 | let(:image) { subject.get(image_name) } 487 | 488 | context 'when the image does exist' do 489 | let(:image_name) { 'debian:stable' } 490 | 491 | it 'returns the new image' do 492 | expect(image).to be_a Docker::Image 493 | end 494 | end 495 | 496 | context 'when the image does not exist' do 497 | let(:image_name) { 'abcdefghijkl' } 498 | 499 | before do 500 | Docker.options = { :mock => true } 501 | Excon.stub({ :method => :get }, { :status => 404 }) 502 | end 503 | 504 | after do 505 | Docker.options = {} 506 | Excon.stubs.shift 507 | end 508 | 509 | it 'raises a not found error' do 510 | expect { image }.to raise_error(Docker::Error::NotFoundError) 511 | end 512 | end 513 | end 514 | 515 | describe '.save' do 516 | include_context "local paths" 517 | 518 | context 'when a filename is specified' do 519 | let(:file) { "#{project_dir}/scratch.tar" } 520 | after { FileUtils.remove(file) } 521 | 522 | it 'exports tarball of image to specified file' do 523 | Docker::Image.save('swipely/base', file) 524 | expect(File.exist?(file)).to eq true 525 | expect(File.read(file)).to_not be_nil 526 | end 527 | end 528 | 529 | context 'when no filename is specified' do 530 | it 'returns raw binary data as string' do 531 | raw = Docker::Image.save('swipely/base') 532 | expect(raw).to_not be_nil 533 | end 534 | end 535 | end 536 | 537 | describe '.save_stream' do 538 | let(:image) { 'busybox:uclibc' } 539 | let(:non_streamed) do 540 | Docker.connection.get('/images/get', 'names' => image) 541 | end 542 | let(:streamed) { +'' } 543 | let(:tar_files) do 544 | proc do |string| 545 | Gem::Package::TarReader 546 | .new(StringIO.new(string, 'rb')) 547 | .map(&:full_name) 548 | .sort 549 | end 550 | end 551 | 552 | it 'yields each chunk of the image' do 553 | Docker::Image.save_stream(image) { |chunk| streamed << chunk } 554 | expect(tar_files.call(streamed)).to eq(tar_files.call(non_streamed)) 555 | end 556 | end 557 | 558 | describe '.exist?' do 559 | subject { described_class } 560 | let(:exists) { subject.exist?(image_name) } 561 | 562 | context 'when the image does exist' do 563 | let(:image_name) { 'debian:stable' } 564 | 565 | it 'returns true' do 566 | expect(exists).to eq(true) 567 | end 568 | end 569 | 570 | context 'when the image does not exist' do 571 | let(:image_name) { 'abcdefghijkl' } 572 | 573 | before do 574 | Docker.options = { :mock => true } 575 | Excon.stub({ :method => :get }, { :status => 404 }) 576 | end 577 | 578 | after do 579 | Docker.options = {} 580 | Excon.stubs.shift 581 | end 582 | 583 | it 'return false' do 584 | expect(exists).to eq(false) 585 | end 586 | end 587 | end 588 | 589 | describe '.import' do 590 | include_context "local paths" 591 | 592 | subject { described_class } 593 | 594 | context 'when the file does not exist' do 595 | let(:file) { '/lol/not/a/file' } 596 | 597 | it 'raises an error' do 598 | expect { subject.import(file) } 599 | .to raise_error(Docker::Error::IOError) 600 | end 601 | end 602 | 603 | context 'when the file does exist' do 604 | let(:file) { File.join(project_dir, 'spec', 'fixtures', 'export.tar') } 605 | let(:import) { subject.import(file) } 606 | after { import.remove(:noprune => true) } 607 | 608 | it 'creates the Image' do 609 | expect(import).to be_a Docker::Image 610 | expect(import.id).to_not be_nil 611 | end 612 | end 613 | 614 | context 'when the argument is a URI' do 615 | context 'when the URI is invalid' do 616 | it 'raises an error' do 617 | expect { subject.import('http://google.com') } 618 | .to raise_error(Docker::Error::IOError) 619 | end 620 | end 621 | 622 | context 'when the URI is valid' do 623 | let(:uri) { 'http://swipely-pub.s3.amazonaws.com/tianon_true.tar' } 624 | let(:import) { subject.import(uri) } 625 | after { import.remove(:noprune => true) } 626 | 627 | it 'returns an Image' do 628 | expect(import).to be_a Docker::Image 629 | expect(import.id).to_not be_nil 630 | end 631 | end 632 | end 633 | end 634 | 635 | describe '.all' do 636 | subject { described_class } 637 | 638 | let(:images) { subject.all(:all => true) } 639 | before { subject.create('fromImage' => 'debian:stable') } 640 | 641 | it 'materializes each Image into a Docker::Image' do 642 | images.each do |image| 643 | expect(image).to_not be_nil 644 | 645 | expect(image).to be_a(described_class) 646 | 647 | expect(image.id).to_not be_nil 648 | 649 | %w(Created Size).each do |key| 650 | expect(image.info).to have_key(key) 651 | end 652 | end 653 | 654 | expect(images.length).to_not be_zero 655 | end 656 | end 657 | 658 | describe '.prune', :docker_17_03 => true do 659 | it 'prune images' do 660 | expect { Docker::Image.prune }.not_to raise_error 661 | end 662 | end 663 | 664 | unless ::Docker.podman? 665 | describe '.search' do 666 | subject { described_class } 667 | 668 | it 'materializes each Image into a Docker::Image' do 669 | expect(subject.search('term' => 'sshd')).to be_all { |image| 670 | !image.id.nil? && image.is_a?(described_class) 671 | } 672 | end 673 | end 674 | end 675 | 676 | describe '.build' do 677 | subject { described_class } 678 | context 'with an invalid Dockerfile' do 679 | if ::Docker.podman? 680 | it 'throws a UnexpectedResponseError' do 681 | expect { subject.build('lololol') } 682 | .to raise_error(Docker::Error::UnexpectedResponseError) 683 | end 684 | else 685 | it 'throws a UnexpectedResponseError', docker_17_09: false do 686 | expect { subject.build('lololol') } 687 | .to raise_error(Docker::Error::ClientError) 688 | end 689 | 690 | it 'throws a ClientError', docker_17_09: true do 691 | expect { subject.build('lololol') } 692 | .to raise_error(Docker::Error::ClientError) 693 | end 694 | end 695 | end 696 | 697 | context 'with a valid Dockerfile' do 698 | context 'without query parameters' do 699 | let(:image) { subject.build("FROM debian:stable\n") } 700 | 701 | it 'builds an image' do 702 | expect(image).to be_a Docker::Image 703 | expect(image.id).to_not be_nil 704 | expect(image.connection).to be_a Docker::Connection 705 | end 706 | end 707 | 708 | context 'with specifying a repo in the query parameters' do 709 | let(:image) { 710 | subject.build( 711 | "FROM debian:stable\nRUN true\n", 712 | "t" => "#{ENV['DOCKER_API_USER']}/debian:true" 713 | ) 714 | } 715 | after { image.remove(:noprune => true) } 716 | 717 | it 'builds an image and tags it' do 718 | expect(image).to be_a Docker::Image 719 | expect(image.id).to_not be_nil 720 | expect(image.connection).to be_a Docker::Connection 721 | image.refresh! 722 | expect(image.info["RepoTags"].size).to eq(1) 723 | expect(image.info["RepoTags"].first).to match(%r{#{ENV['DOCKER_API_USER']}/debian:true}) 724 | end 725 | end 726 | 727 | context 'with a block capturing build output' do 728 | let(:build_output) { +"" } 729 | let(:block) { Proc.new { |chunk| build_output << chunk } } 730 | let!(:image) { subject.build("FROM debian:stable\n", &block) } 731 | 732 | it 'calls the block and passes build output' do 733 | expect(build_output).to match(/(Step|STEP) \d(\/\d)?\s?: FROM debian:stable/) 734 | end 735 | end 736 | end 737 | end 738 | 739 | describe '.build_from_dir' do 740 | subject { described_class } 741 | 742 | context 'with a valid Dockerfile' do 743 | let(:dir) { 744 | File.join(File.dirname(__FILE__), '..', 'fixtures', 'build_from_dir') 745 | } 746 | let(:docker_file) { File.new("#{dir}/Dockerfile") } 747 | let(:image) { subject.build_from_dir(dir, opts, &block) } 748 | let(:opts) { {} } 749 | let(:block) { Proc.new {} } 750 | let(:container) do 751 | Docker::Container.create( 752 | 'Image' => image.id, 753 | 'Cmd' => %w[cat /Dockerfile] 754 | ).tap(&:start).tap(&:wait) 755 | end 756 | let(:output) { container.streaming_logs(stdout: true) } 757 | 758 | after(:each) do 759 | container.remove 760 | image.remove(:noprune => true) 761 | end 762 | 763 | context 'with no query parameters' do 764 | it 'builds the image' do 765 | expect(output).to eq(docker_file.read) 766 | end 767 | end 768 | 769 | context 'with specifying a repo in the query parameters' do 770 | let(:opts) { { "t" => "#{ENV['DOCKER_API_USER']}/debian:from_dir" } } 771 | it 'builds the image and tags it' do 772 | expect(output).to eq(docker_file.read) 773 | image.refresh! 774 | expect(image.info["RepoTags"].size).to eq(1) 775 | expect(image.info["RepoTags"].first).to match(%r{#{ENV['DOCKER_API_USER']}/debian:from_dir}) 776 | end 777 | end 778 | 779 | context 'with a block capturing build output' do 780 | let(:build_output) { +"" } 781 | let(:block) { Proc.new { |chunk| build_output << chunk } } 782 | 783 | it 'calls the block and passes build output' do 784 | image # Create the image variable, which is lazy-loaded by Rspec 785 | expect(build_output).to match(/(Step|STEP) \d(\/\d)?\s?: FROM debian:stable/) 786 | end 787 | 788 | context 'uses a cached version the second time' do 789 | let(:build_output_two) { +"" } 790 | let(:block_two) { Proc.new { |chunk| build_output_two << chunk } } 791 | let(:image_two) { subject.build_from_dir(dir, opts, &block_two) } 792 | 793 | it 'calls the block and passes build output' do 794 | skip('Not supported on podman') if ::Docker.podman? 795 | image # Create the image variable, which is lazy-loaded by Rspec 796 | expect(build_output).to match(/(Step|STEP) \d(\/\d)?\s?: FROM debian:stable/) 797 | expect(build_output).to_not match(/Using cache/) 798 | 799 | image_two # Create the image_two variable, which is lazy-loaded by Rspec 800 | expect(build_output_two).to match(/Using cache/) 801 | end 802 | end 803 | end 804 | 805 | context 'with credentials passed' do 806 | let(:creds) { 807 | { 808 | :username => ENV['DOCKER_API_USER'], 809 | :password => ENV['DOCKER_API_PASS'], 810 | :email => ENV['DOCKER_API_EMAIL'], 811 | :serveraddress => 'https://index.docker.io/v1' 812 | } 813 | } 814 | 815 | before { Docker.creds = creds } 816 | after { Docker.creds = nil } 817 | 818 | it 'sends X-Registry-Config header' do 819 | expect(image.info[:headers].keys).to include('X-Registry-Config') 820 | end 821 | end 822 | end 823 | end 824 | end 825 | -------------------------------------------------------------------------------- /spec/docker/container_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | SingleCov.covered! uncovered: 39 6 | 7 | describe Docker::Container do 8 | describe '#to_s' do 9 | subject { 10 | described_class.send(:new, Docker.connection, 'id' => rand(10000).to_s) 11 | } 12 | 13 | let(:id) { 'bf119e2' } 14 | let(:connection) { Docker.connection } 15 | let(:expected_string) { 16 | "Docker::Container { :id => #{id}, :connection => #{connection} }" 17 | } 18 | before do 19 | { 20 | :@id => id, 21 | :@connection => connection 22 | }.each { |k, v| subject.instance_variable_set(k, v) } 23 | end 24 | 25 | its(:to_s) { should == expected_string } 26 | end 27 | 28 | describe '#json' do 29 | subject { 30 | described_class.create('Cmd' => %w[true], 'Image' => 'debian:stable') 31 | } 32 | let(:description) { subject.json } 33 | after(:each) { subject.remove } 34 | 35 | it 'returns the description as a Hash' do 36 | expect(description).to be_a Hash 37 | expect(description['Id']).to start_with(subject.id) 38 | end 39 | end 40 | 41 | describe '#streaming_logs' do 42 | let(:options) { {} } 43 | subject do 44 | described_class.create( 45 | {'Cmd' => ['/bin/bash', '-lc', 'echo hello'], 'Image' => 'debian:stable'}.merge(options) 46 | ) 47 | end 48 | 49 | before(:each) { subject.tap(&:start).wait } 50 | after(:each) { subject.remove } 51 | 52 | context 'when not selecting any stream' do 53 | let(:non_destination) { subject.streaming_logs } 54 | it 'raises a client error' do 55 | expect { non_destination }.to raise_error(Docker::Error::ClientError) 56 | end 57 | end 58 | 59 | context 'when selecting stdout' do 60 | let(:stdout) { subject.streaming_logs(stdout: 1) } 61 | it 'returns blank logs' do 62 | expect(stdout).to be_a String 63 | expect(stdout).to match("hello") 64 | end 65 | end 66 | 67 | context 'when using a tty' do 68 | let(:options) { { 'Tty' => true } } 69 | 70 | let(:output) { subject.streaming_logs(stdout: 1, tty: 1) } 71 | it 'returns `hello`' do 72 | expect(output).to be_a(String) 73 | expect(output).to match("hello") 74 | end 75 | end 76 | 77 | context 'when passing a block' do 78 | let(:lines) { [] } 79 | let(:output) { subject.streaming_logs(stdout: 1, follow: 1) { |s,c| lines << c } } 80 | it 'returns `hello`' do 81 | expect(output).to be_a(String) 82 | expect(output).to match("hello") 83 | expect(lines.join).to match("hello") 84 | end 85 | end 86 | end 87 | 88 | describe '#stats', :docker_1_9 do 89 | after(:each) do 90 | subject.wait 91 | subject.remove 92 | end 93 | 94 | context "when requesting container stats" do 95 | subject { 96 | described_class.create('Cmd' => ['echo', 'hello'], 'Image' => 'debian:stable') 97 | } 98 | 99 | let(:output) { subject.stats } 100 | it "returns a Hash" do 101 | skip('Not supported on podman') if ::Docker.podman? 102 | expect(output).to be_a Hash 103 | end 104 | end 105 | 106 | context "when streaming container stats" do 107 | subject { 108 | described_class.create( 109 | 'Cmd' => ['sleep', '3'], 110 | 'Image' => 'debian:stable' 111 | ) 112 | } 113 | 114 | it "yields a Hash" do 115 | skip('Not supported on podman') if ::Docker.podman? 116 | subject.start! # If the container isn't started, no stats will be streamed 117 | called_count = 0 118 | subject.stats do |output| 119 | expect(output).to be_a Hash 120 | called_count += 1 121 | break if called_count == 2 122 | end 123 | expect(called_count).to eq 2 124 | end 125 | end 126 | end 127 | 128 | describe '#logs' do 129 | subject { 130 | described_class.create('Cmd' => ['echo', 'hello'], 'Image' => 'debian:stable') 131 | } 132 | after(:each) { subject.remove } 133 | 134 | context "when not selecting any stream" do 135 | let(:non_destination) { subject.logs } 136 | it 'raises a client error' do 137 | expect { non_destination }.to raise_error(Docker::Error::ClientError) 138 | end 139 | end 140 | 141 | context "when selecting stdout" do 142 | let(:stdout) { subject.logs(stdout: 1) } 143 | it 'returns blank logs' do 144 | expect(stdout).to be_a String 145 | expect(stdout).to eq "" 146 | end 147 | end 148 | end 149 | 150 | describe '#create' do 151 | subject { 152 | described_class.create({ 153 | 'Cmd' => %w[true], 154 | 'Image' => 'debian:stable' 155 | }.merge(opts)) 156 | } 157 | 158 | context 'when creating a container named bob' do 159 | let(:opts) { {"name" => "bob"} } 160 | after(:each) { subject.remove } 161 | 162 | it 'should have name set to bob' do 163 | expect(subject.json["Name"]).to eq("/bob") 164 | end 165 | end 166 | end 167 | 168 | describe '#rename' do 169 | subject { 170 | described_class.create({ 171 | 'name' => 'foo', 172 | 'Cmd' => %w[true], 173 | 'Image' => 'debian:stable' 174 | }) 175 | } 176 | 177 | before { subject.start } 178 | after(:each) { subject.tap(&:wait).remove } 179 | 180 | it 'renames the container' do 181 | skip('Not supported on podman') if ::Docker.podman? 182 | subject.rename('bar') 183 | expect(subject.json["Name"]).to match(%r{bar}) 184 | end 185 | end 186 | 187 | describe "#update", :docker_1_10 do 188 | subject { 189 | described_class.create({ 190 | "name" => "foo", 191 | 'Cmd' => %w[true], 192 | "Image" => "debian:stable", 193 | "HostConfig" => { 194 | "CpuShares" => 60000 195 | } 196 | }) 197 | } 198 | 199 | before { subject.tap(&:start).tap(&:wait) } 200 | after(:each) { subject.tap(&:wait).remove } 201 | 202 | it "updates the container" do 203 | skip('Podman containers are immutable once created') if ::Docker.podman? 204 | subject.refresh! 205 | expect(subject.info.fetch("HostConfig").fetch("CpuShares")).to eq 60000 206 | subject.update("CpuShares" => 50000) 207 | subject.refresh! 208 | expect(subject.info.fetch("HostConfig").fetch("CpuShares")).to eq 50000 209 | end 210 | end 211 | 212 | describe '#changes' do 213 | subject { 214 | described_class.create( 215 | 'Cmd' => %w[rm -rf /root], 216 | 'Image' => 'debian:stable' 217 | ) 218 | } 219 | let(:changes) { subject.changes } 220 | 221 | before { subject.tap(&:start).tap(&:wait) } 222 | after(:each) { subject.tap(&:wait).remove } 223 | 224 | it 'returns the changes as an array' do 225 | expect(changes).to be_a(Array) 226 | expect(changes).to include( 227 | { 228 | "Path" => "/root", 229 | "Kind" => 2 230 | }, 231 | ) 232 | end 233 | end 234 | 235 | describe '#top' do 236 | let(:dir) { 237 | File.join(File.dirname(__FILE__), '..', 'fixtures', 'top') 238 | } 239 | let(:image) { Docker::Image.build_from_dir(dir) } 240 | let(:top_empty) { sleep 1; container.top } 241 | let(:top_ary) { sleep 1; container.top } 242 | let(:top_hash) { sleep 1; container.top(format: :hash) } 243 | let!(:container) { image.run('/while') } 244 | after do 245 | container.kill!.remove 246 | image.remove 247 | end 248 | 249 | it 'returns the top commands as an Array' do 250 | expect(top_ary).to be_a Array 251 | expect(top_ary).to_not be_empty 252 | expect(top_ary.first.keys).to include(/PID/) 253 | end 254 | 255 | it 'returns the top commands as an Hash' do 256 | expect(top_hash).to be_a Hash 257 | expect(top_hash).to_not be_empty 258 | expect(top_hash.keys).to eq ['Processes', 'Titles'] 259 | end 260 | 261 | it 'returns nothing when Processes were not returned due to an error' do 262 | expect(Docker::Util).to receive(:parse_json).and_return({}).at_least(:once) 263 | expect(top_empty).to eq [] 264 | end 265 | end 266 | 267 | describe '#archive_in', :docker_1_8 do 268 | let(:license_path) { File.absolute_path(File.join(__FILE__, '..', '..', '..', 'LICENSE')) } 269 | subject { Docker::Container.create('Image' => 'debian:stable', 'Cmd' => ['/bin/sh']) } 270 | let(:committed_image) { subject.commit } 271 | let(:ls_container) { committed_image.run('ls /').tap(&:wait) } 272 | let(:output) { ls_container.streaming_logs(stdout: true, stderr: true) } 273 | 274 | after do 275 | subject.remove 276 | end 277 | 278 | context 'when the input is a tar' do 279 | after do 280 | ls_container.remove 281 | committed_image.remove 282 | end 283 | 284 | it 'file exists in the container' do 285 | skip('Not supported on podman') if ::Docker.podman? 286 | subject.archive_in(license_path, '/', overwrite: false) 287 | expect(output).to include('LICENSE') 288 | end 289 | end 290 | end 291 | 292 | describe '#archive_in_stream', :docker_1_8 do 293 | let(:tar) { StringIO.new(Docker::Util.create_tar('/lol' => 'TEST')) } 294 | subject { Docker::Container.create('Image' => 'debian:stable', 'Cmd' => ['/bin/sh']) } 295 | let(:committed_image) { subject.commit } 296 | let(:ls_container) { committed_image.run('ls /').tap(&:wait) } 297 | let(:output) { ls_container.streaming_logs(stdout: true, stderr: true) } 298 | 299 | after do 300 | subject.remove 301 | end 302 | 303 | context 'when the input is a tar' do 304 | after do 305 | ls_container.remove 306 | committed_image.remove 307 | end 308 | 309 | it 'file exists in the container' do 310 | skip('Not supported on podman') if ::Docker.podman? 311 | subject.archive_in_stream('/', overwrite: false) { tar.read } 312 | expect(output).to include('lol') 313 | end 314 | end 315 | 316 | context 'when the input would overwrite a directory with a file' do 317 | let(:tar) { StringIO.new(Docker::Util.create_tar('/etc' => 'TEST')) } 318 | 319 | it 'raises an error' do 320 | skip('Not supported on podman') if ::Docker.podman? 321 | # Docs say this should return a client error: clearly wrong 322 | # https://docs.docker.com/engine/reference/api/docker_remote_api_v1.21/ 323 | # #extract-an-archive-of-files-or-folders-to-a-directory-in-a-container 324 | expect { 325 | subject.archive_in_stream('/', overwrite: false) { tar.read } 326 | }.to raise_error(Docker::Error::ServerError) 327 | end 328 | end 329 | end 330 | 331 | describe '#archive_out', :docker_1_8 do 332 | subject { Docker::Container.create('Image' => 'debian:stable', 'Cmd' => ['touch','/test']) } 333 | 334 | after { subject.remove } 335 | 336 | context 'when the file does not exist' do 337 | it 'raises an error' do 338 | skip('Not supported on podman') if ::Docker.podman? 339 | subject.start 340 | subject.wait 341 | 342 | expect { subject.archive_out('/lol') { |chunk| puts chunk } } 343 | .to raise_error(Docker::Error::NotFoundError) 344 | end 345 | end 346 | 347 | context 'when the input is a file' do 348 | it 'yields each chunk of the tarred file' do 349 | skip('Not supported on podman') if ::Docker.podman? 350 | subject.start; subject.wait 351 | 352 | chunks = [] 353 | subject.archive_out('/test') { |chunk| chunks << chunk } 354 | chunks = chunks.join("\n") 355 | expect(chunks).to be_include('test') 356 | end 357 | end 358 | 359 | context 'when the input is a directory' do 360 | it 'yields each chunk of the tarred directory' do 361 | skip('Not supported on podman') if ::Docker.podman? 362 | subject.start; subject.wait 363 | 364 | chunks = [] 365 | subject.archive_out('/etc/logrotate.d') { |chunk| chunks << chunk } 366 | chunks = chunks.join("\n") 367 | expect(%w[apt dpkg]).to be_all { |file| chunks.include?(file) } 368 | end 369 | end 370 | end 371 | 372 | describe "#read_file", :docker_1_8 do 373 | subject { 374 | Docker::Container.create( 375 | "Image" => "debian:stable", 376 | "Cmd" => ["/bin/bash", "-c", "echo \"Hello world\" > /test"] 377 | ) 378 | } 379 | 380 | after { subject.remove } 381 | 382 | before do 383 | subject.start 384 | subject.wait 385 | end 386 | 387 | it "reads contents from files" do 388 | skip('Not supported on podman') if ::Docker.podman? 389 | expect(subject.read_file("/test")).to eq "Hello world\n" 390 | end 391 | end 392 | 393 | describe "#store_file", :docker_1_8 do 394 | subject { Docker::Container.create('Image' => 'debian:stable', 'Cmd' => ["ls"]) } 395 | 396 | after { subject.remove } 397 | 398 | it "stores content in files" do 399 | skip('Not supported on podman') if ::Docker.podman? 400 | subject.store_file("/test", "Hello\nWorld") 401 | expect(subject.read_file("/test")).to eq "Hello\nWorld" 402 | end 403 | end 404 | 405 | describe '#export' do 406 | subject { described_class.create('Cmd' => %w[/true], 407 | 'Image' => 'tianon/true') } 408 | before { subject.start } 409 | after { subject.tap(&:wait).remove } 410 | 411 | it 'yields each chunk' do 412 | first = nil 413 | subject.export do |chunk| 414 | first ||= chunk 415 | end 416 | expect(first[257..261]).to eq "ustar" # Make sure the export is a tar. 417 | end 418 | end 419 | 420 | describe '#attach' do 421 | subject { 422 | described_class.create( 423 | 'Cmd' => ['bash','-c','sleep 2; echo hello'], 424 | 'Image' => 'debian:stable' 425 | ) 426 | } 427 | 428 | before { subject.start } 429 | after(:each) { subject.stop.remove } 430 | 431 | context 'with normal sized chunks' do 432 | it 'yields each chunk' do 433 | chunk = nil 434 | subject.attach do |stream, c| 435 | chunk ||= c 436 | end 437 | expect(chunk).to eq("hello\n") 438 | end 439 | end 440 | 441 | context 'with very small chunks' do 442 | before do 443 | Docker.options = { :chunk_size => 1 } 444 | end 445 | 446 | after do 447 | Docker.options = {} 448 | end 449 | 450 | it 'yields each chunk' do 451 | chunk = nil 452 | subject.attach do |stream, c| 453 | chunk ||= c 454 | end 455 | expect(chunk).to eq("hello\n") 456 | end 457 | end 458 | end 459 | 460 | describe '#attach with stdin' do 461 | it 'yields the output' do 462 | skip('Currently broken in podman') if ::Docker.podman? 463 | container = described_class.create( 464 | 'Cmd' => %w[cat], 465 | 'Image' => 'debian:stable', 466 | 'OpenStdin' => true, 467 | 'StdinOnce' => true 468 | ) 469 | chunk = nil 470 | container 471 | .tap(&:start) 472 | .attach(stdin: StringIO.new("foo\nbar\n")) do |stream, c| 473 | chunk ||= c 474 | end 475 | container.tap(&:wait).remove 476 | 477 | expect(chunk).to eq("foo\nbar\n") 478 | end 479 | end 480 | 481 | describe '#start' do 482 | subject { 483 | described_class.create( 484 | 'Cmd' => %w[test -d /foo], 485 | 'Image' => 'debian:stable', 486 | 'Volumes' => {'/foo' => {}}, 487 | 'HostConfig' => { 'Binds' => ["/tmp:/foo"] } 488 | ) 489 | } 490 | let(:all) { Docker::Container.all(all: true) } 491 | 492 | before { subject.start } 493 | after(:each) { subject.remove } 494 | 495 | it 'starts the container' do 496 | expect(all.map(&:id)).to be_any { |id| id.start_with?(subject.id) } 497 | expect(subject.wait(10)['StatusCode']).to be_zero 498 | end 499 | end 500 | 501 | describe '#stop' do 502 | subject { 503 | described_class.create('Cmd' => %w[true], 'Image' => 'debian:stable') 504 | } 505 | 506 | before { subject.tap(&:start).stop('timeout' => '10') } 507 | after { subject.remove } 508 | 509 | it 'stops the container' do 510 | expect(described_class.all(:all => true).map(&:id)).to be_any { |id| 511 | id.start_with?(subject.id) 512 | } 513 | expect(described_class.all.map(&:id)).to be_none { |id| 514 | id.start_with?(subject.id) 515 | } 516 | end 517 | 518 | context 'with a timeout' do 519 | let(:custom_timeout) { 60 } 520 | 521 | before do 522 | subject.tap(&:start) 523 | end 524 | 525 | it 'extends the Excon timeout ensuring the request does not timeout before Docker' do 526 | expect(subject.connection).to receive(:request).with( 527 | :post, 528 | anything, 529 | anything, 530 | hash_including(read_timeout: custom_timeout + 5, write_timeout: custom_timeout + 5) 531 | ).once 532 | allow(subject.connection).to receive(:request).with(:delete, anything, anything) 533 | subject.stop('timeout' => custom_timeout) 534 | end 535 | end 536 | 537 | context 'without a timeout' do 538 | before do 539 | subject.tap(&:start) 540 | end 541 | 542 | it 'does not adjust the default Excon HTTP timeout' do 543 | expect(subject.connection).to receive(:request).with( 544 | :post, 545 | anything, 546 | anything, 547 | hash_including(body: '{}') 548 | ).once 549 | allow(subject.connection).to receive(:request).with(:delete, anything, anything) 550 | subject.stop 551 | end 552 | end 553 | end 554 | 555 | describe '#exec' do 556 | subject { 557 | described_class.create( 558 | 'Cmd' => %w[sleep 20], 559 | 'Image' => 'debian:stable' 560 | ).start 561 | } 562 | after { subject.kill!.remove } 563 | 564 | context 'when passed only a command' do 565 | let(:output) { subject.exec(['bash','-c','sleep 2; echo hello']) } 566 | 567 | it 'returns the stdout/stderr messages and exit code' do 568 | expect(output).to eq([["hello\n"], [], 0]) 569 | end 570 | end 571 | 572 | context 'when detach is true' do 573 | let(:output) { subject.exec(['date'], detach: true) } 574 | 575 | it 'returns the Docker::Exec object' do 576 | expect(output).to be_a Docker::Exec 577 | expect(output.id).to_not be_nil 578 | end 579 | end 580 | 581 | context 'when passed a block' do 582 | it 'streams the stdout/stderr messages' do 583 | chunk = nil 584 | subject.exec(['bash','-c','sleep 2; echo hello']) do |stream, c| 585 | chunk ||= c 586 | end 587 | expect(chunk).to eq("hello\n") 588 | end 589 | end 590 | 591 | context 'when stdin object is passed' do 592 | let(:output) { subject.exec(['cat'], stdin: StringIO.new("hello")) } 593 | 594 | it 'returns the stdout/stderr messages' do 595 | skip('Not supported on podman') if ::Docker.podman? 596 | expect(output).to eq([["hello"],[],0]) 597 | end 598 | end 599 | 600 | context 'when tty is true' do 601 | let(:command) { [ 602 | "bash", "-c", 603 | "if [ -t 1 ]; then echo -n \"I'm a TTY!\"; fi" 604 | ] } 605 | let(:output) { subject.exec(command, tty: true) } 606 | 607 | it 'returns the raw stdout/stderr output' do 608 | expect(output).to eq([["I'm a TTY!"], [], 0]) 609 | end 610 | end 611 | end 612 | 613 | describe '#kill' do 614 | let(:command) { ['/bin/bash', '-c', 'while [ 1 ]; do echo hello; done'] } 615 | subject { 616 | described_class.create('Cmd' => command, 'Image' => 'debian:stable') 617 | } 618 | 619 | before { subject.start } 620 | after(:each) {subject.remove } 621 | 622 | it 'kills the container' do 623 | subject.kill 624 | sleep(1) 625 | expect(described_class.all.map(&:id)).to be_none { |id| 626 | id.start_with?(subject.id) 627 | } 628 | expect(described_class.all(:all => true).map(&:id)).to be_any { |id| 629 | id.start_with?(subject.id) 630 | } 631 | end 632 | 633 | context 'with a kill signal' do 634 | let(:command) { 635 | [ 636 | '/bin/bash', 637 | '-c', 638 | 'trap echo SIGTERM; while [ 1 ]; do echo hello; done' 639 | ] 640 | } 641 | it 'kills the container' do 642 | subject.kill(:signal => "SIGTERM") 643 | sleep(1) 644 | expect(described_class.all.map(&:id)).to be_any { |id| 645 | id.start_with?(subject.id) 646 | } 647 | expect(described_class.all(:all => true).map(&:id)).to be_any { |id| 648 | id.start_with?(subject.id) 649 | } 650 | 651 | subject.kill(:signal => "SIGKILL") 652 | sleep(1) 653 | expect(described_class.all.map(&:id)).to be_none { |id| 654 | id.start_with?(subject.id) 655 | } 656 | expect(described_class.all(:all => true).map(&:id)).to be_any { |id| 657 | id.start_with?(subject.id) 658 | } 659 | end 660 | end 661 | end 662 | 663 | describe '#delete' do 664 | subject { 665 | described_class.create('Cmd' => ['ls'], 'Image' => 'debian:stable') 666 | } 667 | 668 | it 'deletes the container' do 669 | subject.delete(:force => true) 670 | expect(described_class.all.map(&:id)).to be_none { |id| 671 | id.start_with?(subject.id) 672 | } 673 | end 674 | end 675 | 676 | describe '#restart' do 677 | subject { 678 | described_class.create('Cmd' => %w[sleep 10], 'Image' => 'debian:stable') 679 | } 680 | 681 | before { subject.start } 682 | after { subject.kill!.remove } 683 | 684 | it 'restarts the container' do 685 | expect(described_class.all.map(&:id)).to be_any { |id| 686 | id.start_with?(subject.id) 687 | } 688 | subject.stop 689 | expect(described_class.all.map(&:id)).to be_none { |id| 690 | id.start_with?(subject.id) 691 | } 692 | subject.restart('timeout' => '10') 693 | expect(described_class.all.map(&:id)).to be_any { |id| 694 | id.start_with?(subject.id) 695 | } 696 | end 697 | end 698 | 699 | describe '#pause' do 700 | subject { 701 | described_class.create( 702 | 'Cmd' => %w[sleep 50], 703 | 'Image' => 'debian:stable' 704 | ).start 705 | } 706 | after { subject.unpause.kill!.remove } 707 | 708 | it 'pauses the container' do 709 | skip('Not supported on rootless podman') if (::Docker.podman? && ::Docker.rootless?) 710 | subject.pause 711 | expect(described_class.get(subject.id).info['State']['Paused']).to be true 712 | end 713 | end 714 | 715 | describe '#unpause' do 716 | subject { 717 | described_class.create( 718 | 'Cmd' => %w[sleep 50], 719 | 'Image' => 'debian:stable' 720 | ).start 721 | } 722 | before { subject.pause } 723 | after { subject.kill!.remove } 724 | 725 | it 'unpauses the container' do 726 | subject.unpause 727 | expect( 728 | described_class.get(subject.id).info['State']['Paused'] 729 | ).to be false 730 | end 731 | end 732 | 733 | describe '#wait' do 734 | subject { 735 | described_class.create( 736 | 'Cmd' => %w[tar nonsense], 737 | 'Image' => 'debian:stable' 738 | ) 739 | } 740 | 741 | before { subject.start } 742 | after(:each) { subject.remove } 743 | 744 | it 'waits for the command to finish' do 745 | expect(subject.wait['StatusCode']).to_not be_zero 746 | end 747 | 748 | context 'when an argument is given' do 749 | subject { described_class.create('Cmd' => %w[sleep 5], 750 | 'Image' => 'debian:stable') } 751 | 752 | it 'sets the :read_timeout to that amount of time' do 753 | expect(subject.wait(6)['StatusCode']).to be_zero 754 | end 755 | 756 | context 'and a command runs for too long' do 757 | it 'raises a ServerError' do 758 | expect{subject.wait(4)}.to raise_error(Docker::Error::TimeoutError) 759 | subject.tap(&:wait) 760 | end 761 | end 762 | end 763 | end 764 | 765 | describe '#run' do 766 | let(:run_command) { subject.run('ls') } 767 | 768 | context 'when the Container\'s command does not return status code of 0' do 769 | subject { described_class.create('Cmd' => %w[false], 770 | 'Image' => 'debian:stable') } 771 | 772 | after do 773 | subject.remove 774 | end 775 | 776 | it 'raises an error' do 777 | expect { run_command } 778 | .to raise_error(Docker::Error::UnexpectedResponseError) 779 | end 780 | end 781 | 782 | context 'when the Container\'s command returns a status code of 0' do 783 | subject { described_class.create('Cmd' => %w[pwd], 784 | 'Image' => 'debian:stable') } 785 | after do 786 | subject.remove 787 | image = run_command.json['Image'] 788 | run_command.remove 789 | Docker::Image.get(image).history.each do |layer| 790 | next unless layer['CreatedBy'] == 'pwd' 791 | Docker::Image.get(layer['Id']).remove(:noprune => true) 792 | end 793 | end 794 | 795 | it 'creates a new container to run the specified command' do 796 | expect(run_command.wait['StatusCode']).to be_zero 797 | end 798 | end 799 | end 800 | 801 | describe '#commit' do 802 | subject { 803 | described_class.create('Cmd' => %w[true], 'Image' => 'debian:stable') 804 | } 805 | let(:image) { subject.commit } 806 | 807 | after(:each) do 808 | subject.remove 809 | image.remove 810 | end 811 | 812 | it 'creates a new Image from the Container\'s changes' do 813 | subject.tap(&:start).wait 814 | 815 | expect(image).to be_a Docker::Image 816 | expect(image.id).to_not be_nil 817 | end 818 | 819 | context 'if run is passed, it saves the command in the image' do 820 | let(:image) { subject.commit } 821 | let(:container) { image.run('pwd') } 822 | 823 | it 'saves the command' do 824 | skip('Not supported on podman') if ::Docker.podman? 825 | container.wait 826 | expect(container.attach(logs: true, stream: false)).to eql [["/\n"],[]] 827 | container.remove 828 | end 829 | end 830 | end 831 | 832 | describe '.create' do 833 | subject { described_class } 834 | 835 | context 'when the Container does not yet exist' do 836 | context 'when the HTTP request does not return a 200' do 837 | before do 838 | Docker.options = { :mock => true } 839 | Excon.stub({ :method => :post }, { :status => 400 }) 840 | end 841 | after do 842 | Excon.stubs.shift 843 | Docker.options = {} 844 | end 845 | 846 | it 'raises an error' do 847 | expect { subject.create }.to raise_error(Docker::Error::ClientError) 848 | end 849 | end 850 | 851 | context 'when the HTTP request returns a 200' do 852 | let(:options) do 853 | { 854 | "Cmd" => ["date"], 855 | "Image" => "debian:stable", 856 | } 857 | end 858 | let(:container) { subject.create(options) } 859 | after { container.remove } 860 | 861 | it 'sets the id' do 862 | expect(container).to be_a Docker::Container 863 | expect(container.id).to_not be_nil 864 | expect(container.connection).to_not be_nil 865 | end 866 | end 867 | end 868 | end 869 | 870 | describe '.get' do 871 | subject { described_class } 872 | 873 | context 'when the HTTP response is not a 200' do 874 | before do 875 | Docker.options = { :mock => true } 876 | Excon.stub({ :method => :get }, { :status => 500 }) 877 | end 878 | after do 879 | Excon.stubs.shift 880 | Docker.options = {} 881 | end 882 | 883 | it 'raises an error' do 884 | expect { subject.get('randomID') } 885 | .to raise_error(Docker::Error::ServerError) 886 | end 887 | end 888 | 889 | context 'when the HTTP response is a 200' do 890 | let(:container) { 891 | subject.create('Cmd' => ['ls'], 'Image' => 'debian:stable') 892 | } 893 | after { container.remove } 894 | 895 | it 'materializes the Container into a Docker::Container' do 896 | expect(subject.get(container.id)).to be_a Docker::Container 897 | end 898 | end 899 | 900 | end 901 | 902 | describe '.all' do 903 | subject { described_class } 904 | 905 | context 'when the HTTP response is not a 200' do 906 | before do 907 | Docker.options = { :mock => true } 908 | Excon.stub({ :method => :get }, { :status => 500 }) 909 | end 910 | after do 911 | Excon.stubs.shift 912 | Docker.options = {} 913 | end 914 | 915 | it 'raises an error' do 916 | expect { subject.all } 917 | .to raise_error(Docker::Error::ServerError) 918 | end 919 | end 920 | 921 | context 'when the HTTP response is a 200' do 922 | let(:container) { 923 | subject.create('Cmd' => ['ls'], 'Image' => 'debian:stable') 924 | } 925 | before { container } 926 | after { container.remove } 927 | 928 | it 'materializes each Container into a Docker::Container' do 929 | expect(subject.all(:all => true)).to be_all { |container| 930 | container.is_a?(Docker::Container) 931 | } 932 | expect(subject.all(:all => true).length).to_not be_zero 933 | end 934 | end 935 | end 936 | 937 | describe '.prune', :docker_17_03 => true do 938 | it 'prune containers' do 939 | expect { Docker::Container.prune }.not_to raise_error 940 | end 941 | end 942 | end 943 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | docker-api 2 | ========== 3 | [![Gem Version](https://badge.fury.io/rb/docker-api.svg)](https://badge.fury.io/rb/docker-api) [![Code Climate](https://codeclimate.com/github/upserve/docker-api.svg)](https://codeclimate.com/github/upserve/docker-api) 4 | 5 | This gem provides an object-oriented interface to the [Docker Engine API](https://docs.docker.com/develop/sdk/). Every method listed there is implemented. At the time of this writing, docker-api is meant to interface with Docker version 1.4.* 6 | 7 | If you're interested in using Docker to package your apps, we recommend the [dockly](https://github.com/upserve/dockly) gem. Dockly provides a simple DSL for describing Docker containers that install as Debian packages and are controlled by upstart scripts. 8 | 9 | Installation 10 | ------------ 11 | 12 | Add this line to your application's Gemfile: 13 | 14 | ```ruby 15 | gem 'docker-api' 16 | ``` 17 | 18 | And then run: 19 | 20 | ```shell 21 | $ bundle install 22 | ``` 23 | 24 | Alternatively, if you wish to just use the gem in a script, you can run: 25 | 26 | ```shell 27 | $ gem install docker-api 28 | ``` 29 | 30 | Finally, just add `require 'docker'` to the top of the file using this gem. 31 | 32 | Usage 33 | ----- 34 | 35 | docker-api is designed to be very lightweight. Almost no state is cached (aside from id's which are immutable) to ensure that each method call's information is up to date. As such, just about every external method represents an API call. 36 | 37 | At this time, basic `podman` support has been added via the podman docker-compatible API socket. 38 | 39 | ## Starting up 40 | 41 | Follow the [installation instructions](https://docs.docker.com/install/), and then run: 42 | 43 | ```shell 44 | $ sudo docker -d 45 | ``` 46 | 47 | This will daemonize Docker so that it can be used for the remote API calls. 48 | 49 | ### Host 50 | 51 | If you're running Docker locally as a socket, there is no setup to do in Ruby. If you're not using a socket or have changed the path of the socket, you'll have to point the gem to your socket or local/remote port. For example: 52 | 53 | ```ruby 54 | Docker.url = 'tcp://example.com:5422' 55 | ``` 56 | 57 | Two things to note here. The first is that this gem uses [excon](https://github.com/excon/excon), so any of the options that are valid for `Excon.new` are also valid for `Docker.options`. Second, by default Docker runs on a socket. The gem will assume you want to connect to the socket unless you specify otherwise. 58 | 59 | Also, you may set the above variables via `ENV` variables. For example: 60 | 61 | ```shell 62 | $ DOCKER_URL=unix:///var/docker.sock irb 63 | irb(main):001:0> require 'docker' 64 | => true 65 | irb(main):002:0> Docker.url 66 | => "unix:///var/docker.sock" 67 | irb(main):003:0> Docker.options 68 | => {} 69 | ``` 70 | 71 | ```shell 72 | $ DOCKER_URL=tcp://example.com:1000 irb 73 | irb(main):001:0> require 'docker' 74 | => true 75 | irb(main):003:0> Docker.url 76 | => "tcp://example.com:1000" 77 | irb(main):004:0> Docker.options 78 | => {} 79 | ``` 80 | 81 | ### SSL 82 | 83 | When running docker using SSL, setting the DOCKER_CERT_PATH will configure docker-api to use SSL. 84 | The cert path is a folder that contains the cert, key and cacert files. 85 | docker-api is expecting the files to be named: cert.pem, key.pem, and ca.pem. 86 | If your files are named different, you'll want to set your options explicity: 87 | 88 | ``` 89 | Docker.options = { 90 | client_cert: File.join(cert_path, 'cert.pem'), 91 | client_key: File.join(cert_path, 'key.pem'), 92 | ssl_ca_file: File.join(cert_path, 'ca.pem'), 93 | scheme: 'https' 94 | } 95 | ``` 96 | 97 | If you want to load the cert files from a variable, e.g. you want to load them from ENV as needed on Heroku: 98 | 99 | ``` 100 | cert_store = OpenSSL::X509::Store.new 101 | certificate = OpenSSL::X509::Certificate.new ENV["DOCKER_CA"] 102 | cert_store.add_cert certificate 103 | 104 | Docker.options = { 105 | client_cert_data: ENV["DOCKER_CERT"], 106 | client_key_data: ENV["DOCKER_KEY"], 107 | ssl_cert_store: cert_store, 108 | scheme: 'https' 109 | } 110 | ``` 111 | 112 | If you need to disable SSL verification, set the DOCKER_SSL_VERIFY variable to 'false'. 113 | 114 | ## Global calls 115 | 116 | All of the following examples require a connection to a Docker server. See the Starting up section above for more information. 117 | 118 | ```ruby 119 | require 'docker' 120 | # => true 121 | 122 | # docker command for reference: docker version 123 | Docker.version 124 | # => { 'Version' => '0.5.2', 'GoVersion' => 'go1.1' } 125 | 126 | # docker command for reference: docker info 127 | Docker.info 128 | # => { "Debug" => false, "Containers" => 187, "Images" => 196, "NFd" => 10, "NGoroutines" => 9, "MemoryLimit" => true } 129 | 130 | # docker command for reference: docker login 131 | Docker.authenticate!('username' => 'docker-fan-boi', 'password' => 'i<3docker', 'email' => 'dockerboy22@aol.com') 132 | # => true 133 | 134 | # docker command for reference: docker login registry.gitlab.com 135 | Docker.authenticate!('username' => 'docker-fan-boi', 'password' => 'i<3docker', 'email' => 'dockerboy22@aol.com', 'serveraddress' => 'https://registry.gitlab.com/v1/') 136 | # => true 137 | ``` 138 | 139 | ## Images 140 | 141 | Just about every method here has a one-to-one mapping with the [Images](https://docs.docker.com/engine/reference/api/docker_remote_api_v1.14/#2-2-images) section of the API. If an API call accepts query parameters, these can be passed as an Hash to it's corresponding method. Also, note that `Docker::Image.new` is a private method, so you must use `.create`, `.build`, `.build_from_dir`, `build_from_tar`, or `.import` to make an instance. 142 | 143 | ```ruby 144 | require 'docker' 145 | # => true 146 | 147 | # Pull an Image. 148 | # docker command for reference: docker pull ubuntu:14.04 149 | image = Docker::Image.create('fromImage' => 'ubuntu:14.04') 150 | # => Docker::Image { :id => ae7ffbcd1, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 151 | 152 | # Insert a local file into an Image. 153 | image.insert_local('localPath' => 'Gemfile', 'outputPath' => '/') 154 | # => Docker::Image { :id => 682ea192f, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 155 | 156 | # Insert multiple local files into an Image. 157 | image.insert_local('localPath' => [ 'Gemfile', 'Rakefile' ], 'outputPath' => '/') 158 | # => Docker::Image { :id => eb693ec80, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 159 | 160 | # Add a repo name to Image. 161 | # docker command for reference: docker tag base2 162 | image.tag('repo' => 'base2', 'force' => true) 163 | # => ["base2"] 164 | 165 | # Add a repo name and tag an Image. 166 | # docker command for reference: docker tag base2:latest 167 | image.tag('repo' => 'base2', 'tag' => 'latest', force: true) 168 | # => ["base2:latest"] 169 | 170 | # Get more information about the Image. 171 | # docker command for reference: docker inspect 172 | image.json 173 | # => {"id"=>"67859327bf22ef8b5b9b4a6781f72b2015acd894fa03ce07e0db7af170ba468c", "comment"=>"Imported from -", "created"=>"2013-06-19T18:42:58.287944526-04:00", "container_config"=>{"Hostname"=>"", "User"=>"", "Memory"=>0, "MemorySwap"=>0, "CpuShares"=>0, "AttachStdin"=>false, "AttachStdout"=>false, "AttachStderr"=>false, "PortSpecs"=>nil, "Tty"=>false, "OpenStdin"=>false, "StdinOnce"=>false, "Env"=>nil, "Cmd"=>nil, "Dns"=>nil, "Image"=>"", "Volumes"=>nil, "VolumesFrom"=>""}, "docker_version"=>"0.4.0", "architecture"=>"x86_64"} 174 | 175 | # View the history of the Image. 176 | image.history 177 | # => [{"Id"=>"67859327bf22", "Created"=>1371681778}] 178 | 179 | # Push the Image to the Docker registry. Note that you have to login using 180 | # `Docker.authenticate!` and tag the Image first. 181 | # docker command for reference: docker push 182 | image.push 183 | # => Docker::Image { @connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} }, @info = { "id" => eb693ec80, "RepoTags" => ["base2", "base2/latest"]} } 184 | 185 | # Push individual tag to the Docker registry. 186 | image.push(nil, tag: "tag_name") 187 | image.push(nil, repo_tag: 'registry/repo_name:tag_name') 188 | 189 | # Given a command, create a new Container to run that command in the Image. 190 | # docker command for reference: docker run -ti ls -l 191 | image.run('ls -l') 192 | # => Docker::Container { id => aaef712eda, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 193 | 194 | # Remove the Image from the server. 195 | # docker command for reference: docker rmi -f 196 | image.remove(:force => true) 197 | # => true 198 | 199 | # Export a single Docker Image to a file 200 | # docker command for reference: docker save my_export.tar 201 | image.save('my_export.tar') 202 | # => Docker::Image { :id => 66b712aef, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 203 | 204 | # Return the raw image binary data 205 | image.save 206 | # => "abiglongbinarystring" 207 | 208 | # Stream the contents of the image to a block: 209 | image.save_stream { |chunk| puts chunk } 210 | # => nil 211 | 212 | # Given a Container's export, creates a new Image. 213 | # docker command for reference: docker import some-export.tar 214 | Docker::Image.import('some-export.tar') 215 | # => Docker::Image { :id => 66b712aef, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 216 | 217 | # `Docker::Image.import` can also import from a URI 218 | Docker::Image.import('http://some-site.net/my-image.tar') 219 | # => Docker::Image { :id => 6b462b2d2, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 220 | 221 | # For a lower-level interface for importing tars, `Docker::Image.import_stream` may be used. 222 | # It accepts a block, and will call that block until it returns an empty `String`. 223 | File.open('my-export.tar') do |file| 224 | Docker::Image.import_stream { file.read(1000).to_s } 225 | end 226 | 227 | # Create an Image from a Dockerfile as a String. 228 | Docker::Image.build("from base\nrun touch /test") 229 | # => Docker::Image { :id => b750fe79269d2ec9a3c593ef05b4332b1d1a02a62b4accb2c21d589ff2f5f2dc, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 230 | 231 | # Create an Image from a Dockerfile. 232 | # docker command for reference: docker build . 233 | Docker::Image.build_from_dir('.') 234 | # => Docker::Image { :id => 1266dc19e, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 235 | 236 | # Create an Image from a file other than Dockerfile. 237 | # docker command for reference: docker build -f Dockerfile.Centos . 238 | Docker::Image.build_from_dir('.', { 'dockerfile' => 'Dockerfile.Centos' }) 239 | # => Docker::Image { :id => 1266dc19e, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 240 | 241 | # Create an Image from a Dockerfile and stream the logs 242 | Docker::Image.build_from_dir('.') do |v| 243 | if (log = JSON.parse(v)) && log.has_key?("stream") 244 | $stdout.puts log["stream"] 245 | end 246 | end 247 | 248 | # Create an Image from a tar file. 249 | # docker command for reference: docker build - < docker_image.tar 250 | Docker::Image.build_from_tar(File.open('docker_image.tar', 'r')) 251 | # => Docker::Image { :id => 1266dc19e, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 252 | 253 | # Load all Images on your Docker server. 254 | # docker command for reference: docker images 255 | Docker::Image.all 256 | # => [Docker::Image { :id => b750fe79269d2ec9a3c593ef05b4332b1d1a02a62b4accb2c21d589ff2f5f2dc, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }, Docker::Image { :id => 8dbd9e392a964056420e5d58ca5cc376ef18e2de93b5cc90e868a1bbc8318c1c, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }] 257 | 258 | # Get Image from the server, with id 259 | # docker command for reference: docker images 260 | Docker::Image.get('df4f1bdecf40') 261 | # => Docker::Image { :id => eb693ec80, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 262 | 263 | # Check if an image with a given id exists on the server. 264 | Docker::Image.exist?('ef723dcdac09') 265 | # => true 266 | 267 | # Load an image from the file system 268 | Docker::Image.load('./my-image.tar') 269 | # => "" 270 | 271 | # An IO object may also be specified for loading 272 | File.open('./my-image.tar', 'rb') do |file| 273 | Docker::Image.load(file) 274 | end 275 | # => "" 276 | 277 | # Export multiple images to a single tarball 278 | # docker command for reference: docker save my_image1 my_image2:not_latest > my_export.tar 279 | names = %w( my_image1 my_image2:not_latest ) 280 | Docker::Image.save(names, 'my_export.tar') 281 | # => nil 282 | 283 | # Return the raw image binary data 284 | names = %w( my_image1 my_image2:not_latest ) 285 | Docker::Image.save(names) 286 | # => "abiglongbinarystring" 287 | 288 | # Stream the raw binary data 289 | names = %w( my_image1 my_image2:not_latest ) 290 | Docker::Image.save_stream(names) { |chunk| puts chunk } 291 | # => nil 292 | 293 | # Search the Docker registry. 294 | # docker command for reference: docker search sshd 295 | Docker::Image.search('term' => 'sshd') 296 | # => [Docker::Image { :id => cespare/sshd, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }, Docker::Image { :id => johnfuller/sshd, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }, Docker::Image { :id => dhrp/mongodb-sshd, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }, Docker::Image { :id => rayang2004/sshd, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }, Docker::Image { :id => dhrp/sshd, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }, Docker::Image { :id => toorop/daemontools-sshd, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }, Docker::Image { :id => toorop/daemontools-sshd-nginx, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }, Docker::Image { :id => toorop/daemontools-sshd-nginx-php-fpm, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }, Docker::Image { :id => mbkan/lamp, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }, Docker::Image { :id => toorop/golang, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }, Docker::Image { :id => wma55/u1210sshd, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }, Docker::Image { :id => jdswinbank/sshd, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }, Docker::Image { :id => vgauthier/sshd, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }] 297 | ``` 298 | 299 | ## Containers 300 | 301 | Much like the Images, this object also has a one-to-one mapping with the [Containers](https://docs.docker.com/engine/reference/api/docker_remote_api_v1.14/#2-1-containers) section of the API. Also like Images, `.new` is a private method, so you must use `.create` to make an instance. 302 | 303 | ```ruby 304 | require 'docker' 305 | 306 | # Create a Container. 307 | container = Docker::Container.create('Cmd' => ['ls'], 'Image' => 'base') 308 | # => Docker::Container { :id => 492510dd38e4, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 309 | 310 | # Get more information about the Container. 311 | container.json 312 | # => {"ID"=>"492510dd38e4da7703f36dfccd013de672b8250f57f59d1555ced647766b5e82", "Created"=>"2013-06-20T10:46:02.897548-04:00", "Path"=>"ls", "Args"=>[], "Config"=>{"Hostname"=>"492510dd38e4", "User"=>"", "Memory"=>0, "MemorySwap"=>0, "CpuShares"=>0, "AttachStdin"=>false, "AttachStdout"=>false, "AttachStderr"=>false, "PortSpecs"=>nil, "Tty"=>false, "OpenStdin"=>false, "StdinOnce"=>false, "Env"=>nil, "Cmd"=>["ls"], "Dns"=>nil, "Image"=>"base", "Volumes"=>nil, "VolumesFrom"=>""}, "State"=>{"Running"=>false, "Pid"=>0, "ExitCode"=>0, "StartedAt"=>"0001-01-01T00:00:00Z", "Ghost"=>false}, "Image"=>"b750fe79269d2ec9a3c593ef05b4332b1d1a02a62b4accb2c21d589ff2f5f2dc", "NetworkSettings"=>{"IpAddress"=>"", "IpPrefixLen"=>0, "Gateway"=>"", "Bridge"=>"", "PortMapping"=>nil}, "SysInitPath"=>"/usr/bin/docker", "ResolvConfPath"=>"/etc/resolv.conf", "Volumes"=>nil} 313 | 314 | # Start running the Container. 315 | container.start 316 | # => Docker::Container { :id => 492510dd38e4, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 317 | 318 | # Stop running the Container. 319 | container.stop 320 | # => Docker::Container { :id => 492510dd38e4, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 321 | 322 | # Restart the Container. 323 | container.restart 324 | # => Docker::Container { :id => 492510dd38e4, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 325 | 326 | # Pause the running Container processes. 327 | container.pause 328 | # => Docker::Container { :id => 492510dd38e4, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 329 | 330 | # Unpause the running Container processes. 331 | container.unpause 332 | # => Docker::Container { :id => 492510dd38e4, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 333 | 334 | # Kill the command running in the Container. 335 | container.kill 336 | # => Docker::Container { :id => 492510dd38e4, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 337 | 338 | # Kill the Container specifying the kill signal. 339 | container.kill(:signal => "SIGHUP") 340 | # => Docker::Container { :id => 492510dd38e4, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 341 | 342 | # Return the currently executing processes in a Container. 343 | container.top 344 | # => [{"PID"=>"4851", "TTY"=>"pts/0", "TIME"=>"00:00:00", "CMD"=>"lxc-start"}] 345 | 346 | # Same as above, but uses the original format 347 | container.top(format: :hash) 348 | # => { 349 | # "Titles" => ["PID", "TTY", "TIME", "CMD"], 350 | # "Processes" => [["4851", "pts/0", "00:00:00", "lxc-start"]] 351 | # } 352 | 353 | # To expose 1234 to bridge 354 | # In Dockerfile: EXPOSE 1234/tcp 355 | # docker run resulting-image-name 356 | Docker::Container.create( 357 | 'Image' => 'image-name', 358 | 'HostConfig' => { 359 | 'PortBindings' => { 360 | '1234/tcp' => [{}] 361 | } 362 | } 363 | ) 364 | 365 | # To expose 1234 to host with any port 366 | # docker run -p 1234 image-name 367 | Docker::Container.create( 368 | 'Image' => 'image-name', 369 | 'ExposedPorts' => { '1234/tcp' => {} }, 370 | 'HostConfig' => { 371 | 'PortBindings' => { 372 | '1234/tcp' => [{}] 373 | } 374 | } 375 | ) 376 | 377 | # To expose 1234 to host with a specified host port 378 | # docker run -p 1234:1234 image-name 379 | Docker::Container.create( 380 | 'Image' => 'image-name', 381 | 'ExposedPorts' => { '1234/tcp' => {} }, 382 | 'HostConfig' => { 383 | 'PortBindings' => { 384 | '1234/tcp' => [{ 'HostPort' => '1234' }] 385 | } 386 | } 387 | ) 388 | 389 | # To expose 1234 to host with a specified host port and host IP 390 | # docker run -p 192.168.99.100:1234:1234 image-name 391 | Docker::Container.create( 392 | 'Image' => 'image-name', 393 | 'ExposedPorts' => { '1234/tcp' => {} }, 394 | 'HostConfig' => { 395 | 'PortBindings' => { 396 | '1234/tcp' => [{ 'HostPort' => '1234', 'HostIp' => '192.168.99.100' }] 397 | } 398 | } 399 | ) 400 | 401 | # To set container name pass `name` key to options 402 | Docker::Container.create( 403 | 'name' => 'my-new-container', 404 | 'Image' => 'image-name' 405 | ) 406 | 407 | # Stores a file with the given content in the container 408 | container.store_file("/test", "Hello world") 409 | 410 | # Reads a file from the container 411 | container.read_file("/test") 412 | # => "Hello world" 413 | 414 | # Export a Container. Since an export is typically at least 300M, chunks of the 415 | # export are yielded instead of just returning the whole thing. 416 | File.open('export.tar', 'w') do |file| 417 | container.export { |chunk| file.write(chunk) } 418 | end 419 | # => nil 420 | 421 | # Inspect a Container's changes to the file system. 422 | container.changes 423 | # => [{'Path'=>'/dev', 'Kind'=>0}, {'Path'=>'/dev/kmsg', 'Kind'=>1}] 424 | 425 | # Copy files/directories from the Container. Note that these are exported as tars. 426 | container.archive_out('/etc/hosts') { |chunk| puts chunk } 427 | 428 | hosts0000644000000000000000000000023412100405636007023 0ustar 429 | 127.0.0.1 localhost 430 | ::1 localhost ip6-localhost ip6-loopback 431 | fe00::0 ip6-localnet 432 | ff00::0 ip6-mcastprefix 433 | ff02::1 ip6-allnodes 434 | ff02::2 ip6-allrouters 435 | # => Docker::Container { :id => a1759f3e2873, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 436 | 437 | # Wait for the current command to finish executing. If an argument is given, 438 | # will timeout after that number of seconds. The default is one minute. 439 | container.wait(15) 440 | # => {'StatusCode'=>0} 441 | 442 | # Attach to the Container. Currently, the below options are the only valid ones. 443 | # By default, :stream, :stdout, and :stderr are set. 444 | container.attach(:stream => true, :stdin => nil, :stdout => true, :stderr => true, :logs => true, :tty => false) 445 | # => [["bin\nboot\ndev\netc\nhome\nlib\nlib64\nmedia\nmnt\nopt\nproc\nroot\nrun\nsbin\nselinux\nsrv\nsys\ntmp\nusr\nvar", []] 446 | 447 | # If you wish to stream the attach method, a block may be supplied. 448 | container = Docker::Container.create('Image' => 'base', 'Cmd' => ['find / -name *']) 449 | container.tap(&:start).attach { |stream, chunk| puts "#{stream}: #{chunk}" } 450 | stderr: 2013/10/30 17:16:24 Unable to locate find / -name * 451 | # => [[], ["2013/10/30 17:16:24 Unable to locate find / -name *\n"]] 452 | 453 | # If you want to attach to stdin of the container, supply an IO-like object: 454 | container = Docker::Container.create('Image' => 'base', 'Cmd' => ['cat'], 'OpenStdin' => true, 'StdinOnce' => true) 455 | container.tap(&:start).attach(stdin: StringIO.new("foo\nbar\n")) 456 | # => [["foo\nbar\n"], []] 457 | 458 | # Similar to the stdout/stderr attach method, there is logs and streaming_logs 459 | 460 | # logs will only return after the container has exited. The output will be the raw output from the logs stream. 461 | # streaming_logs will collect the messages out of the multiplexed form and also execute a block on each line that comes in (block takes a stream and a chunk as arguments) 462 | 463 | # Raw logs from a TTY-enabled container after exit 464 | container.logs(stdout: true) 465 | # => "\e]0;root@8866c76564e8: /\aroot@8866c76564e8:/# echo 'i\b \bdocker-api'\r\ndocker-api\r\n\e]0;root@8866c76564e8: /\aroot@8866c76564e8:/# exit\r\n" 466 | 467 | # Logs from a non-TTY container with multiplex prefix 468 | container.logs(stdout: true) 469 | # => "\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u00021\n\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u00022\n\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u00023\n\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u00024\n\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u00025\n\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u00026\n\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u00027\n\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u00028\n\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u00029\n\u0001\u0000\u0000\u0000\u0000\u0000\u0000\u000310\n" 470 | 471 | # Streaming logs from non-TTY container removing multiplex prefix with a block printing out each line (block not possible with Container#logs) 472 | container.streaming_logs(stdout: true) { |stream, chunk| puts "#{stream}: #{chunk}" } 473 | stdout: 1 474 | stdout: 2 475 | stdout: 3 476 | stdout: 4 477 | stdout: 5 478 | stdout: 6 479 | stdout: 7 480 | stdout: 8 481 | stdout: 9 482 | stdout: 10 483 | # => "1\n\n2\n\n3\n\n4\n\n5\n\n6\n\n7\n\n8\n\n9\n\n10\n" 484 | 485 | # If the container has TTY enabled, set `tty => true` to get the raw stream: 486 | command = ["bash", "-c", "if [ -t 1 ]; then echo -n \"I'm a TTY!\"; fi"] 487 | container = Docker::Container.create('Image' => 'ubuntu', 'Cmd' => command, 'Tty' => true) 488 | container.tap(&:start).attach(:tty => true) 489 | # => [["I'm a TTY!"], []] 490 | 491 | # Obtaining the current statistics of a container 492 | container.stats 493 | # => {"read"=>"2016-02-29T20:47:05.221608695Z", "precpu_stats"=>{"cpu_usage"=> ... } 494 | 495 | # Create an Image from a Container's changes. 496 | container.commit 497 | # => Docker::Image { :id => eaeb8d00efdf, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 498 | 499 | # Commit the Container and run a new command. The second argument is the number 500 | # of seconds the Container should wait before stopping its current command. 501 | container.run('pwd', 10) 502 | # => Docker::Image { :id => 4427be4199ac, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 503 | 504 | # Run an Exec instance inside the container and capture its output and exit status 505 | container.exec(['date']) 506 | # => [["Wed Nov 26 11:10:30 CST 2014\n"], [], 0] 507 | 508 | # Launch an Exec instance without capturing its output or status 509 | container.exec(['./my_service'], detach: true) 510 | # => Docker::Exec { :id => be4eaeb8d28a, :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 511 | 512 | # Parse the output of an Exec instance 513 | container.exec(['find', '/', '-name *']) { |stream, chunk| puts "#{stream}: #{chunk}" } 514 | stderr: 2013/10/30 17:16:24 Unable to locate find / -name * 515 | # => [[], ["2013/10/30 17:16:24 Unable to locate find / -name *\n"], 1] 516 | 517 | # Run an Exec instance by grab only the STDOUT output 518 | container.exec(['date'], stderr: false) 519 | # => [["Wed Nov 26 11:10:30 CST 2014\n"], [], 0] 520 | 521 | # Pass input to an Exec instance command via Stdin 522 | container.exec(['cat'], stdin: StringIO.new("foo\nbar\n")) 523 | # => [["foo\nbar\n"], [], 0] 524 | 525 | # Get the raw stream of data from an Exec instance 526 | command = ["bash", "-c", "if [ -t 1 ]; then echo -n \"I'm a TTY!\"; fi"] 527 | container.exec(command, tty: true) 528 | # => [["I'm a TTY!"], [], 0] 529 | 530 | # Wait for the current command to finish executing. If an argument is given, 531 | # will timeout after that number of seconds. The default is one minute. 532 | command = ["bash", "-c", "if [ -t 1 ]; then echo -n \"Set max seconds for exec!!\"; fi"] 533 | container.exec(command, wait: 120) 534 | # => [["Set max seconds for exec!"], [], 0] 535 | 536 | # Delete a Container. 537 | container.delete(:force => true) 538 | # => nil 539 | 540 | # Update the container. 541 | container.update("CpuShares" => 50000") 542 | 543 | # Request a Container by ID or name. 544 | Docker::Container.get('500f53b25e6e') 545 | # => Docker::Container { :id => , :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } } 546 | 547 | # Request all of the Containers. By default, will only return the running Containers. 548 | Docker::Container.all(:all => true) 549 | # => [Docker::Container { :id => , :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }] 550 | ``` 551 | 552 | ## JSON encoded values 553 | 554 | For JSON encoded values, nothing is done implicitly, meaning you need to explicitly call `to_json` on your parameter before the call. For example, to request all of the Containers using a filter: 555 | 556 | ```ruby 557 | require 'docker' 558 | 559 | # Request all of the Containers, filtering by status exited. 560 | Docker::Container.all(all: true, filters: { status: ["exited"] }.to_json) 561 | # => [Docker::Container { :id => , :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }] 562 | 563 | # Request all of the Container, filtering by label_name. 564 | Docker::Container.all(all: true, filters: { label: [ "label_name" ] }.to_json) 565 | # => [Docker::Container { :id => , :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }] 566 | 567 | # Request all of the Container, filtering by label label_name that have the value label_value_. 568 | Docker::Container.all(all: true, filters: { label: [ "label_name=label_value" ] }.to_json) 569 | # => [Docker::Container { :id => , :connection => Docker::Connection { :url => tcp://localhost, :options => {:port=>2375} } }] 570 | ``` 571 | 572 | This applies for all parameters that are requested to be JSON encoded by the docker api. 573 | 574 | ## Events 575 | 576 | ```ruby 577 | require 'docker' 578 | 579 | # Action on a stream of events as they come in 580 | Docker::Event.stream { |event| puts event; break } 581 | Docker::Event { :status => create, :id => aeb8b55726df63bdd69d41e1b2650131d7ce32ca0d2fa5cbc75f24d0df34c7b0, :from => base:latest, :time => 1416958554 } 582 | # => nil 583 | 584 | # Action on all events after a given time (will execute the block for all events up till the current time, and wait to execute on any new events after) 585 | Docker::Event.since(1416958763) { |event| puts event; puts Time.now.to_i; break } 586 | Docker::Event { :status => die, :id => 663005cdeb56f50177c395a817dbc8bdcfbdfbdaef329043b409ecb97fb68d7e, :from => base:latest, :time => 1416958764 } 587 | 1416959041 588 | # => nil 589 | ``` 590 | 591 | These methods are prone to read timeouts. The timeout can be disabled by setting it to zero, or simply made much higher: 592 | 593 | ```ruby 594 | # Disable timeouts completely 595 | Docker::Event.stream({ read_timeout: 0 }) { |event| … } 596 | 597 | # Timeout if no events are received in 24h 598 | Docker::Event.stream({ read_timeout: 60 * 60 * 24) }) { |event| … } 599 | 600 | # Set a high timeout globally. Be warned that you probably don't want this for other methods. 601 | Docker.options[:read_timeout] = 60 * 60 * 24 602 | Docker::Event.stream { |event| … } 603 | ``` 604 | 605 | ## Connecting to Multiple Servers 606 | 607 | By default, each object connects to the connection specified by `Docker.connection`. If you need to connect to multiple servers, you can do so by specifying the connection on `#new` or in the utilizing class method. For example: 608 | 609 | ```ruby 610 | require 'docker' 611 | 612 | Docker::Container.all({}, Docker::Connection.new('tcp://example.com:2375', {})) 613 | ``` 614 | 615 | ## Rake Task 616 | 617 | To create images through `rake`, a DSL task is provided. For example: 618 | 619 | 620 | ```ruby 621 | require 'rake' 622 | require 'docker' 623 | 624 | image 'repo:tag' do 625 | image = Docker::Image.create('fromImage' => 'repo', 'tag' => 'old_tag') 626 | image = Docker::Image.run('rm -rf /etc').commit 627 | image.tag('repo' => 'repo', 'tag' => 'tag') 628 | end 629 | 630 | image 'repo:new_tag' => 'repo:tag' do 631 | image = Docker::Image.create('fromImage' => 'repo', 'tag' => 'tag') 632 | image = image.insert_local('localPath' => 'some-file.tar.gz', 'outputPath' => '/') 633 | image.tag('repo' => 'repo', 'tag' => 'new_tag') 634 | end 635 | ``` 636 | 637 | ## Not supported (yet) 638 | 639 | * Generating a tarball of images and metadata for a repository specified by a name: https://docs.docker.com/engine/reference/api/docker_remote_api_v1.14/#get-a-tarball-containing-all-images-and-tags-in-a-repository 640 | * Load a tarball generated from docker that contains all the images and metadata of a repository: https://docs.docker.com/engine/reference/api/docker_remote_api_v1.14/#load-a-tarball-with-a-set-of-images-and-tags-into-docker 641 | 642 | License 643 | ----- 644 | 645 | This program is licensed under the MIT license. See LICENSE for details. 646 | --------------------------------------------------------------------------------