centurion 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'centurion/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'centurion'
8
+ spec.version = Centurion::VERSION
9
+ spec.authors = [
10
+ 'Nic Benders', 'Karl Matthias', 'Andrew Bloomgarden', 'Aaron Bento',
11
+ 'Paul Showalter', 'David Kerr', 'Jonathan Owens', 'Jon Guymon',
12
+ 'Merlyn Albery-Speyer', 'Amjith Ramanujam', 'David Celis', 'Emily Hyland',
13
+ 'Bryan Stearns']
14
+ spec.email = [
15
+ 'nic@newrelic.com', 'kmatthias@newrelic.com', 'andrew@newrelic.com',
16
+ 'aaron@newrelic.com', 'poeslacker@gmail.com', 'dkerr@newrelic.com',
17
+ 'jonathan@newrelic.com', 'jon@newrelic.com', 'merlyn@newrelic.com',
18
+ 'amjith@newrelic.com', 'dcelis@newrelic.com', 'ehyland@newrelic.com',
19
+ 'bryan@newrelic.com']
20
+ spec.summary = %q{Deploy images to a fleet of Docker servers}
21
+ spec.homepage = 'https://github.com/newrelic/centurion'
22
+ spec.license = 'MIT'
23
+
24
+ spec.files = `git ls-files -z`.split("\x0")
25
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
26
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
27
+ spec.require_paths = ['lib']
28
+
29
+ spec.add_dependency 'trollop'
30
+ spec.add_dependency 'excon', '~> 0.33'
31
+ spec.add_dependency 'logger-colors'
32
+
33
+ spec.add_development_dependency 'bundler'
34
+ spec.add_development_dependency 'rake'
35
+ spec.add_development_dependency 'rspec'
36
+ spec.add_development_dependency 'pry'
37
+ spec.add_development_dependency 'simplecov'
38
+
39
+ spec.required_ruby_version = '>= 1.9.3'
40
+ end
@@ -0,0 +1,91 @@
1
+ # This file borrows heavily from the Capistrano project, so although much of
2
+ # the code has been re-written at this point, we include their license here
3
+ # as a reminder. NOTE that THIS LICENSE ONLY APPLIES TO THIS FILE itself, not
4
+ # to the rest of the project.
5
+ #
6
+ # ORIGINAL CAPISTRANO LICENSE FOLLOWS:
7
+ #
8
+ # MIT License (MIT)
9
+ #
10
+ # Copyright (c) 2012-2013 Tom Clements, Lee Hambley
11
+ #
12
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ # of this software and associated documentation files (the "Software"), to deal
14
+ # in the Software without restriction, including without limitation the rights
15
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ # copies of the Software, and to permit persons to whom the Software is
17
+ # furnished to do so, subject to the following conditions:
18
+ #
19
+ # The above copyright notice and this permission notice shall be included in
20
+ # all copies or substantial portions of the Software.
21
+ #
22
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
28
+ # THE SOFTWARE.
29
+
30
+ require 'singleton'
31
+
32
+ module Capistrano
33
+ module DSL
34
+ module Env
35
+ class CurrentEnvironmentNotSetError < RuntimeError; end
36
+
37
+ class Store < Hash
38
+ include Singleton
39
+ end
40
+
41
+ def env
42
+ Store.instance
43
+ end
44
+
45
+ def fetch(key, default=nil, &block)
46
+ env[current_environment][key] || default
47
+ end
48
+
49
+ def any?(key)
50
+ value = fetch(key)
51
+ if value && value.respond_to?(:any?)
52
+ value.any?
53
+ else
54
+ !fetch(key).nil?
55
+ end
56
+ end
57
+
58
+ def set(key, value)
59
+ env[current_environment][key] = value
60
+ end
61
+
62
+ def delete(key)
63
+ env[current_environment].delete(key)
64
+ end
65
+
66
+ def set_current_environment(environment)
67
+ env[:current_environment] = environment
68
+ env[environment] ||= {}
69
+ end
70
+
71
+ def current_environment
72
+ raise CurrentEnvironmentNotSetError.new('Must set current environment') unless env[:current_environment]
73
+ env[:current_environment]
74
+ end
75
+
76
+ def clear_env
77
+ env.clear
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ module Capistrano
84
+ module DSL
85
+ include Env
86
+
87
+ def invoke(task, *args)
88
+ Rake::Task[task].invoke(*args)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,5 @@
1
+ Dir[File.join(File.dirname(__FILE__), 'centurion', '*')].each do |file|
2
+ require file
3
+ end
4
+
5
+ module Centurion; end
@@ -0,0 +1,145 @@
1
+ require 'excon'
2
+
3
+ module Centurion; end
4
+
5
+ module Centurion::Deploy
6
+ FAILED_CONTAINER_VALIDATION = 100
7
+
8
+ def stop_containers(target_server, port_bindings)
9
+ public_port = public_port_for(port_bindings)
10
+ old_containers = target_server.find_containers_by_public_port(public_port)
11
+ info "Stopping container(s): #{old_containers.inspect}"
12
+
13
+ old_containers.each do |old_container|
14
+ info "Stopping old container #{old_container['Id'][0..7]} (#{old_container['Names'].join(',')})"
15
+ target_server.stop_container(old_container['Id'])
16
+ end
17
+ end
18
+
19
+ def wait_for_http_status_ok(target_server, port, endpoint, image_id, tag, sleep_time=5, retries=12)
20
+ info 'Waiting for the port to come up'
21
+ 1.upto(retries) do
22
+ if container_up?(target_server, port) && http_status_ok?(target_server, port, endpoint)
23
+ info 'Container is up!'
24
+ break
25
+ end
26
+
27
+ info "Waiting #{sleep_time} seconds to test the #{endpoint} endpoint..."
28
+ sleep(sleep_time)
29
+ end
30
+
31
+ unless http_status_ok?(target_server, port, endpoint)
32
+ error "Failed to validate started container on #{target_server}:#{port}"
33
+ exit(FAILED_CONTAINER_VALIDATION)
34
+ end
35
+ end
36
+
37
+ def container_up?(target_server, port)
38
+ # The API returns a record set like this:
39
+ #[{"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"}]
40
+
41
+ running_containers = target_server.find_containers_by_public_port(port)
42
+ container = running_containers.pop
43
+
44
+ unless running_containers.empty?
45
+ # This _should_ never happen, but...
46
+ error "More than one container is bound to port #{port} on #{target_server}!"
47
+ return false
48
+ end
49
+
50
+ if container && container['Ports'].any? { |bind| bind['PublicPort'].to_i == port.to_i }
51
+ info "Found container up for #{Time.now.to_i - container['Created'].to_i} seconds"
52
+ return true
53
+ end
54
+
55
+ false
56
+ end
57
+
58
+ def http_status_ok?(target_server, port, endpoint)
59
+ url = "http://#{target_server.hostname}:#{port}#{endpoint}"
60
+ response = begin
61
+ Excon.get(url)
62
+ rescue Excon::Errors::SocketError
63
+ warn "Failed to connect to #{url}, no socket open."
64
+ nil
65
+ end
66
+
67
+ return false unless response
68
+ return true if response.status >= 200 && response.status < 300
69
+
70
+ warn "Got HTTP status: #{response.status}"
71
+ false
72
+ end
73
+
74
+ def wait_for_load_balancer_check_interval
75
+ sleep(fetch(:rolling_deploy_check_interval, 5))
76
+ end
77
+
78
+ def cleanup_containers(target_server, port_bindings)
79
+ public_port = public_port_for(port_bindings)
80
+ old_containers = target_server.old_containers_for_port(public_port)
81
+ old_containers.shift(2)
82
+
83
+ info "Public port #{public_port}"
84
+ old_containers.each do |old_container|
85
+ info "Removing old container #{old_container['Id'][0..7]} (#{old_container['Names'].join(',')})"
86
+ target_server.remove_container(old_container['Id'])
87
+ end
88
+ end
89
+
90
+ def container_config_for(target_server, image_id, port_bindings=nil, env_vars=nil)
91
+ container_config = {
92
+ 'Image' => image_id,
93
+ 'Hostname' => target_server.hostname,
94
+ }
95
+
96
+ if port_bindings
97
+ container_config['ExposedPorts'] = { port_bindings.keys.first => {} }
98
+ end
99
+
100
+ if env_vars
101
+ container_config['Env'] = env_vars.map { |k,v| "#{k}=#{v}" }
102
+ end
103
+
104
+ container_config
105
+ end
106
+
107
+ def start_new_container(target_server, image_id, port_bindings, volumes, env_vars=nil)
108
+ container_config = container_config_for(target_server, image_id, port_bindings, env_vars)
109
+ start_container_with_config(target_server, volumes, port_bindings, container_config)
110
+ end
111
+
112
+ def launch_console(target_server, image_id, port_bindings, volumes, env_vars=nil)
113
+ container_config = container_config_for(target_server, image_id, port_bindings, env_vars).merge(
114
+ 'Cmd' => [ '/bin/bash' ],
115
+ 'AttachStdin' => true,
116
+ 'Tty' => true,
117
+ 'OpenStdin' => true,
118
+ )
119
+
120
+ container = start_container_with_config(target_server, volumes, port_bindings, container_config)
121
+
122
+ target_server.attach(container['Id'])
123
+ end
124
+
125
+ private
126
+
127
+ def start_container_with_config(target_server, volumes, port_bindings, container_config)
128
+ info "Creating new container for #{container_config['Image'][0..7]}"
129
+ new_container = target_server.create_container(container_config)
130
+
131
+ host_config = {
132
+ 'PortBindings' => port_bindings
133
+ }
134
+ # Map some host volumes if needed
135
+ host_config['Binds'] = volumes if volumes && !volumes.empty?
136
+
137
+ info "Starting new container #{new_container['Id'][0..7]}"
138
+ target_server.start_container(new_container['Id'], host_config)
139
+
140
+ info "Inspecting new container #{new_container['Id'][0..7]}:"
141
+ info target_server.inspect_container(new_container['Id'])
142
+
143
+ new_container
144
+ end
145
+ end
@@ -0,0 +1,94 @@
1
+ require_relative 'docker_server_group'
2
+ require 'uri'
3
+
4
+ module Centurion::DeployDSL
5
+ def on_each_docker_host(&block)
6
+ Centurion::DockerServerGroup.new(fetch(:hosts, []), fetch(:docker_path)).tap do |hosts|
7
+ hosts.each { |host| block.call(host) }
8
+ end
9
+ end
10
+
11
+ def env_vars(new_vars)
12
+ current = fetch(:env_vars, {})
13
+ new_vars.each_pair do |new_key, new_value|
14
+ current[new_key.to_s] = new_value
15
+ end
16
+ set(:env_vars, current)
17
+ end
18
+
19
+ def host(hostname)
20
+ current = fetch(:hosts, [])
21
+ current << hostname
22
+ set(:hosts, current)
23
+ end
24
+
25
+ def localhost
26
+ # DOCKER_HOST is like 'tcp://127.0.0.1:4243'
27
+ docker_host_uri = URI.parse(ENV['DOCKER_HOST'] || "tcp://127.0.0.1")
28
+ host_and_port = [docker_host_uri.host, docker_host_uri.port].compact.join(':')
29
+ host(host_and_port)
30
+ end
31
+
32
+ def host_port(port, options)
33
+ validate_options_keys(options, [ :host_ip, :container_port, :type ])
34
+ require_options_keys(options, [ :container_port ])
35
+
36
+ add_to_bindings(
37
+ options[:host_ip] || '0.0.0.0',
38
+ options[:container_port],
39
+ port,
40
+ options[:type] || 'tcp'
41
+ )
42
+ end
43
+
44
+ def public_port_for(port_bindings)
45
+ # {'80/tcp'=>[{'HostIp'=>'0.0.0.0', 'HostPort'=>'80'}]}
46
+ first_port_binding = port_bindings.values.first
47
+ first_port_binding.first['HostPort']
48
+ end
49
+
50
+ def host_volume(volume, options)
51
+ validate_options_keys(options, [ :container_volume ])
52
+ require_options_keys(options, [ :container_volume ])
53
+
54
+ binds = fetch(:binds, [])
55
+ container_volume = options[:container_volume]
56
+
57
+ binds << "#{volume}:#{container_volume}"
58
+ set(:binds, binds)
59
+ end
60
+
61
+ def get_current_tags_for(image)
62
+ hosts = Centurion::DockerServerGroup.new(fetch(:hosts), fetch(:docker_path))
63
+ hosts.inject([]) do |memo, target_server|
64
+ tags = target_server.current_tags_for(image)
65
+ memo += [{ server: target_server.hostname, tags: tags }] if tags
66
+ memo
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def add_to_bindings(host_ip, container_port, port, type='tcp')
73
+ set(:port_bindings, fetch(:port_bindings, {}).tap do |bindings|
74
+ bindings["#{container_port.to_s}/#{type}"] = [
75
+ {'HostIp' => host_ip, 'HostPort' => port.to_s}
76
+ ]
77
+ bindings
78
+ end)
79
+ end
80
+
81
+ def validate_options_keys(options, valid_keys)
82
+ unless options.keys.all? { |k| valid_keys.include?(k) }
83
+ raise ArgumentError.new('Options passed with invalid key!')
84
+ end
85
+ end
86
+
87
+ def require_options_keys(options, required_keys)
88
+ missing = required_keys.reject { |k| options.keys.include?(k) }
89
+
90
+ unless missing.empty?
91
+ raise ArgumentError.new("Options must contain #{missing.inspect}")
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,35 @@
1
+ require 'excon'
2
+ require 'json'
3
+ require 'uri'
4
+
5
+ module Centurion; end
6
+
7
+ class Centurion::DockerRegistry
8
+ def initialize()
9
+ # @base_uri = "https://staging-docker-registry.nr-ops.net"
10
+ @base_uri = 'http://chi-docker-registry.nr-ops.net'
11
+ end
12
+
13
+ def digest_for_tag( repository, tag)
14
+ path = "/v1/repositories/#{repository}/tags/#{tag}"
15
+ $stderr.puts "GET: #{path.inspect}"
16
+ response = Excon.get(
17
+ @base_uri + path,
18
+ :headers => { "Content-Type" => "application/json" }
19
+ )
20
+ raise response.inspect unless response.status == 200
21
+
22
+ # This hack is stupid, and I hate it. But it works around the fact that
23
+ # the Docker Registry will return a base JSON String, which the Ruby parser
24
+ # refuses (possibly correctly) to handle
25
+ JSON.load('[' + response.body + ']').first
26
+ end
27
+
28
+ def respository_tags( respository )
29
+ path = "/v1/repositories/#{respository}/tags"
30
+ $stderr.puts "GET: #{path.inspect}"
31
+ response = Excon.get(@base_uri + path)
32
+ raise response.inspect unless response.status == 200
33
+ JSON.load(response.body)
34
+ end
35
+ end
@@ -0,0 +1,58 @@
1
+ require 'pty'
2
+ require 'forwardable'
3
+
4
+ require_relative 'logging'
5
+ require_relative 'docker_via_api'
6
+ require_relative 'docker_via_cli'
7
+
8
+ module Centurion; end
9
+
10
+ class Centurion::DockerServer
11
+ include Centurion::Logging
12
+ extend Forwardable
13
+
14
+ attr_reader :hostname, :port
15
+
16
+ def_delegators :docker_via_api, :create_container, :inspect_container,
17
+ :inspect_image, :ps, :start_container, :stop_container,
18
+ :old_containers_for_port, :remove_container
19
+ def_delegators :docker_via_cli, :pull, :tail, :attach
20
+
21
+ def initialize(host, docker_path)
22
+ @docker_path = docker_path
23
+ @hostname, @port = host.split(':')
24
+ @port ||= '4243'
25
+ end
26
+
27
+ def current_tags_for(image)
28
+ running_containers = ps.select { |c| c['Image'] =~ /#{image}/ }
29
+ return [] if running_containers.empty?
30
+
31
+ parse_image_tags_for(running_containers)
32
+ end
33
+
34
+ def find_containers_by_public_port(public_port, type='tcp')
35
+ ps.select do |container|
36
+ if container['Ports']
37
+ container['Ports'].find do |port|
38
+ port['PublicPort'] == public_port.to_i && port['Type'] == type
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def docker_via_api
47
+ @docker_via_api ||= Centurion::DockerViaApi.new(@hostname, @port)
48
+ end
49
+
50
+ def docker_via_cli
51
+ @docker_via_cli ||= Centurion::DockerViaCli.new(@hostname, @port, @docker_path)
52
+ end
53
+
54
+ def parse_image_tags_for(running_containers)
55
+ running_container_names = running_containers.map { |c| c['Image'] }
56
+ running_container_names.map { |name| name.split(/:/).last } # (image, tag)
57
+ end
58
+ end