ufo 0.0.6
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +5 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +128 -0
- data/Guardfile +12 -0
- data/LICENSE.txt +22 -0
- data/README.md +227 -0
- data/Rakefile +6 -0
- data/bin/ufo +10 -0
- data/lib/starter_project/.env.prod +3 -0
- data/lib/starter_project/Dockerfile +20 -0
- data/lib/starter_project/bin/deploy +12 -0
- data/lib/starter_project/ufo/settings.yml +7 -0
- data/lib/starter_project/ufo/task_definitions.rb +44 -0
- data/lib/starter_project/ufo/templates/main.json.erb +39 -0
- data/lib/ufo.rb +26 -0
- data/lib/ufo/aws_services.rb +21 -0
- data/lib/ufo/cli.rb +136 -0
- data/lib/ufo/cli/help.rb +164 -0
- data/lib/ufo/defaults.rb +46 -0
- data/lib/ufo/destroy.rb +60 -0
- data/lib/ufo/docker_builder.rb +120 -0
- data/lib/ufo/docker_cleaner.rb +52 -0
- data/lib/ufo/dockerfile_updater.rb +42 -0
- data/lib/ufo/dsl.rb +93 -0
- data/lib/ufo/dsl/helper.rb +47 -0
- data/lib/ufo/dsl/outputter.rb +40 -0
- data/lib/ufo/dsl/task_definition.rb +65 -0
- data/lib/ufo/ecr_auth.rb +35 -0
- data/lib/ufo/ecr_cleaner.rb +66 -0
- data/lib/ufo/execute.rb +19 -0
- data/lib/ufo/init.rb +83 -0
- data/lib/ufo/pretty_time.rb +14 -0
- data/lib/ufo/scale.rb +34 -0
- data/lib/ufo/settings.rb +33 -0
- data/lib/ufo/ship.rb +436 -0
- data/lib/ufo/tasks_builder.rb +30 -0
- data/lib/ufo/tasks_register.rb +51 -0
- data/lib/ufo/templates/default.json.erb +39 -0
- data/lib/ufo/version.rb +3 -0
- data/spec/fixtures/home_existing/.docker/config.json +10 -0
- data/spec/lib/cli_spec.rb +50 -0
- data/spec/lib/ecr_auth_spec.rb +39 -0
- data/spec/lib/ecr_cleaner_spec.rb +32 -0
- data/spec/lib/ship_spec.rb +77 -0
- data/spec/spec_helper.rb +28 -0
- data/ufo.gemspec +34 -0
- metadata +267 -0
data/lib/ufo/defaults.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
module Ufo
|
2
|
+
# To include this module must have this in initialize:
|
3
|
+
#
|
4
|
+
# def initialize(optiions, ...)
|
5
|
+
# @options = options
|
6
|
+
# ...
|
7
|
+
# end
|
8
|
+
#
|
9
|
+
# So @options must be set
|
10
|
+
module Defaults
|
11
|
+
# image: 123456789.dkr.ecr.us-east-1.amazonaws.com/sinatra
|
12
|
+
# # service to cluster mapping, overrides default cluster cli overrides this
|
13
|
+
# service_cluster:
|
14
|
+
# default: prod-lo
|
15
|
+
# hi-web-prod: prod-hi
|
16
|
+
# hi-clock-prod: prod-lo
|
17
|
+
# hi-worker-prod: prod-lo
|
18
|
+
#
|
19
|
+
# Assumes that @service is set in the class that the Defaults module is included in.
|
20
|
+
def default_cluster
|
21
|
+
service_cluster = settings.data["service_cluster"]
|
22
|
+
service_cluster[@service] || service_cluster["default"]
|
23
|
+
end
|
24
|
+
|
25
|
+
# These default service values only are used when a service is created by `ufo`
|
26
|
+
def default_maximum_percent
|
27
|
+
Integer(new_service_settings["maximum_percent"] || 200)
|
28
|
+
end
|
29
|
+
|
30
|
+
def default_minimum_healthy_percent
|
31
|
+
Integer(new_service_settings["minimum_healthy_percent"] || 100)
|
32
|
+
end
|
33
|
+
|
34
|
+
def default_desired_count
|
35
|
+
Integer(new_service_settings["desired_count"] || 1)
|
36
|
+
end
|
37
|
+
|
38
|
+
def new_service_settings
|
39
|
+
settings.data["new_service"] || {}
|
40
|
+
end
|
41
|
+
|
42
|
+
def settings
|
43
|
+
@settings ||= Settings.new(@options[:project_root])
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
data/lib/ufo/destroy.rb
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
module Ufo
|
2
|
+
class Destroy
|
3
|
+
include Defaults
|
4
|
+
include AwsServices
|
5
|
+
|
6
|
+
def initialize(service, options={})
|
7
|
+
@service = service
|
8
|
+
@options = options
|
9
|
+
@cluster = @options[:cluster] || default_cluster
|
10
|
+
end
|
11
|
+
|
12
|
+
def bye
|
13
|
+
unless are_you_sure?
|
14
|
+
puts "Phew, that was close"
|
15
|
+
exit
|
16
|
+
end
|
17
|
+
|
18
|
+
clusters = ecs.describe_clusters(clusters: [@cluster]).clusters
|
19
|
+
if clusters.size < 1
|
20
|
+
puts "The #{@cluster} cluster does not exist so there can be no service on that cluster to delete."
|
21
|
+
exit
|
22
|
+
end
|
23
|
+
|
24
|
+
services = ecs.describe_services(cluster: @cluster, services: [@service]).services
|
25
|
+
service = services.first
|
26
|
+
if service.nil?
|
27
|
+
puts "Unable to find #{@service} service to delete it."
|
28
|
+
exit
|
29
|
+
end
|
30
|
+
if service.status != "ACTIVE"
|
31
|
+
puts "The #{@service} service is not ACTIVE so no need to delete it."
|
32
|
+
exit
|
33
|
+
end
|
34
|
+
|
35
|
+
# changes desired size to 0
|
36
|
+
ecs.update_service(
|
37
|
+
desired_count: 0,
|
38
|
+
cluster: @cluster,
|
39
|
+
service: @service
|
40
|
+
)
|
41
|
+
# Cannot find all tasks scoped to a service. Only scoped to a cluster.
|
42
|
+
# So will not try to stop the tasks.
|
43
|
+
# ask to stop them
|
44
|
+
#
|
45
|
+
resp = ecs.delete_service(
|
46
|
+
cluster: @cluster,
|
47
|
+
service: @service
|
48
|
+
)
|
49
|
+
puts "#{@service} service has been scaled down to 0 and destroyed." unless @options[:mute]
|
50
|
+
end
|
51
|
+
|
52
|
+
def are_you_sure?
|
53
|
+
return true if @options[:force]
|
54
|
+
puts "You are about to destroy #{@service} service on #{@cluster} cluster."
|
55
|
+
print "Are you sure you want to do this? (y/n) "
|
56
|
+
answer = $stdin.gets.strip
|
57
|
+
answer =~ /^y/
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module Ufo
|
2
|
+
class DockerBuilder
|
3
|
+
include PrettyTime
|
4
|
+
include Execute
|
5
|
+
|
6
|
+
def initialize(options={})
|
7
|
+
@options = options
|
8
|
+
@project_root = options[:project_root] || '.'
|
9
|
+
@dockerfile = options[:dockerfile] || 'Dockerfile'
|
10
|
+
@image_namespace = options[:image_namespace] || 'ufo'
|
11
|
+
end
|
12
|
+
|
13
|
+
def build
|
14
|
+
start_time = Time.now
|
15
|
+
store_full_image_name
|
16
|
+
update_auth_token # call after store_full_image_name
|
17
|
+
|
18
|
+
command = "docker build -t #{full_image_name} -f #{@dockerfile} ."
|
19
|
+
say "Building docker image with:".green
|
20
|
+
say " #{command}".green
|
21
|
+
check_dockerfile_exists
|
22
|
+
command = "cd #{@project_root} && #{command}"
|
23
|
+
success = execute(command, use_system: true)
|
24
|
+
unless success
|
25
|
+
puts "The docker image fail to build. Are you sure the docker daemon is available? Try running: docker version"
|
26
|
+
exit 1
|
27
|
+
end
|
28
|
+
|
29
|
+
took = Time.now - start_time
|
30
|
+
say "Docker image #{full_image_name} built. " + "Took #{pretty_time(took)}.".green
|
31
|
+
end
|
32
|
+
|
33
|
+
def push
|
34
|
+
update_auth_token
|
35
|
+
start_time = Time.now
|
36
|
+
message = "Pushed #{full_image_name} docker image."
|
37
|
+
if @options[:noop]
|
38
|
+
message = "NOOP #{message}"
|
39
|
+
else
|
40
|
+
execute("docker push #{full_image_name}", use_system: true)
|
41
|
+
end
|
42
|
+
took = Time.now - start_time
|
43
|
+
message << " Took #{pretty_time(took)}.".green
|
44
|
+
puts message unless @options[:mute]
|
45
|
+
end
|
46
|
+
|
47
|
+
def check_dockerfile_exists
|
48
|
+
unless File.exist?("#{@project_root}/#{@dockerfile}")
|
49
|
+
puts "#{@dockerfile} does not exist. Are you sure it exists?"
|
50
|
+
exit 1
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def update_auth_token
|
55
|
+
return unless ecr_image?
|
56
|
+
repo_domain = "https://#{image_name.split('/').first}"
|
57
|
+
auth = EcrAuth.new(repo_domain)
|
58
|
+
auth.update
|
59
|
+
end
|
60
|
+
|
61
|
+
def ecr_image?
|
62
|
+
full_image_name =~ /\.amazonaws\.com/
|
63
|
+
end
|
64
|
+
|
65
|
+
# full_image - does not include the tag
|
66
|
+
def image_name
|
67
|
+
settings.data["image"]
|
68
|
+
end
|
69
|
+
|
70
|
+
# full_image - includes the tag
|
71
|
+
def full_image_name
|
72
|
+
unless File.exist?(docker_name_path)
|
73
|
+
puts "Unable to find #{docker_name_path} which contains the last docker image name that was used as a part of `ufo docker build`. Please run `ufo docker build` first."
|
74
|
+
exit 1
|
75
|
+
end
|
76
|
+
IO.read(docker_name_path).strip
|
77
|
+
end
|
78
|
+
|
79
|
+
# Store this in a file because this name gets reference in other tasks later
|
80
|
+
# and we want the image name to stay the same when the commands are run separate
|
81
|
+
# in different processes. So we store the state in a file.
|
82
|
+
# Only when a new docker build command gets run will the image name state be updated.
|
83
|
+
def store_full_image_name
|
84
|
+
dirname = File.dirname(docker_name_path)
|
85
|
+
FileUtils.mkdir_p(dirname) unless File.exist?(dirname)
|
86
|
+
full_image_name = "#{image_name}:#{@image_namespace}-#{timestamp}-#{git_sha}"
|
87
|
+
IO.write(docker_name_path, full_image_name)
|
88
|
+
IO.write("#{@project_root}/ufo/version", full_image_name)
|
89
|
+
end
|
90
|
+
|
91
|
+
def docker_name_path
|
92
|
+
# output gets entirely wiped by tasks builder so dotn use that folder
|
93
|
+
"#{@project_root}/ufo/docker_image_name_#{@image_namespace}.txt"
|
94
|
+
end
|
95
|
+
|
96
|
+
def timestamp
|
97
|
+
@timestamp ||= Time.now.strftime('%Y-%m-%dT%H-%M-%S')
|
98
|
+
end
|
99
|
+
|
100
|
+
def git_sha
|
101
|
+
return @git_sha if @git_sha
|
102
|
+
# always call this and dont use the execute method because of the noop option
|
103
|
+
@git_sha = `cd #{@project_root} && git rev-parse --short HEAD`
|
104
|
+
@git_sha.strip!
|
105
|
+
end
|
106
|
+
|
107
|
+
def settings
|
108
|
+
@settings ||= Settings.new(@project_root)
|
109
|
+
end
|
110
|
+
|
111
|
+
def update_dockerfile
|
112
|
+
updater = DockerfileUpdater.new(full_image_name, @options)
|
113
|
+
updater.update
|
114
|
+
end
|
115
|
+
|
116
|
+
def say(msg)
|
117
|
+
puts msg unless @options[:mute]
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module Ufo
|
2
|
+
class DockerCleaner
|
3
|
+
include Execute
|
4
|
+
|
5
|
+
def initialize(docker_image_name, options)
|
6
|
+
# docker_image_name does not containg the tag
|
7
|
+
# Example: 123456789.dkr.ecr.us-east-1.amazonaws.com/image
|
8
|
+
@docker_image_name = docker_image_name
|
9
|
+
@options = options
|
10
|
+
@keep = options[:keep] || 3
|
11
|
+
@tag_prefix = options[:tag_prefix] || "ufo"
|
12
|
+
end
|
13
|
+
|
14
|
+
def cleanup
|
15
|
+
return if delete_list.empty?
|
16
|
+
command = "docker rmi #{delete_list}"
|
17
|
+
say "Cleaning up docker images...".green
|
18
|
+
say "Running: #{"docker rmi #{delete_list}"}"
|
19
|
+
return if @options[:noop]
|
20
|
+
execute(command, use_system: false) # to use_system: false silence output
|
21
|
+
end
|
22
|
+
|
23
|
+
def delete_list
|
24
|
+
return @delete_list if @delete_list
|
25
|
+
|
26
|
+
out = execute("docker images", live: true) # live to override the noop cli options
|
27
|
+
name_regexp = Regexp.new(@docker_image_name)
|
28
|
+
# Example tag: ufo-2016-10-19T00-36-47-211b63a
|
29
|
+
tag_string = "#{@tag_prefix}-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-.{7}"
|
30
|
+
tag_regexp = Regexp.new(tag_string)
|
31
|
+
filtered_out = out.split("\n").select do |line|
|
32
|
+
name,tag = line.split(' ')
|
33
|
+
name =~ name_regexp && tag =~ tag_regexp
|
34
|
+
end
|
35
|
+
|
36
|
+
tags = filtered_out.map { |l| l.split(' ')[1] } # 2nd column is tag
|
37
|
+
tags = tags.sort.reverse # ordered by most recent images first
|
38
|
+
delete_tags = tags[@keep..-1]
|
39
|
+
if delete_tags.nil?
|
40
|
+
say "No images found that matched #{@docker_image_name}:#{tag_string}"
|
41
|
+
@delete_list = []
|
42
|
+
else
|
43
|
+
@delete_list = delete_tags.map { |t| "#{@docker_image_name}:#{t}" }.join(' ')
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def say(msg)
|
48
|
+
msg = "NOOP #{msg}" if @options[:noop]
|
49
|
+
puts msg unless @options[:mute]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Ufo
|
2
|
+
class DockerfileUpdater
|
3
|
+
def initialize(full_image_name, options={})
|
4
|
+
@full_image_name = full_image_name
|
5
|
+
@options = options
|
6
|
+
@project_root = options[:project_root] || '.'
|
7
|
+
end
|
8
|
+
|
9
|
+
def update
|
10
|
+
write_new_dockerfile
|
11
|
+
end
|
12
|
+
|
13
|
+
def current_dockerfile
|
14
|
+
@current_dockerfile ||= IO.read(dockerfile_path)
|
15
|
+
end
|
16
|
+
|
17
|
+
def dockerfile_path
|
18
|
+
"#{@project_root}/Dockerfile"
|
19
|
+
end
|
20
|
+
|
21
|
+
def new_dockerfile
|
22
|
+
lines = current_dockerfile.split("\n")
|
23
|
+
# replace FROM line
|
24
|
+
new_lines = lines.map do |line|
|
25
|
+
if line =~ /^FROM /
|
26
|
+
"FROM #{@full_image_name}"
|
27
|
+
else
|
28
|
+
line
|
29
|
+
end
|
30
|
+
end
|
31
|
+
new_lines.join("\n") + "\n"
|
32
|
+
end
|
33
|
+
|
34
|
+
def write_new_dockerfile
|
35
|
+
IO.write(dockerfile_path, new_dockerfile)
|
36
|
+
unless @options[:mute]
|
37
|
+
puts "The Dockerfile FROM statement has been updated with the latest base image:".green
|
38
|
+
puts " #{@full_image_name}".green
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
data/lib/ufo/dsl.rb
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
require 'ostruct'
|
2
|
+
|
3
|
+
module Ufo
|
4
|
+
autoload :TaskDefinition, 'ufo/dsl/task_definition'
|
5
|
+
autoload :Outputter, 'ufo/dsl/outputter'
|
6
|
+
autoload :Helper, 'ufo/dsl/helper'
|
7
|
+
|
8
|
+
class DSL
|
9
|
+
def initialize(template_definitions_path, options={})
|
10
|
+
@template_definitions_path = template_definitions_path
|
11
|
+
@options = options
|
12
|
+
@project_root = options[:project_root] || '.'
|
13
|
+
@task_definitions = []
|
14
|
+
@outputters = []
|
15
|
+
end
|
16
|
+
|
17
|
+
def run
|
18
|
+
evaluate_template_definitions
|
19
|
+
build_task_definitions
|
20
|
+
write_outputs
|
21
|
+
end
|
22
|
+
|
23
|
+
# All we're doing at this point is saving blocks of code into memory
|
24
|
+
# The instance_eval provides the task_definition and helper methods as they are part
|
25
|
+
# of this class.
|
26
|
+
def evaluate_template_definitions
|
27
|
+
source_code = IO.read(@template_definitions_path)
|
28
|
+
instance_eval(source_code, @template_definitions_path)
|
29
|
+
end
|
30
|
+
|
31
|
+
def build_task_definitions
|
32
|
+
puts "Generating Task Definitions:" unless @options[:quiet]
|
33
|
+
clean_existing_task_definitions
|
34
|
+
@task_definitions.each do |task|
|
35
|
+
erb_result = task.build
|
36
|
+
@outputters << Outputter.new(task.task_definition_name, erb_result, @options)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def clean_existing_task_definitions
|
41
|
+
# removing 1 file a a time instead of recursing removing the directory to be safe
|
42
|
+
Dir.glob("#{@options[:project_root]}/ufo/output/*").each do |path|
|
43
|
+
FileUtils.rm_f(path)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def write_outputs
|
48
|
+
@outputters.each do |outputter|
|
49
|
+
outputter.write
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# methods available in task_definitionintions
|
54
|
+
def task_definition(name, &block)
|
55
|
+
@task_definitions << TaskDefinition.new(self, name, @options, &block)
|
56
|
+
end
|
57
|
+
|
58
|
+
def env_vars(text)
|
59
|
+
lines = filtered_lines(text)
|
60
|
+
lines.map do |line|
|
61
|
+
key,value = line.strip.split("=").map {|x| x.strip}
|
62
|
+
{
|
63
|
+
name: key,
|
64
|
+
value: value,
|
65
|
+
}
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def env_file(path)
|
70
|
+
full_path = "#{@project_root}/#{path}"
|
71
|
+
unless File.exist?(full_path)
|
72
|
+
puts "The #{full_path} env file could not be found. Are you sure it exists?"
|
73
|
+
exit 1
|
74
|
+
end
|
75
|
+
text = IO.read(full_path)
|
76
|
+
env_vars(text)
|
77
|
+
end
|
78
|
+
|
79
|
+
def filtered_lines(text)
|
80
|
+
lines = text.split("\n")
|
81
|
+
# remove comment at the end of the line
|
82
|
+
lines.map! { |l| l.sub(/#.*/,'').strip }
|
83
|
+
# filter out commented lines
|
84
|
+
lines = lines.reject { |l| l =~ /(^|\s)#/i }
|
85
|
+
# filter out empty lines
|
86
|
+
lines = lines.reject { |l| l.strip.empty? }
|
87
|
+
end
|
88
|
+
|
89
|
+
def helper
|
90
|
+
Helper.new(@options)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# Some of variables are from the Dockerfile and some are from other places.
|
2
|
+
#
|
3
|
+
# * helper.full_image_name - Docker image name to be used when a the docker image is build. This is defined in ufo/settings.yml.
|
4
|
+
# * helper.dockerfile_port - Expose port in the Dockerfile. Only supports one exposed port, the first one that is encountered.
|
5
|
+
|
6
|
+
# Simply aggregates a bunch of variables that is useful for the task_definition.
|
7
|
+
module Ufo
|
8
|
+
class DSL
|
9
|
+
# provides some helperally context variables
|
10
|
+
class Helper
|
11
|
+
def initialize(options={})
|
12
|
+
@options = options
|
13
|
+
@project_root = options[:project_root] || '.'
|
14
|
+
end
|
15
|
+
|
16
|
+
##############
|
17
|
+
# helper variables
|
18
|
+
def dockerfile_port
|
19
|
+
dockerfile_path = "#{@project_root}/Dockerfile"
|
20
|
+
if File.exist?(dockerfile_path)
|
21
|
+
parse_for_dockerfile_port(dockerfile_path)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def full_image_name
|
26
|
+
DockerBuilder.new(@options).full_image_name
|
27
|
+
end
|
28
|
+
|
29
|
+
#############
|
30
|
+
# helper methods
|
31
|
+
def settings
|
32
|
+
@settings ||= Settings.new(@project_root)
|
33
|
+
end
|
34
|
+
|
35
|
+
def parse_for_dockerfile_port(dockerfile_path)
|
36
|
+
lines = IO.read(dockerfile_path).split("\n")
|
37
|
+
expose_line = lines.find { |l| l =~ /^EXPOSE / }
|
38
|
+
if expose_line
|
39
|
+
md = expose_line.match(/EXPOSE (\d+)/)
|
40
|
+
port = md[1] if md
|
41
|
+
end
|
42
|
+
port.to_i if port
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|