centurion 1.0.6

Sign up to get free protection for your applications and to get access to all the features.
@@ -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