From 0c57849b03ef127c0cd77a726ba4dfc64fc08ca7 Mon Sep 17 00:00:00 2001 From: Karl Matthias Date: Wed, 4 Jun 2014 14:45:33 -0700 Subject: [PATCH] Public Release. --- .gitignore | 10 + Gemfile | 6 + LICENSE | 19 ++ README.md | 223 ++++++++++++++++++ Rakefile | 15 ++ bin/centurion | 70 ++++++ bin/centurionize | 60 +++++ centurion.gemspec | 40 ++++ lib/capistrano_dsl.rb | 91 +++++++ lib/centurion.rb | 5 + lib/centurion/deploy.rb | 145 ++++++++++++ lib/centurion/deploy_dsl.rb | 94 ++++++++ lib/centurion/docker_registry.rb | 35 +++ lib/centurion/docker_server.rb | 58 +++++ lib/centurion/docker_server_group.rb | 31 +++ lib/centurion/docker_via_api.rb | 121 ++++++++++ lib/centurion/docker_via_cli.rb | 40 ++++ lib/centurion/logging.rb | 28 +++ lib/centurion/version.rb | 3 + lib/tasks/deploy.rake | 176 ++++++++++++++ lib/tasks/info.rake | 24 ++ lib/tasks/list.rake | 52 ++++ spec/capistrano_dsl_spec.rb | 67 ++++++ spec/deploy_dsl_spec.rb | 104 ++++++++ spec/deploy_spec.rb | 181 ++++++++++++++ spec/docker_server_group_spec.rb | 31 +++ spec/docker_server_spec.rb | 43 ++++ spec/docker_via_api_spec.rb | 111 +++++++++ spec/docker_via_cli_spec.rb | 26 ++ spec/logging_spec.rb | 41 ++++ spec/spec_helper.rb | 7 + .../matchers/capistrano_dsl_matchers.rb | 13 + 32 files changed, 1970 insertions(+) create mode 100644 .gitignore create mode 100644 Gemfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Rakefile create mode 100755 bin/centurion create mode 100755 bin/centurionize create mode 100644 centurion.gemspec create mode 100644 lib/capistrano_dsl.rb create mode 100644 lib/centurion.rb create mode 100644 lib/centurion/deploy.rb create mode 100644 lib/centurion/deploy_dsl.rb create mode 100644 lib/centurion/docker_registry.rb create mode 100644 lib/centurion/docker_server.rb create mode 100644 lib/centurion/docker_server_group.rb create mode 100644 lib/centurion/docker_via_api.rb create mode 100644 lib/centurion/docker_via_cli.rb create mode 100644 lib/centurion/logging.rb create mode 100644 lib/centurion/version.rb create mode 100644 lib/tasks/deploy.rake create mode 100644 lib/tasks/info.rake create mode 100644 lib/tasks/list.rake create mode 100644 spec/capistrano_dsl_spec.rb create mode 100644 spec/deploy_dsl_spec.rb create mode 100644 spec/deploy_spec.rb create mode 100644 spec/docker_server_group_spec.rb create mode 100644 spec/docker_server_spec.rb create mode 100644 spec/docker_via_api_spec.rb create mode 100644 spec/docker_via_cli_spec.rb create mode 100644 spec/logging_spec.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/support/matchers/capistrano_dsl_matchers.rb diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..01fa7900 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.bundle +.config +coverage/ +*.gem +Gemfile.lock +pkg/ +*.rbc +*.swp +*.swo +tmp/ diff --git a/Gemfile b/Gemfile new file mode 100644 index 00000000..ea88091e --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +source 'https://WiPJLMV2QdeRQ2XRu3Yt@gem.fury.io/newrelic/' +source 'https://rubygems.org' + +# Specify your gem's dependencies in centurion.gemspec +gemspec +gem 'rspec_junit_formatter', group: "test" diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..bc17bf58 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2014 New Relic, Inc. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..b332adba --- /dev/null +++ b/README.md @@ -0,0 +1,223 @@ +Centurion +========= + +A deployment tool for Docker. Takes containers from a Docker registry and runs +them on a fleet of hosts with the correct environment variables, host mappings, +and port mappings. Supports rolling deployments out of the box, and makes it +easy to ship applications to Docker servers. + +We're using it to run our production infrastructure. + +Centurion works in a two part deployment process where the build process ships +a container to the registry, and Centurion ships containers from the registry +to the Docker fleet. Registry support is handled by the Docker command line +tools directly so you can use anything they currently support via the normal +registry mechanism. + +If you haven't been using a registry, you should read up on how to do that +before trying to deploy anything with Centurion. Docker, Inc [provide +repositories](https://index.docker.io/), including the main public repository. +Alternatively, you can [host your +own](https://github.com/dotcloud/docker-registry), or +[Quay.io](https://quay.io) is another commercial option. + +Status +------ + +This project is under active development! The initial release on GitHub contains +one roll-up commit of all our internal code. But all internal devlopment will +now be on public GitHub. See the CONTRIBUTORS file for the contributors to the +original internal project. + +Installation +------------ + +Centurion is a Ruby gem. It assumes that you have a working, modern-ish Ruby +(1.9.3 or higher). On Ubuntu 12.04 you can install this with the `ruby-1.9.1` +system package, for example. On OSX this is best accomplished via `rbenv` and +`ruby-build` which can be installed with [Homebrew](http://brew.sh/) or from +[GitHub](https://github.com/sstephenson/rbenv). + +Once you have a running, modern Ruby, you simply: + +``` +$ gem install centurion +``` + +With rbenv you will now need to do and `rbenv rehash` and the commands should +be available. With a non-rbenv install, assuming the gem dir is in your path, +the commands should just work now. + +Configuration +------------- + +Centurion expects to find configuration tasks in the current working directory. +Soon it will also support reading configuration from etcd. + +We recommend putting all your configuration for multiple applications into a +single repo rather than spreading it around by project. This allows a central +choke point on configuration changes between applications and tends to work +well with the hand-off in many organizations between the build and deploy +steps. If you only have one application, or don't need this you can +decentralize the config into each repo. + +It will look for configuration files in either `./config/centurion` or `.`. + +The pattern at New Relic is to have a configs repo with a `Gemfile` that +sources the Centurion gem. If you want Centurion to set up the structure for +you and to create a sample config, you can simply run `centurionize` once you +have the Ruby Gem installed. + +Centurion ships with a simple scaffolding tool that will setup a new config repo for +you, as well as scaffold individual project configs. Here's how you run it: + +```bash +$ centurionize -p +``` + +`centurionize` relies on Bundler being installed already. Running the command +will have the following effects: + + * Ensure that a `config/centurion` directory exists + * Scaffold an example config for your project (you can specify the registry) + * Ensure that a Gemfile is present + * Ensure that Centurion is in the Gemfile (if absent it just appends it) + +Any time you add a new project you can scaffold it in the same manner even +in the same repo. + +###Writing configs + +If you used `centurionize` you will have a base config scaffolded for you. +But you'll still need to specify all of your configuration. + +Configs are in the form of a Rake task that uses a built-in DSL to make them +easy to write. Here's a sample config for a project called "radio-radio" that +would go into `config/centurion/radio-radio.rake`: + +```ruby +namespace :environment do + task :common do + set :image, 'example.com/newrelic/radio-radio' + host 'docker-server-1.example.com' + host 'docker-server-2.example.com' + end + + desc 'Staging environment' + task :staging => :common do + set_current_environment(:staging) + env_var YOUR_ENV: 'staging' + env_var MY_DB: 'radio-db.example.com' + host_port 10234, container_port: 9292 + host_port 10235, container_port: 9293 + hot_volume '/mnt/volume1', container_volume: '/mnt/volume2' + end + + desc 'Production environment' + task :production => :common do + set_current_environment(:production) + env_var YOUR_ENV: 'production' + env_var MY_DB: 'radio-db-prod.example.com' + host_port 22234, container_port: 9292 + host_port 23235, container_port: 9293 + end +end +``` + +This sets up a staging and productionenvironment and defines a `common` task +that will be run in either case. Note the dependency call in the task +definition for the `production` and `staging` tasks. Additionally, it defines +some host ports to map and sets which servers to deploy to. Some configuration +will provided to the containers at startup time, in the form of environment +variables. + +All of the DSL items (`host_port`, `host_volume`, `env_var`, `host`) can be +specified more than once and will append to the configuration. + +Deploying +--------- + +Centurion supports a number of tasks out of the box that make working with +distributed containers easy. Here are some examples: + +###Do a rolling deployment to a fleet of Docker servers + +A rolling deployment will stop and start each container one at a time to make +sure that the application stays available from the viewpoint of the load +balancer. Currently assumes a valid response in the 200 range on +`/status/check`. This will shortly be settable in your config. + +````bash +$ bundle exec centurion -p radio-radio -e staging -a rolling_deploy +```` + +###Deploy a project to a fleet of Docker servers + +This will hard stop, then start containers on all the specified hosts. This +is not recommended for apps where one endpoint needs to be available at all +times. + +````bash +$ bundle exec centurion -p radio-radio -e staging -a deploy +```` + +###List all the tags running on your servers for a particular project + +Returns a nicely-formatted list of all the current tags and which machines +they are running on. Gives a uniqued list of tags across all hosts as well. +This is useful for validating the state of the deployment in the case where +something goes wrong mid-deploy. + +```bash +$ bundle exec centurion -p radio-radio -e staging -a list:running_container_tags +``` + +###List all the containers currently running for this project + +Returns a (as yet not very nicely formatted) list of all the containers for +this project on each of the servers from the config. + +```bash +$ bundle exec centurion -p radio-radio -e staging -a list:running_containers +``` + +###List registry containers + +Returns a list of all the containers for this project in the registry. + +````bash +$ bundle exec centurion -p radio-radio -e staging -a list +```` + +Future Additions +---------------- + +We're currently looking at the following feature additions: + + * [etcd](https://github.com/coreos/etcd) integration for configs and discovery + * Add the ability to show all the available tasks on the command line + * Certificate authentication + * Customized tasks + * Settable status page URL for rolling deployment + * Dynamic host allocation to a pool of servers + +Contributions +------------- + +Contributions are more than welcome. Bug reports with specific reproduction +steps are great. If you have a code contribution you'd like to make, open a +pull request with suggested code. + +Pull requests should: + + * Clearly state their intent in the title + * Have a description that explains the need for the changes + * Include tests! + * Not break the public API + +If you are simply looking to contribute to the project, taking on one of the +items in the "Future Additions" section above would be a great place to start. +Ping us to let us know you're working on it by opening a GitHub Issue on the +project. + +Copyright (c) 2014 New Relic, Inc. All rights reserved. diff --git a/Rakefile b/Rakefile new file mode 100644 index 00000000..479e0762 --- /dev/null +++ b/Rakefile @@ -0,0 +1,15 @@ +$: << File.expand_path("lib") +require 'bundler/gem_tasks' + +begin + require 'rspec/core/rake_task' + + RSpec::Core::RakeTask.new(:spec) do |t| + t.rspec_opts = %w[--color --format=documentation] + t.pattern = "spec/**/*_spec.rb" + end + + task :default => [:spec] +rescue LoadError + # don't generate Rspec tasks if we don't have it installed +end diff --git a/bin/centurion b/bin/centurion new file mode 100755 index 00000000..39ccb58f --- /dev/null +++ b/bin/centurion @@ -0,0 +1,70 @@ +#!/usr/bin/env ruby +$: << File.join(File.dirname(__FILE__), '..', 'lib') +require_relative '../lib/centurion' +require 'capistrano_dsl' + +self.extend Capistrano::DSL +self.extend Centurion::DeployDSL +self.extend Centurion::Logging + +# +# Initialize Rake engine +# +require 'rake' +Rake.application.options.trace = true + +task_dir = File.expand_path(File.join(File.dirname(__FILE__), *%w{.. lib tasks})) +Dir.glob(File.join(task_dir, '*.rake')).each { |file| load file } + +possible_environments = %w[development integration staging production local_integration] +def possible_environments.to_s + join(', ').sub(/, (\w+)$/, ', or \1') +end + +# +# Trollup option setup +# +require 'trollop' + +opts = Trollop::options do + opt :project, 'project (dirac, rubicon...)', type: String, required: true, short: '-p' + opt :environment, "environment (#{possible_environments})", type: String, required: true, short: '-e' + opt :action, 'action (deploy, list...)', type: String, default: 'list', short: '-a' + opt :image, 'image (analytics/rubicon...)', type: String, required: false, short: '-i' + opt :tag, 'tag (latest...)', type: String, required: false, short: '-t' + opt :hosts, 'hosts, comma separated', type: String, required: false, short: '-h' + opt :docker_path, 'path to docker executable (default: docker)', type: String, default: 'docker', short: '-d' +end + +unless possible_environments.include?(opts[:environment]) + Trollop::die :environment, "is unknown; must be #{possible_environments}" +end + +set_current_environment(opts[:environment].to_sym) +set :project, opts[:project] +set :environment, opts[:environment] + +# Load the per-project config and execute the task for the current environment +projects_dir = File.join(Dir.getwd(), 'config', 'centurion') +config_file = "#{opts[:project]}.rake" +if File.exists?(File.join(projects_dir, config_file)) + load File.join(File.join(projects_dir, config_file)) +elsif File.exists?(config_file) + load config_file +else + raise "Can't find '#{config_file}'!" +end +invoke("environment:#{opts[:environment]}") + +# Override the config with command line values if given +set :image, opts[:image] if opts[:image] +set :tag, opts[:tag] if opts[:tag] +set :hosts, opts[:hosts].split(",") if opts[:hosts] + +# Default tag should be "latest" +set :tag, 'latest' unless any?(:tag) + +# Specify a path to docker executable +set :docker_path, opts[:docker_path] + +invoke(opts[:action]) diff --git a/bin/centurionize b/bin/centurionize new file mode 100755 index 00000000..0f233b06 --- /dev/null +++ b/bin/centurionize @@ -0,0 +1,60 @@ +#!/usr/bin/env ruby + +require 'fileutils' +require 'trollop' + +opts = Trollop::options do + opt :project, 'The short name (no spaces) of the project to scaffold', required: true, type: String + opt :registry_base, 'The base url for your registry (ex: example.com/yourcompany/) slash terminated', default: '' + opt :centurion_dir, 'The base dir for centurion configs', default: File.join(Dir.getwd, 'config', 'centurion') +end + +project_file = File.join(opts[:centurion_dir], "#{opts[:project]}.rake") + +unless Dir.exists?(opts[:centurion_dir]) + puts "Creating #{opts[:centurion_dir]}" + FileUtils.mkdir_p(opts[:centurion_dir]) +end + +unless File.exists?(project_file) + puts "Writing example config to #{project_file}" + File.write(project_file, <<-EOS.gsub(/^\s{4}/, '')) + namespace :environment do + task :common do + set :image, '#{opts[:registry_base]}#{opts[:project]}' + end + + desc 'Staging environment' + task :staging => :common do + set_current_environment(:staging) + #env_var YOUR_ENV: 'staging' + #host_port 10234, container_port: 9292 + #host 'docker-server-staging-1.example.com' + #host 'docker-server-staging-2.example.com' + end + + desc 'Production environment' + task :production => :common do + set_current_environment(:production) + #env_var YOUR_ENV: 'production' + #host_port 23235, container_port: 9293 + #host 'docker-server-prod-1.example.com' + #host 'docker-server-prod-2.example.com' + #host_volume '/mnt/volume1', container_volume: '/mnt/volume1' + end + end + EOS +end + +gemfile = File.join(opts[:centurion_dir], *%w{.. .. Gemfile}) +unless File.exists?(gemfile) + raise "Error creating Gemfile: $!" unless system("bash -c 'cd #{File.dirname(gemfile)} && bundle init'") +end + +unless File.read(gemfile) =~ /centurion/s + puts 'Adding Centurion to the Gemfile' + File.open(gemfile, 'a') { |f| f << "gem 'centurion'" } + puts "\n\nRemember to run `bundle install` before running Centurion\n\n" +end + +puts 'Done!' diff --git a/centurion.gemspec b/centurion.gemspec new file mode 100644 index 00000000..0760fcfc --- /dev/null +++ b/centurion.gemspec @@ -0,0 +1,40 @@ +# coding: utf-8 +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'centurion/version' + +Gem::Specification.new do |spec| + spec.name = 'centurion' + spec.version = Centurion::VERSION + spec.authors = [ + 'Nic Benders', 'Karl Matthias', 'Andrew Bloomgarden', 'Aaron Bento', + 'Paul Showalter', 'David Kerr', 'Jonathan Owens', 'Jon Guymon', + 'Merlyn Albery-Speyer', 'Amjith Ramanujam', 'David Celis', 'Emily Hyland', + 'Bryan Stearns'] + spec.email = [ + 'nic@newrelic.com', 'kmatthias@newrelic.com', 'andrew@newrelic.com', + 'aaron@newrelic.com', 'poeslacker@gmail.com', 'dkerr@newrelic.com', + 'jonathan@newrelic.com', 'jon@newrelic.com', 'merlyn@newrelic.com', + 'amjith@newrelic.com', 'dcelis@newrelic.com', 'ehyland@newrelic.com', + 'bryan@newrelic.com'] + spec.summary = %q{Deploy images to a Docker server} + spec.homepage = 'https://source.datanerd.us/site-engineering/centurion' + spec.license = "New Relic" + + spec.files = `git ls-files -z`.split("\x0") + spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } + spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) + spec.require_paths = ['lib'] + + spec.add_dependency 'trollop' + spec.add_dependency 'excon', '~> 0.33' + spec.add_dependency 'logger-colors' + + spec.add_development_dependency 'bundler' + spec.add_development_dependency 'rake' + spec.add_development_dependency 'rspec' + spec.add_development_dependency 'pry' + spec.add_development_dependency 'simplecov' + + spec.required_ruby_version = '>= 1.9.3' +end diff --git a/lib/capistrano_dsl.rb b/lib/capistrano_dsl.rb new file mode 100644 index 00000000..9f489791 --- /dev/null +++ b/lib/capistrano_dsl.rb @@ -0,0 +1,91 @@ +# This file borrows heavily from the Capistrano project, so although much of +# the code has been re-written at this point, we include their license here +# as a reminder. NOTE that THIS LICENSE ONLY APPLIES TO THIS FILE itself, not +# to the rest of the project. +# +# ORIGINAL CAPISTRANO LICENSE FOLLOWS: +# +# MIT License (MIT) +# +# Copyright (c) 2012-2013 Tom Clements, Lee Hambley +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +require 'singleton' + +module Capistrano + module DSL + module Env + class CurrentEnvironmentNotSetError < RuntimeError; end + + class Store < Hash + include Singleton + end + + def env + Store.instance + end + + def fetch(key, default=nil, &block) + env[current_environment][key] || default + end + + def any?(key) + value = fetch(key) + if value && value.respond_to?(:any?) + value.any? + else + !fetch(key).nil? + end + end + + def set(key, value) + env[current_environment][key] = value + end + + def delete(key) + env[current_environment].delete(key) + end + + def set_current_environment(environment) + env[:current_environment] = environment + env[environment] ||= {} + end + + def current_environment + raise CurrentEnvironmentNotSetError.new('Must set current environment') unless env[:current_environment] + env[:current_environment] + end + + def clear_env + env.clear + end + end + end +end + +module Capistrano + module DSL + include Env + + def invoke(task, *args) + Rake::Task[task].invoke(*args) + end + end +end diff --git a/lib/centurion.rb b/lib/centurion.rb new file mode 100644 index 00000000..7ee964bd --- /dev/null +++ b/lib/centurion.rb @@ -0,0 +1,5 @@ +Dir[File.join(File.dirname(__FILE__), 'centurion', '*')].each do |file| + require file +end + +module Centurion; end diff --git a/lib/centurion/deploy.rb b/lib/centurion/deploy.rb new file mode 100644 index 00000000..56f60752 --- /dev/null +++ b/lib/centurion/deploy.rb @@ -0,0 +1,145 @@ +require 'excon' + +module Centurion; end + +module Centurion::Deploy + FAILED_CONTAINER_VALIDATION = 100 + + def stop_containers(target_server, port_bindings) + public_port = public_port_for(port_bindings) + old_containers = target_server.find_containers_by_public_port(public_port) + info "Stopping container(s): #{old_containers.inspect}" + + old_containers.each do |old_container| + info "Stopping old container #{old_container['Id'][0..7]} (#{old_container['Names'].join(',')})" + target_server.stop_container(old_container['Id']) + end + end + + def wait_for_http_status_ok(target_server, port, image_id, tag, sleep_time=5, retries=12) + info 'Waiting for the port to come up' + 1.upto(retries) do + if container_up?(target_server, port) && http_status_ok?(target_server, port) + info 'Container is up!' + break + end + + info "Waiting #{sleep_time} seconds to test the /status/check endpoint..." + sleep(sleep_time) + end + + unless http_status_ok?(target_server, port) + error "Failed to validate started container on #{target_server}:#{port}" + exit(FAILED_CONTAINER_VALIDATION) + end + end + + def container_up?(target_server, port) + # The API returns a record set like this: + #[{"Command"=>"script/run ", "Created"=>1394470428, "Id"=>"41a68bda6eb0a5bb78bbde19363e543f9c4f0e845a3eb130a6253972051bffb0", "Image"=>"quay.io/newrelic/rubicon:5f23ac3fad7979cd1efdc9295e0d8c5707d1c806", "Names"=>["/happy_pike"], "Ports"=>[{"IP"=>"0.0.0.0", "PrivatePort"=>80, "PublicPort"=>8484, "Type"=>"tcp"}], "Status"=>"Up 13 seconds"}] + + running_containers = target_server.find_containers_by_public_port(port) + container = running_containers.pop + + unless running_containers.empty? + # This _should_ never happen, but... + error "More than one container is bound to port #{port} on #{target_server}!" + return false + end + + if container && container['Ports'].any? { |bind| bind['PublicPort'].to_i == port.to_i } + info "Found container up for #{Time.now.to_i - container['Created'].to_i} seconds" + return true + end + + false + end + + def http_status_ok?(target_server, port) + url = "http://#{target_server.hostname}:#{port}/status/check" + response = begin + Excon.get(url) + rescue Excon::Errors::SocketError + warn "Failed to connect to #{url}, no socket open." + nil + end + + return false unless response + return true if response.status >= 200 && response.status < 300 + + warn "Got HTTP status: #{response.status}" + false + end + + def wait_for_load_balancer_check_interval + sleep(fetch(:rolling_deploy_check_interval, 5)) + end + + def cleanup_containers(target_server, port_bindings) + public_port = public_port_for(port_bindings) + old_containers = target_server.old_containers_for_port(public_port) + old_containers.shift(2) + + info "Public port #{public_port}" + old_containers.each do |old_container| + info "Removing old container #{old_container['Id'][0..7]} (#{old_container['Names'].join(',')})" + target_server.remove_container(old_container['Id']) + end + end + + def container_config_for(target_server, image_id, port_bindings=nil, env_vars=nil) + container_config = { + 'Image' => image_id, + 'Hostname' => target_server.hostname, + } + + if port_bindings + container_config['ExposedPorts'] = { port_bindings.keys.first => {} } + end + + if env_vars + container_config['Env'] = env_vars.map { |k,v| "#{k}=#{v}" } + end + + container_config + end + + def start_new_container(target_server, image_id, port_bindings, volumes, env_vars=nil) + container_config = container_config_for(target_server, image_id, port_bindings, env_vars) + start_container_with_config(target_server, volumes, port_bindings, container_config) + end + + def launch_console(target_server, image_id, port_bindings, volumes, env_vars=nil) + container_config = container_config_for(target_server, image_id, port_bindings, env_vars).merge( + 'Cmd' => [ '/bin/bash' ], + 'AttachStdin' => true, + 'Tty' => true, + 'OpenStdin' => true, + ) + + container = start_container_with_config(target_server, volumes, port_bindings, container_config) + + target_server.attach(container['Id']) + end + + private + + def start_container_with_config(target_server, volumes, port_bindings, container_config) + info "Creating new container for #{container_config['Image'][0..7]}" + new_container = target_server.create_container(container_config) + + host_config = { + 'PortBindings' => port_bindings + } + # Map some host volumes if needed + host_config['Binds'] = volumes if volumes && !volumes.empty? + + info "Starting new container #{new_container['Id'][0..7]}" + target_server.start_container(new_container['Id'], host_config) + + info "Inspecting new container #{new_container['Id'][0..7]}:" + info target_server.inspect_container(new_container['Id']) + + new_container + end +end diff --git a/lib/centurion/deploy_dsl.rb b/lib/centurion/deploy_dsl.rb new file mode 100644 index 00000000..c4d63131 --- /dev/null +++ b/lib/centurion/deploy_dsl.rb @@ -0,0 +1,94 @@ +require_relative 'docker_server_group' +require 'uri' + +module Centurion::DeployDSL + def on_each_docker_host(&block) + Centurion::DockerServerGroup.new(fetch(:hosts, []), fetch(:docker_path)).tap do |hosts| + hosts.each { |host| block.call(host) } + end + end + + def env_vars(new_vars) + current = fetch(:env_vars, {}) + new_vars.each_pair do |new_key, new_value| + current[new_key.to_s] = new_value + end + set(:env_vars, current) + end + + def host(hostname) + current = fetch(:hosts, []) + current << hostname + set(:hosts, current) + end + + def localhost + # DOCKER_HOST is like 'tcp://127.0.0.1:4243' + docker_host_uri = URI.parse(ENV['DOCKER_HOST'] || "tcp://127.0.0.1") + host_and_port = [docker_host_uri.host, docker_host_uri.port].compact.join(':') + host(host_and_port) + end + + def host_port(port, options) + validate_options_keys(options, [ :host_ip, :container_port, :type ]) + require_options_keys(options, [ :container_port ]) + + add_to_bindings( + options[:host_ip] || '0.0.0.0', + options[:container_port], + port, + options[:type] || 'tcp' + ) + end + + def public_port_for(port_bindings) + # {'80/tcp'=>[{'HostIp'=>'0.0.0.0', 'HostPort'=>'80'}]} + first_port_binding = port_bindings.values.first + first_port_binding.first['HostPort'] + end + + def host_volume(volume, options) + validate_options_keys(options, [ :container_volume ]) + require_options_keys(options, [ :container_volume ]) + + binds = fetch(:binds, []) + container_volume = options[:container_volume] + + binds << "#{volume}:#{container_volume}" + set(:binds, binds) + end + + def get_current_tags_for(image) + hosts = Centurion::DockerServerGroup.new(fetch(:hosts), fetch(:docker_path)) + hosts.inject([]) do |memo, target_server| + tags = target_server.current_tags_for(image) + memo += [{ server: target_server.hostname, tags: tags }] if tags + memo + end + end + + private + + def add_to_bindings(host_ip, container_port, port, type='tcp') + set(:port_bindings, fetch(:port_bindings, {}).tap do |bindings| + bindings["#{container_port.to_s}/#{type}"] = [ + {'HostIp' => host_ip, 'HostPort' => port.to_s} + ] + bindings + end) + end + + def validate_options_keys(options, valid_keys) + unless options.keys.all? { |k| valid_keys.include?(k) } + raise ArgumentError.new('Options passed with invalid key!') + end + end + + def require_options_keys(options, required_keys) + missing = required_keys.reject { |k| options.keys.include?(k) } + + unless missing.empty? + raise ArgumentError.new("Options must contain #{missing.inspect}") + end + end +end diff --git a/lib/centurion/docker_registry.rb b/lib/centurion/docker_registry.rb new file mode 100644 index 00000000..0cd46f1b --- /dev/null +++ b/lib/centurion/docker_registry.rb @@ -0,0 +1,35 @@ +require 'excon' +require 'json' +require 'uri' + +module Centurion; end + +class Centurion::DockerRegistry + def initialize() + # @base_uri = "https://staging-docker-registry.nr-ops.net" + @base_uri = 'http://chi-docker-registry.nr-ops.net' + end + + def digest_for_tag( repository, tag) + path = "/v1/repositories/#{repository}/tags/#{tag}" + $stderr.puts "GET: #{path.inspect}" + response = Excon.get( + @base_uri + path, + :headers => { "Content-Type" => "application/json" } + ) + raise response.inspect unless response.status == 200 + + # This hack is stupid, and I hate it. But it works around the fact that + # the Docker Registry will return a base JSON String, which the Ruby parser + # refuses (possibly correctly) to handle + JSON.load('[' + response.body + ']').first + end + + def respository_tags( respository ) + path = "/v1/repositories/#{respository}/tags" + $stderr.puts "GET: #{path.inspect}" + response = Excon.get(@base_uri + path) + raise response.inspect unless response.status == 200 + JSON.load(response.body) + end +end diff --git a/lib/centurion/docker_server.rb b/lib/centurion/docker_server.rb new file mode 100644 index 00000000..cd5f81ec --- /dev/null +++ b/lib/centurion/docker_server.rb @@ -0,0 +1,58 @@ +require 'pty' +require 'forwardable' + +require_relative 'logging' +require_relative 'docker_via_api' +require_relative 'docker_via_cli' + +module Centurion; end + +class Centurion::DockerServer + include Centurion::Logging + extend Forwardable + + attr_reader :hostname, :port + + def_delegators :docker_via_api, :create_container, :inspect_container, + :inspect_image, :ps, :start_container, :stop_container, + :old_containers_for_port, :remove_container + def_delegators :docker_via_cli, :pull, :tail, :attach + + def initialize(host, docker_path) + @docker_path = docker_path + @hostname, @port = host.split(':') + @port ||= '4243' + end + + def current_tags_for(image) + running_containers = ps.select { |c| c['Image'] =~ /#{image}/ } + return [] if running_containers.empty? + + parse_image_tags_for(running_containers) + end + + def find_containers_by_public_port(public_port, type='tcp') + ps.select do |container| + if container['Ports'] + container['Ports'].find do |port| + port['PublicPort'] == public_port.to_i && port['Type'] == type + end + end + end + end + + private + + def docker_via_api + @docker_via_api ||= Centurion::DockerViaApi.new(@hostname, @port) + end + + def docker_via_cli + @docker_via_cli ||= Centurion::DockerViaCli.new(@hostname, @port, @docker_path) + end + + def parse_image_tags_for(running_containers) + running_container_names = running_containers.map { |c| c['Image'] } + running_container_names.map { |name| name.split(/:/).last } # (image, tag) + end +end diff --git a/lib/centurion/docker_server_group.rb b/lib/centurion/docker_server_group.rb new file mode 100644 index 00000000..ac250d60 --- /dev/null +++ b/lib/centurion/docker_server_group.rb @@ -0,0 +1,31 @@ +require_relative 'docker_server' +require_relative 'logging' + +module Centurion; end + +class Centurion::DockerServerGroup + include Enumerable + include Centurion::Logging + + attr_reader :hosts + + def initialize(hosts, docker_path) + raise ArgumentError.new('Bad Host list!') if hosts.nil? || hosts.empty? + @hosts = hosts.map { |hostname| Centurion::DockerServer.new(hostname, docker_path) } + end + + def each(&block) + @hosts.each do |host| + info "----- Connecting to Docker on #{host.hostname} -----" + block.call(host) + end + end + + def each_in_parallel(&block) + threads = @hosts.map do |host| + Thread.new { block.call(host) } + end + + threads.each { |t| t.join } + end +end diff --git a/lib/centurion/docker_via_api.rb b/lib/centurion/docker_via_api.rb new file mode 100644 index 00000000..02450814 --- /dev/null +++ b/lib/centurion/docker_via_api.rb @@ -0,0 +1,121 @@ +require 'excon' +require 'json' +require 'uri' + +module Centurion; end + +class Centurion::DockerViaApi + def initialize(hostname, port) + @base_uri = "http://#{hostname}:#{port}" + + configure_excon_globally + end + + def ps(options={}) + path = "/v1.7/containers/json" + path += "?all=1" if options[:all] + response = Excon.get(@base_uri + path) + + raise unless response.status == 200 + JSON.load(response.body) + end + + def inspect_image(image, tag = "latest") + repository = "#{image}:#{tag}" + path = "/v1.7/images/#{repository}/json" + + response = Excon.get( + @base_uri + path, + :headers => {'Accept' => 'application/json'} + ) + raise response.inspect unless response.status == 200 + JSON.load(response.body) + end + + def old_containers_for_port(host_port) + old_containers = ps(all: true).select do |container| + container["Status"] =~ /^Exit / + end.select do |container| + inspected = inspect_container container["Id"] + container_listening_on_port?(inspected, host_port) + end + old_containers + end + + def remove_container(container_id) + path = "/v1.7/containers/#{container_id}" + response = Excon.delete( + @base_uri + path, + ) + raise response.inspect unless response.status == 204 + true + end + + def stop_container(container_id) + path = "/v1.7/containers/#{container_id}/stop?t=30" + response = Excon.post( + @base_uri + path, + ) + raise response.inspect unless response.status == 204 + true + end + + def create_container(configuration) + path = "/v1.7/containers/create" + response = Excon.post( + @base_uri + path, + :body => configuration.to_json, + :headers => { "Content-Type" => "application/json" } + ) + raise response.inspect unless response.status == 201 + JSON.load(response.body) + end + + def start_container(container_id, configuration) + path = "/v1.7/containers/#{container_id}/start" + response = Excon.post( + @base_uri + path, + :body => configuration.to_json, + :headers => { "Content-Type" => "application/json" } + ) + case response.status + when 204 + true + when 500 + fail "Failed to start container! \"#{response.body}\"" + else + raise response.inspect + end + end + + def inspect_container(container_id) + path = "/v1.7/containers/#{container_id}/json" + response = Excon.get( + @base_uri + path, + ) + raise response.inspect unless response.status == 200 + JSON.load(response.body) + end + + private + + # use on result of inspect container, not on an item in a list + def container_listening_on_port?(container, port) + port_bindings = container['HostConfig']['PortBindings'] + return false unless port_bindings + + port_bindings.values.flatten.any? do |port_binding| + port_binding['HostPort'].to_i == port.to_i + end + end + + def configure_excon_globally + Excon.defaults[:connect_timeout] = 120 + Excon.defaults[:read_timeout] = 120 + Excon.defaults[:write_timeout] = 120 + Excon.defaults[:debug_request] = true + Excon.defaults[:debug_response] = true + Excon.defaults[:nonblock] = false + Excon.defaults[:tcp_nodelay] = true + end +end diff --git a/lib/centurion/docker_via_cli.rb b/lib/centurion/docker_via_cli.rb new file mode 100644 index 00000000..bd2fb1ef --- /dev/null +++ b/lib/centurion/docker_via_cli.rb @@ -0,0 +1,40 @@ +require 'pty' +require_relative 'logging' + +module Centurion; end + +class Centurion::DockerViaCli + include Centurion::Logging + + def initialize(hostname, port, docker_path) + @docker_host = "tcp://#{hostname}:#{port}" + @docker_path = docker_path + end + + def pull(image, tag='latest') + info "Using CLI to pull" + run_with_echo("#{@docker_path} -H=#{@docker_host} pull #{image}:#{tag}") + end + + def tail(container_id) + info "Tailing the logs on #{container_id}" + run_with_echo("#{@docker_path} -H=#{@docker_host} logs -f #{container_id}") + end + + def attach(container_id) + Process.exec("#{@docker_path} -H=#{@docker_host} attach #{container_id}") + end + + private + + def run_with_echo( command ) + $stdout.sync = true + $stderr.sync = true + IO.popen(command) do |io| + io.each_char { |char| print char } + end + unless $?.success? + raise "The command failed with a non-zero exit status: #{$?.exitstatus}" + end + end +end diff --git a/lib/centurion/logging.rb b/lib/centurion/logging.rb new file mode 100644 index 00000000..a12dfd18 --- /dev/null +++ b/lib/centurion/logging.rb @@ -0,0 +1,28 @@ +require 'logger/colors' +require 'logger' + +module Centurion; end + +module Centurion::Logging + def info(*args) + log.info args.join(' ') + end + + def warn(*args) + log.warn args.join(' ') + end + + def error(*args) + log.error args.join(' ') + end + + def debug(*args) + log.debug args.join(' ') + end + + private + + def log(*args) + @@logger ||= Logger.new(STDOUT) + end +end diff --git a/lib/centurion/version.rb b/lib/centurion/version.rb new file mode 100644 index 00000000..85ac7f4d --- /dev/null +++ b/lib/centurion/version.rb @@ -0,0 +1,3 @@ +module Centurion + VERSION = '1.0.0' +end diff --git a/lib/tasks/deploy.rake b/lib/tasks/deploy.rake new file mode 100644 index 00000000..817ea278 --- /dev/null +++ b/lib/tasks/deploy.rake @@ -0,0 +1,176 @@ +require 'thread' +require 'excon' +require 'centurion/deploy' + +task :deploy do + invoke 'deploy:get_image' + invoke 'deploy:stop' + invoke 'deploy:start_new' + invoke 'deploy:cleanup' +end + +task :deploy_console do + invoke 'deploy:get_image' + invoke 'deploy:stop' + invoke 'deploy:launch_console' + invoke 'deploy:cleanup' +end + +task :rolling_deploy do + invoke 'deploy:get_image' + invoke 'deploy:rolling_deploy' + invoke 'deploy:cleanup' +end + +task :stop => ['deploy:stop'] + +namespace :deploy do + include Centurion::Deploy + + task :get_image do + invoke 'deploy:pull_image' + invoke 'deploy:determine_image_id_from_first_server' + invoke 'deploy:verify_image' + end + + # stop + # - remote: list + # - remote: stop + task :stop do + on_each_docker_host { |server| stop_containers(server, fetch(:port_bindings)) } + end + + # start + # - remote: create + # - remote: start + # - remote: inspect container + task :start_new do + on_each_docker_host do |server| + start_new_container( + server, + fetch(:image_id), + fetch(:port_bindings), + fetch(:binds), + fetch(:env_vars) + ) + end + end + + task :launch_console do + on_each_docker_host do |server| + launch_console( + server, + fetch(:image_id), + fetch(:port_bindings), + fetch(:binds), + fetch(:env_vars) + ) + end + end + + task :rolling_deploy do + on_each_docker_host do |server| + stop_containers(server, fetch(:port_bindings)) + + start_new_container( + server, + fetch(:image_id), + fetch(:port_bindings), + fetch(:binds), + fetch(:env_vars) + ) + + fetch(:port_bindings).each_pair do |container_port, host_ports| + wait_for_http_status_ok( + server, + host_ports.first['HostPort'], + fetch(:image), + fetch(:tag), + fetch(:rolling_deploy_wait_time, 5), + fetch(:rolling_deploy_retries, 24) + ) + end + + wait_for_load_balancer_check_interval + end + end + + task :cleanup do + on_each_docker_host do |target_server| + cleanup_containers(target_server, fetch(:port_bindings)) + end + end + + task :determine_image_id do + registry = Centurion::DockerRegistry.new() + exact_image = registry.digest_for_tag(fetch(:image), fetch(:tag)) + set :image_id, exact_image + $stderr.puts "RESOLVED #{fetch(:image)}:#{fetch(:tag)} => #{exact_image[0..11]}" + end + + task :determine_image_id_from_first_server do + on_each_docker_host do |target_server| + image_detail = target_server.inspect_image(fetch(:image), fetch(:tag)) + exact_image = image_detail["id"] + set :image_id, exact_image + $stderr.puts "RESOLVED #{fetch(:image)}:#{fetch(:tag)} => #{exact_image[0..11]}" + break + end + end + + task :pull_image do + $stderr.puts "Fetching image #{fetch(:image)}:#{fetch(:tag)} IN PARALLEL\n" + + target_servers = Centurion::DockerServerGroup.new(fetch(:hosts), fetch(:docker_path)) + target_servers.each_in_parallel do |target_server| + target_server.pull(fetch(:image), fetch(:tag)) + end + end + + task :verify_image do + on_each_docker_host do |target_server| + image_detail = target_server.inspect_image(fetch(:image), fetch(:tag)) + found_image_id = image_detail["id"] + + if found_image_id == fetch(:image_id) + $stderr.puts "Image #{found_image_id[0..7]} found on #{target_server.hostname}" + else + raise "Did not find image #{fetch(:image_id)} on host #{target_server.hostname}!" + end + + # Print the container config + image_detail["container_config"].each_pair do |key,value| + $stderr.puts "\t#{key} => #{value.inspect}" + end + end + end + + task :promote_from_staging do + if fetch(:environment) == 'staging' + $stderr.puts "\n\nYour target environment needs to not be 'staging' to promote from staging." + exit(1) + end + + starting_environment = current_environment + + # Set our env to staging so we can grab the current tag. + invoke 'environment:staging' + + staging_tags = get_current_tags_for(fetch(:image)).map { |t| t[:tags] }.flatten.uniq + + if staging_tags.size != 1 + $stderr.puts "\n\nUh, oh: Not sure which staging tag to deploy! Found:(#{staging_tags.join(', ')})" + exit(1) + end + + $stderr.puts "Staging environment has #{staging_tags.first} deployed." + + # Make sure that we set our env back to production, then update the tag. + set_current_environment(starting_environment) + set :tag, staging_tags.first + + $stderr.puts "Deploying #{fetch(:tag)} to the #{starting_environment} environment" + + invoke 'deploy' + end +end diff --git a/lib/tasks/info.rake b/lib/tasks/info.rake new file mode 100644 index 00000000..f4a7ce2c --- /dev/null +++ b/lib/tasks/info.rake @@ -0,0 +1,24 @@ +task :info => 'info:default' + +namespace :info do + task :default do + puts "Environment: #{fetch(:environment)}" + puts "Project: #{fetch(:project)}" + puts "Image: #{fetch(:image)}" + puts "Tag: #{fetch(:tag)}" + puts "Port Bindings: #{fetch(:port_bindings).inspect}" + puts "Mount Point: #{fetch(:binds).inspect}" + puts "ENV: #{fetch(:env_vars).inspect}" + puts "Hosts: #{fetch(:hosts).inspect}" + end + + task :run_command do + example_host = fetch(:hosts).first + env_args = "" + fetch(:env_vars, {}).each_pair do |name,value| + env_args << "-e #{name}='#{value}' " + end + volume_args = fetch(:binds, []).map {|bind| "-v #{bind}"}.join(" ") + puts "docker -H=tcp://#{example_host} run #{env_args} #{volume_args} #{fetch(:image)}:#{fetch(:tag)}" + end +end diff --git a/lib/tasks/list.rake b/lib/tasks/list.rake new file mode 100644 index 00000000..a4fa385f --- /dev/null +++ b/lib/tasks/list.rake @@ -0,0 +1,52 @@ +require 'centurion/docker_registry' + +task :list do + invoke 'list:tags' + invoke 'list:running_containers' +end + +namespace :list do + task :running_container_tags do + + tags = get_current_tags_for(fetch(:image)) + + $stderr.puts "\n\nCurrent #{current_environment} tags for #{fetch(:image)}:\n\n" + tags.each do |info| + if info && !info[:tags].empty? + $stderr.puts "#{'%-20s' % info[:server]}: #{info[:tags].join(', ')}" + else + $stderr.puts "#{'%-20s' % info[:server]}: NO TAGS!" + end + end + + $stderr.puts "\nAll tags for this image: #{tags.map { |t| t[:tags] }.flatten.uniq.join(', ')}" + end + + task :tags do + begin + registry = Centurion::DockerRegistry.new() + tags = registry.respository_tags(fetch(:image)) + tags.each do |tag| + puts "\t#{tag[0]}\t-> #{tag[1][0..11]}" + end + rescue StandardError => e + error "Couldn't communicate with Registry: #{e.message}" + end + puts + end + + task :running_containers do + on_each_docker_host do |target_server| + begin + running_containers = target_server.ps + running_containers.each do |container| + puts container.inspect + end + rescue StandardError => e + error "Couldn't communicate with Docker on #{target_server.hostname}: #{e.message}" + raise + end + puts + end + end +end diff --git a/spec/capistrano_dsl_spec.rb b/spec/capistrano_dsl_spec.rb new file mode 100644 index 00000000..82c15f0f --- /dev/null +++ b/spec/capistrano_dsl_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' +require 'capistrano_dsl' + +class DSLTest + extend Capistrano::DSL +end + +describe Capistrano::DSL do + before do + DSLTest.clear_env + end + + context 'handling multiple environments' do + it 'sets the environment' do + expect { DSLTest.set_current_environment(:test) }.not_to raise_error + end + + it 'fetchs the current environment' do + DSLTest.set_current_environment(:test) + expect(DSLTest.current_environment).to eq(:test) + end + end + + context 'without a current environment set' do + it 'dies if the current_environment is not set' do + expect { DSLTest.set(:foo, 'asdf') }.to raise_error(Capistrano::DSL::CurrentEnvironmentNotSetError) + end + end + + context 'with a current environment set' do + before do + DSLTest.set_current_environment(:test) + end + + it 'stores variables in the environment' do + expect { DSLTest.set(:foo, 'bar') }.not_to raise_error + expect(DSLTest).to have_key_and_value(:foo, 'bar') + end + + it 'deletes keys from the environment' do + DSLTest.set(:foo, 'bar') + expect(DSLTest).to have_key_and_value(:foo, 'bar') + DSLTest.delete(:foo) + expect(DSLTest.fetch(:foo)).to be_nil + end + + it 'returns true for any? when the value exists' do + DSLTest.set(:foo, 'bar') + expect(DSLTest.any?(:foo)).to be_true + end + + it 'returns false for any? when the value does not exist' do + expect(DSLTest.any?(:foo)).to be_false + end + + it 'passes through the any? method to values that support it' do + class NoAny + def any? + 'oh no' + end + end + + DSLTest.set(:foo, NoAny.new) + DSLTest.any?(:foo).should eq('oh no') + end + end +end diff --git a/spec/deploy_dsl_spec.rb b/spec/deploy_dsl_spec.rb new file mode 100644 index 00000000..6ea1a9e5 --- /dev/null +++ b/spec/deploy_dsl_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' +require 'centurion/deploy_dsl' +require 'capistrano_dsl' + +class DeployDSLTest + extend Capistrano::DSL + extend Centurion::DeployDSL +end + +describe Centurion::DeployDSL do + before do + DeployDSLTest.clear_env + DeployDSLTest.set_current_environment('test') + end + + it 'exposes an easy wrapper for handling each Docker host' do + recipient = double('recipient') + expect(recipient).to receive(:ping).with('host1') + expect(recipient).to receive(:ping).with('host2') + + DeployDSLTest.set(:hosts, %w{ host1 host2 }) + DeployDSLTest.on_each_docker_host { |h| recipient.ping(h.hostname) } + end + + it 'adds new env_vars to the existing ones' do + DeployDSLTest.set(:env_vars, { 'SHAKESPEARE' => 'Hamlet' }) + DeployDSLTest.env_vars('DICKENS' => 'David Copperfield') + + expect(DeployDSLTest.fetch(:env_vars)).to include( + 'SHAKESPEARE' => 'Hamlet', + 'DICKENS' => 'David Copperfield' + ) + end + + it 'adds hosts to the host list' do + DeployDSLTest.set(:hosts, [ 'host1' ]) + DeployDSLTest.host('host2') + + expect(DeployDSLTest).to have_key_and_value(:hosts, %w{ host1 host2 }) + end + + describe '#localhost' do + it 'adds a host by reading DOCKER_HOST if present' do + expect(ENV).to receive(:[]).with('DOCKER_HOST').and_return('tcp://127.1.1.1:4240') + DeployDSLTest.localhost + expect(DeployDSLTest).to have_key_and_value(:hosts, %w[ 127.1.1.1:4240 ]) + end + + it 'adds a host defaulting to loopback if DOCKER_HOST is not present' do + expect(ENV).to receive(:[]).with('DOCKER_HOST').and_return(nil) + DeployDSLTest.localhost + expect(DeployDSLTest).to have_key_and_value(:hosts, %w[ 127.0.0.1 ]) + end + end + + describe '#host_port' do + it 'raises unless passed container_port in the options' do + expect { DeployDSLTest.host_port(666, {}) }.to raise_error(ArgumentError, /:container_port/) + end + + it 'adds new bind ports to the list' do + dummy_value = { '666/tcp' => ['value'] } + DeployDSLTest.set(:port_bindings, dummy_value) + DeployDSLTest.host_port(999, container_port: 80) + + expect(DeployDSLTest).to have_key_and_value( + :port_bindings, + dummy_value.merge('80/tcp' => [{ 'HostIp' => '0.0.0.0', 'HostPort' => '999' }]) + ) + end + + it 'does not explode if port_bindings is empty' do + expect { DeployDSLTest.host_port(999, container_port: 80) }.not_to raise_error + end + + it 'raises if invalid options are passed' do + expect { DeployDSLTest.host_port(80, asdf: 'foo') }.to raise_error(ArgumentError, /invalid key!/) + end + end + + describe '#host_volume' do + it 'raises unless passed the container_volume option' do + expect { DeployDSLTest.host_volume('foo', {}) }.to raise_error(ArgumentError, /:container_volume/) + end + + it 'raises when passed bogus options' do + expect { DeployDSLTest.host_volume('foo', bogus: 1) }.to raise_error(ArgumentError, /invalid key!/) + end + + it 'adds new host volumes' do + expect(DeployDSLTest.fetch(:binds)).to be_nil + DeployDSLTest.host_volume('volume1', container_volume: '/dev/sdd') + DeployDSLTest.host_volume('volume2', container_volume: '/dev/sde') + expect(DeployDSLTest.fetch(:binds)).to eq %w{ volume1:/dev/sdd volume2:/dev/sde } + end + end + + it 'gets current tags for an image' do + Centurion::DockerServer.any_instance.stub(current_tags_for: [ 'foo' ]) + DeployDSLTest.set(:hosts, [ 'host1' ]) + + expect(DeployDSLTest.get_current_tags_for('asdf')).to eq [ { server: 'host1', tags: [ 'foo'] } ] + end +end diff --git a/spec/deploy_spec.rb b/spec/deploy_spec.rb new file mode 100644 index 00000000..39403834 --- /dev/null +++ b/spec/deploy_spec.rb @@ -0,0 +1,181 @@ +require 'centurion/deploy' +require 'centurion/deploy_dsl' +require 'centurion/logging' + +class TestDeploy + extend Centurion::Deploy + extend Centurion::DeployDSL + extend Centurion::Logging +end + +describe Centurion::Deploy do + let(:mock_ok_status) { double('http_status_ok').tap { |s| s.stub(status: 200) } } + let(:mock_bad_status) { double('http_status_ok').tap { |s| s.stub(status: 500) } } + let(:server) { double('docker_server').tap { |s| s.stub(hostname: 'host1'); s.stub(:attach) } } + let(:port) { 8484 } + let(:container) { { 'Ports' => [{ 'PublicPort' => port }, 'Created' => Time.now.to_i ], 'Id' => '21adfd2ef2ef2349494a', 'Names' => [ 'name1' ] } } + + describe '#http_status_ok?' do + it 'validates HTTP status checks when the response is good' do + expect(Excon).to receive(:get).and_return(mock_ok_status) + expect(TestDeploy.http_status_ok?(server, port)).to be_true + end + + it 'identifies bad HTTP responses' do + expect(Excon).to receive(:get).and_return(mock_bad_status) + TestDeploy.stub(:warn) + expect(TestDeploy.http_status_ok?(server, port)).to be_false + end + + it 'outputs the HTTP status when it is not OK' do + expect(Excon).to receive(:get).and_return(mock_bad_status) + expect(TestDeploy).to receive(:warn).with(/Got HTTP status: 500/) + expect(TestDeploy.http_status_ok?(server, port)).to be_false + end + + it 'handles SocketErrors and outputs a message' do + expect(Excon).to receive(:get).and_raise(Excon::Errors::SocketError.new(RuntimeError.new())) + expect(TestDeploy).to receive(:warn).with(/Failed to connect/) + expect(TestDeploy.http_status_ok?(server, port)).to be_false + end + end + + describe '#container_up?' do + it 'recognizes when no containers are running' do + expect(server).to receive(:find_containers_by_public_port).and_return([]) + + TestDeploy.container_up?(server, port).should be_false + end + + it 'complains when more than one container is bound to this port' do + expect(server).to receive(:find_containers_by_public_port).and_return([1,2]) + expect(TestDeploy).to receive(:error).with /More than one container/ + + TestDeploy.container_up?(server, port).should be_false + end + + it 'recognizes when the container is actually running' do + expect(server).to receive(:find_containers_by_public_port).and_return([container]) + expect(TestDeploy).to receive(:info).with /Found container/ + + TestDeploy.container_up?(server, port).should be_true + end + end + + describe "#cleanup_containers" do + it "deletes all but two containers" do + expect(server).to receive(:old_containers_for_port).with(port.to_s).and_return([ + {'Id' => '123', 'Names' => ['foo']}, + {'Id' => '456', 'Names' => ['foo']}, + {'Id' => '789', 'Names' => ['foo']}, + {'Id' => '0ab', 'Names' => ['foo']}, + {'Id' => 'cde', 'Names' => ['foo']}, + ]) + expect(server).to receive(:remove_container).with("789") + expect(server).to receive(:remove_container).with("0ab") + expect(server).to receive(:remove_container).with("cde") + + TestDeploy.cleanup_containers(server, {"80/tcp" => [{"HostIp" => "0.0.0.0", "HostPort" => port.to_s}]}) + end + end + + describe '#stop_containers' do + it 'calls stop_container on the right containers' do + second_container = container.dup + containers = [ container, second_container ] + bindings = {'80/tcp'=>[{'HostIp'=>'0.0.0.0', 'HostPort'=>'80'}]} + + expect(server).to receive(:find_containers_by_public_port).and_return(containers) + expect(TestDeploy).to receive(:public_port_for).with(bindings).and_return('80') + expect(server).to receive(:stop_container).with(container['Id']).once + expect(server).to receive(:stop_container).with(second_container['Id']).once + + TestDeploy.stop_containers(server, bindings) + end + end + + describe '#wait_for_load_balancer_check_interval' do + it 'knows how long to sleep' do + timing = double(timing) + expect(TestDeploy).to receive(:fetch).with(:rolling_deploy_check_interval, 5).and_return(timing) + expect(TestDeploy).to receive(:sleep).with(timing) + + TestDeploy.wait_for_load_balancer_check_interval + end + end + + describe '#container_config_for' do + it 'works with env_vars provided' do + config = TestDeploy.container_config_for(server, 'image_id', {}, 'FOO' => 'BAR') + + expect(config).to be_a(Hash) + expect(config.keys).to match_array(%w{ Hostname Image Env ExposedPorts }) + expect(config['Env']).to eq(['FOO=BAR']) + end + + it 'works without env_vars or port_bindings' do + config = TestDeploy.container_config_for(server, 'image_id') + + expect(config).to be_a(Hash) + expect(config.keys).to match_array(%w{ Hostname Image }) + end + end + + describe '#start_new_container' do + let(:bindings) { {'80/tcp'=>[{'HostIp'=>'0.0.0.0', 'HostPort'=>'80'}]} } + + it 'configures the container' do + expect(TestDeploy).to receive(:container_config_for).with(server, 'image_id', bindings, nil).once + TestDeploy.stub(:start_container_with_config) + + TestDeploy.start_new_container(server, 'image_id', bindings, {}) + end + + it 'starts the container' do + expect(TestDeploy).to receive(:start_container_with_config).with(server, {}, anything(), anything()) + + TestDeploy.start_new_container(server, 'image_id', bindings, {}) + end + + it 'ultimately asks the server object to do the work' do + server.should_receive(:create_container).with( + hash_including({'Image'=>'image_id', 'Hostname'=>'host1', 'ExposedPorts'=>{'80/tcp'=>{}}}) + ).and_return(container) + + server.should_receive(:start_container) + server.should_receive(:inspect_container) + + new_container = TestDeploy.start_new_container(server, 'image_id', bindings, {}) + expect(new_container).to eq(container) + end + end + + describe '#launch_console' do + let(:bindings) { {'80/tcp'=>[{'HostIp'=>'0.0.0.0', 'HostPort'=>'80'}]} } + + it 'configures the container' do + expect(TestDeploy).to receive(:container_config_for).with(server, 'image_id', bindings, nil).once + TestDeploy.stub(:start_container_with_config) + + TestDeploy.start_new_container(server, 'image_id', bindings, {}) + end + + it 'augments the container_config' do + expect(TestDeploy).to receive(:start_container_with_config).with(server, {}, + anything(), + hash_including('Cmd' => [ '/bin/bash' ], 'AttachStdin' => true , 'Tty' => true , 'OpenStdin' => true) + ).and_return({'Id' => 'shakespeare'}) + + TestDeploy.launch_console(server, 'image_id', bindings, {}) + end + + it 'starts the console' do + expect(TestDeploy).to receive(:start_container_with_config).with( + server, {}, anything(), anything() + ).and_return({'Id' => 'shakespeare'}) + + TestDeploy.launch_console(server, 'image_id', bindings, {}) + expect(server).to have_received(:attach).with('shakespeare') + end + end +end diff --git a/spec/docker_server_group_spec.rb b/spec/docker_server_group_spec.rb new file mode 100644 index 00000000..849609a2 --- /dev/null +++ b/spec/docker_server_group_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' +require 'centurion/docker_server' +require 'centurion/docker_server_group' + +describe Centurion::DockerServerGroup do + let(:docker_path) { 'docker' } + let(:group) { Centurion::DockerServerGroup.new(['host1', 'host2'], docker_path) } + + it 'takes a hostlist and instantiates DockerServers' do + expect(group.hosts).to have(2).items + expect(group.hosts.first).to be_a(Centurion::DockerServer) + expect(group.hosts.last).to be_a(Centurion::DockerServer) + end + + it 'implements Enumerable' do + expect(group.methods).to be_a_kind_of(Enumerable) + end + + it 'prints a friendly message to stderr when iterating' do + expect(group).to receive(:info).with(/Connecting to Docker on host[0-9]/).twice + + group.each { |host| } + end + + it 'can run parallel operations' do + item = double('item').tap { |i| i.stub(:dummy_method) } + expect(item).to receive(:dummy_method).twice + + expect { group.each_in_parallel { |host| item.dummy_method } }.not_to raise_error + end +end diff --git a/spec/docker_server_spec.rb b/spec/docker_server_spec.rb new file mode 100644 index 00000000..fbe178fe --- /dev/null +++ b/spec/docker_server_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' +require 'centurion/docker_server' + +describe Centurion::DockerServer do + let(:host) { 'host1' } + let(:docker_path) { 'docker' } + let(:server) { Centurion::DockerServer.new(host, docker_path) } + + it 'knows its hostname' do + expect(server.hostname).to eq('host1') + end + + it 'knows its port' do + expect(server.port).to eq('4243') + end + + describe 'when host includes a port' do + let(:host) { 'host2:4321' } + it 'knows that port' do + expect(server.port).to eq('4321') + end + end + + { docker_via_api: [:create_container, :inspect_container, :inspect_image, + :ps, :start_container, :stop_container], + docker_via_cli: [:pull, :tail] }.each do |delegate, methods| + methods.each do |method| + it "delegates '#{method}' to #{delegate}" do + dummy_result = double + dummy_delegate = double(method => dummy_result) + server.stub(delegate => dummy_delegate) + expect(dummy_delegate).to receive(method) + expect(server.send(method)).to be(dummy_result) + end + end + end + + it 'returns tags associated with an image' do + image_names = %w[target:latest target:production other:latest] + server.stub(ps: image_names.map {|name| { 'Image' => name } }) + expect(server.current_tags_for('target')).to eq(%w[latest production]) + end +end diff --git a/spec/docker_via_api_spec.rb b/spec/docker_via_api_spec.rb new file mode 100644 index 00000000..a056f406 --- /dev/null +++ b/spec/docker_via_api_spec.rb @@ -0,0 +1,111 @@ +require 'spec_helper' +require 'centurion/docker_via_api' + +describe Centurion::DockerViaApi do + let(:hostname) { 'example.com' } + let(:port) { '4243' } + let(:api) { Centurion::DockerViaApi.new(hostname, port) } + let(:excon_uri) { "http://#{hostname}:#{port}/v1.7" } + let(:json_string) { '[{ "Hello": "World" }]' } + let(:json_value) { JSON.load(json_string) } + let(:inspected_containers) do + [ + {"Id" => "123", "Status" => "Exit 0"}, + {"Id" => "456", "Status" => "Running blah blah"}, + {"Id" => "789", "Status" => "Exit 1"}, + ] + end + + it 'lists processes' do + expect(Excon).to receive(:get). + with(excon_uri + "/containers/json"). + and_return(double(body: json_string, status: 200)) + expect(api.ps).to eq(json_value) + end + + it 'lists all processes' do + expect(Excon).to receive(:get). + with(excon_uri + "/containers/json?all=1"). + and_return(double(body: json_string, status: 200)) + expect(api.ps(all: true)).to eq(json_value) + end + + it 'inspects an image' do + expect(Excon).to receive(:get). + with(excon_uri + "/images/foo:bar/json", + headers: {'Accept' => 'application/json'}). + and_return(double(body: json_string, status: 200)) + expect(api.inspect_image('foo', 'bar')).to eq(json_value) + end + + it 'creates a container' do + configuration_as_json = double + configuration = double(:to_json => configuration_as_json) + expect(Excon).to receive(:post). + with(excon_uri + "/containers/create", + body: configuration_as_json, + headers: {'Content-Type' => 'application/json'}). + and_return(double(body: json_string, status: 201)) + api.create_container(configuration) + end + + it 'starts a container' do + configuration_as_json = double + configuration = double(:to_json => configuration_as_json) + expect(Excon).to receive(:post). + with(excon_uri + "/containers/12345/start", + body: configuration_as_json, + headers: {'Content-Type' => 'application/json'}). + and_return(double(body: json_string, status: 204)) + api.start_container('12345', configuration) + end + + it 'stops a container' do + expect(Excon).to receive(:post). + with(excon_uri + "/containers/12345/stop?t=30"). + and_return(double(status: 204)) + api.stop_container('12345') + end + + it 'inspects a container' do + expect(Excon).to receive(:get). + with(excon_uri + "/containers/12345/json"). + and_return(double(body: json_string, status: 200)) + expect(api.inspect_container('12345')).to eq(json_value) + end + + it 'removes a container' do + expect(Excon).to receive(:delete). + with(excon_uri + "/containers/12345"). + and_return(double(status: 204)) + expect(api.remove_container('12345')).to eq(true) + end + + it 'lists old containers for a port' do + expect(Excon).to receive(:get). + with(excon_uri + "/containers/json?all=1"). + and_return(double(body: inspected_containers.to_json, status: 200)) + expect(Excon).to receive(:get). + with(excon_uri + "/containers/123/json"). + and_return(double(body: inspected_container_on_port("123", 8485).to_json, status: 200)) + expect(Excon).to receive(:get). + with(excon_uri + "/containers/789/json"). + and_return(double(body: inspected_container_on_port("789", 8486).to_json, status: 200)) + + expect(api.old_containers_for_port(8485)).to eq([{"Id" => "123", "Status" => "Exit 0"}]) + end + + def inspected_container_on_port(id, port) + { + "Id" => id.to_s, + "HostConfig" => { + "PortBindings" => { + "80/tcp" => [ + "HostIp" => "0.0.0.0", + "HostPort" => port.to_s + ] + } + } + } + end +end diff --git a/spec/docker_via_cli_spec.rb b/spec/docker_via_cli_spec.rb new file mode 100644 index 00000000..6f65364b --- /dev/null +++ b/spec/docker_via_cli_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' +require 'centurion/docker_via_cli' + +describe Centurion::DockerViaCli do + let(:docker_path) { 'docker' } + let(:docker_via_cli) { Centurion::DockerViaCli.new('host1', 4243, docker_path) } + + it 'pulls the latest image given its name' do + expect(docker_via_cli).to receive(:run_with_echo). + with("docker -H=tcp://host1:4243 pull foo:latest") + docker_via_cli.pull('foo') + end + + it 'pulls an image given its name & tag' do + expect(docker_via_cli).to receive(:run_with_echo). + with("docker -H=tcp://host1:4243 pull foo:bar") + docker_via_cli.pull('foo', 'bar') + end + + it 'tails logs on a container' do + id = '12345abcdef' + expect(docker_via_cli).to receive(:run_with_echo). + with("docker -H=tcp://host1:4243 logs -f #{id}") + docker_via_cli.tail(id) + end +end diff --git a/spec/logging_spec.rb b/spec/logging_spec.rb new file mode 100644 index 00000000..3d0802ac --- /dev/null +++ b/spec/logging_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' +require 'centurion/logging' + +class TestLogging + extend Centurion::Logging + def self.logger + log + end +end + +describe Centurion::Logging do + let(:message) { %w{ something something_else } } + + context '#info' do + it 'passes through to Logger' do + expect(TestLogging.logger).to receive(:info).with(message.join(' ')) + TestLogging.info(*message) + end + end + + context '#warn' do + it 'passes through to Logger' do + expect(TestLogging.logger).to receive(:warn).with(message.join(' ')) + TestLogging.warn(*message) + end + end + + context '#debug' do + it 'passes through to Logger' do + expect(TestLogging.logger).to receive(:debug).with(message.join(' ')) + TestLogging.debug(*message) + end + end + + context '#error' do + it 'passes through to Logger' do + expect(TestLogging.logger).to receive(:error).with(message.join(' ')) + TestLogging.error(*message) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 00000000..50b499bd --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,7 @@ +require 'simplecov' +SimpleCov.start do + add_filter '/spec' +end + +$: << File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')) +Dir[File.dirname(__FILE__) + '/support/**/*.rb'].each {|f| require f} diff --git a/spec/support/matchers/capistrano_dsl_matchers.rb b/spec/support/matchers/capistrano_dsl_matchers.rb new file mode 100644 index 00000000..140111dd --- /dev/null +++ b/spec/support/matchers/capistrano_dsl_matchers.rb @@ -0,0 +1,13 @@ +RSpec::Matchers.define :have_key_and_value do |expected_key, expected_value| + match do |actual| + actual.env[actual.current_environment].has_key?(expected_key.to_sym) && (actual.fetch(expected_key.to_sym) == expected_value) + end + + failure_message_for_should do |actual| + "expected that #{actual.env[actual.current_environment].keys.inspect} would include #{expected_key.inspect} with value #{expected_value.inspect}" + end + + failure_message_for_should_not do |actual| + "expected that #{actual.env[actual.current_environment].keys.join(', ')} would not include #{expected_key.inspect} with value #{expected_value.inspect}" + end +end