shipitron 0.1.0 → 0.2.0

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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +4 -0
  3. data/.gitignore +4 -0
  4. data/.rspec +3 -0
  5. data/Dockerfile +60 -0
  6. data/Dockerfile.release +49 -0
  7. data/README.md +44 -10
  8. data/build_dev.sh +39 -0
  9. data/exe/shipitron +5 -0
  10. data/lib/shipitron/cli.rb +132 -0
  11. data/lib/shipitron/client/bootstrap_application.rb +35 -0
  12. data/lib/shipitron/client/create_ecs_services.rb +81 -0
  13. data/lib/shipitron/client/deploy_application.rb +34 -0
  14. data/lib/shipitron/client/ensure_deploy_not_running.rb +54 -0
  15. data/lib/shipitron/client/load_application_config.rb +41 -0
  16. data/lib/shipitron/client/load_templates.rb +45 -0
  17. data/lib/shipitron/client/register_ecs_task_definitions.rb +75 -0
  18. data/lib/shipitron/client/run_ecs_tasks.rb +141 -0
  19. data/lib/shipitron/consul_keys.rb +34 -0
  20. data/lib/shipitron/consul_lock.rb +28 -0
  21. data/lib/shipitron/docker_image.rb +23 -0
  22. data/lib/shipitron/ecs_client.rb +22 -0
  23. data/lib/shipitron/ecs_task_def.rb +17 -0
  24. data/lib/shipitron/fetch_bucket.rb +26 -0
  25. data/lib/shipitron/logger.rb +33 -0
  26. data/lib/shipitron/mustache_yaml_parser.rb +22 -0
  27. data/lib/shipitron/parse_templates.rb +32 -0
  28. data/lib/shipitron/post_build.rb +32 -0
  29. data/lib/shipitron/server/deploy_application.rb +75 -0
  30. data/lib/shipitron/server/docker/build_image.rb +25 -0
  31. data/lib/shipitron/server/docker/configure.rb +35 -0
  32. data/lib/shipitron/server/docker/push_image.rb +37 -0
  33. data/lib/shipitron/server/docker/run_build_script.rb +53 -0
  34. data/lib/shipitron/server/download_build_cache.rb +44 -0
  35. data/lib/shipitron/server/ecs_task_defs/map_parsed_templates.rb +31 -0
  36. data/lib/shipitron/server/ecs_task_defs/update_from_params.rb +42 -0
  37. data/lib/shipitron/server/ecs_task_defs/update_in_place.rb +68 -0
  38. data/lib/shipitron/server/git/clone_local_copy.rb +46 -0
  39. data/lib/shipitron/server/git/configure.rb +40 -0
  40. data/lib/shipitron/server/git/download_cache.rb +56 -0
  41. data/lib/shipitron/server/git/pull_repo.rb +30 -0
  42. data/lib/shipitron/server/git/update_cache.rb +52 -0
  43. data/lib/shipitron/server/git/upload_cache.rb +61 -0
  44. data/lib/shipitron/server/run_post_build.rb +79 -0
  45. data/lib/shipitron/server/transform_cli_args.rb +85 -0
  46. data/lib/shipitron/server/update_ecs_services.rb +105 -0
  47. data/lib/shipitron/server/update_ecs_task_definitions.rb +47 -0
  48. data/lib/shipitron/server/upload_build_cache.rb +43 -0
  49. data/lib/shipitron/smash.rb +10 -0
  50. data/lib/shipitron/version.rb +1 -1
  51. data/lib/shipitron.rb +38 -0
  52. data/scripts/docker-entrypoint.sh +18 -0
  53. data/scripts/fetch-bundler-data.sh +16 -0
  54. data/shipitron.gemspec +14 -0
  55. metadata +236 -4
@@ -0,0 +1,75 @@
1
+ require 'shipitron'
2
+ require 'shipitron/ecs_client'
3
+ require 'shipitron/mustache_yaml_parser'
4
+
5
+ module Shipitron
6
+ module Client
7
+ class RegisterEcsTaskDefinitions
8
+ include Metaractor
9
+ include EcsClient
10
+
11
+ required :region
12
+ required :task_def_directory
13
+
14
+ def call
15
+ Logger.info 'Creating ECS task definitions'
16
+
17
+ task_defs = Pathname.new(task_def_directory)
18
+ unless task_defs.directory?
19
+ fail_with_error!(
20
+ message: "task definition directory '#{task_def_directory}' does not exist"
21
+ )
22
+ end
23
+
24
+ task_defs.find do |path|
25
+ next if path.directory?
26
+
27
+ task_def = Smash.load(
28
+ path.to_s,
29
+ parser: MustacheYamlParser.new(
30
+ context: {
31
+ tag: 'latest'
32
+ }
33
+ )
34
+ )
35
+
36
+ begin
37
+ ecs_client(region: region).describe_task_definition(
38
+ task_definition: task_def.family
39
+ )
40
+ rescue Aws::ECS::Errors::ClientException => e
41
+ raise if e.message != 'Unable to describe task definition.'
42
+
43
+ Logger.info "Creating task definition '#{task_def.family}'"
44
+ Logger.debug "Task definition: #{task_def.to_h}"
45
+ ecs_client(region: region).register_task_definition(
46
+ task_def.to_h
47
+ )
48
+ else
49
+ Logger.info "Task definition '#{task_def.family}' already exists."
50
+ end
51
+ end
52
+
53
+ Logger.info 'Done'
54
+ rescue Aws::ECS::Errors::ServiceError => e
55
+ fail_with_errors!(messages: [
56
+ "Error: #{e.message}",
57
+ e.backtrace.join("\n")
58
+ ])
59
+ end
60
+
61
+ private
62
+ def region
63
+ context.region
64
+ end
65
+
66
+ def cluster_name
67
+ context.cluster_name
68
+ end
69
+
70
+ def task_def_directory
71
+ context.task_def_directory
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,141 @@
1
+ require 'shipitron'
2
+ require 'shipitron/ecs_client'
3
+ require 'shellwords'
4
+ require 'base64'
5
+
6
+ module Shipitron
7
+ module Client
8
+ class RunEcsTasks
9
+ include Metaractor
10
+ include EcsClient
11
+
12
+ required :application
13
+ required :clusters
14
+ required :shipitron_task
15
+ required :repository_url
16
+ required :s3_cache_bucket
17
+ required :image_name
18
+ required :ecs_task_defs
19
+ optional :ecs_task_def_templates
20
+ required :ecs_services
21
+ optional :ecs_service_templates
22
+ optional :build_script
23
+ optional :post_builds
24
+ optional :simulate
25
+
26
+ before do
27
+ context.post_builds ||= []
28
+ context.ecs_task_def_templates ||= []
29
+ context.ecs_service_templates ||= []
30
+ end
31
+
32
+ def call
33
+ Logger.info "Skipping ECS run_task calls due to --simulate" if simulate?
34
+
35
+ clusters.each do |cluster|
36
+ begin
37
+ if simulate?
38
+ command_args(cluster)
39
+ next
40
+ end
41
+
42
+ response = ecs_client(region: cluster.region).run_task(
43
+ cluster: cluster.name,
44
+ task_definition: shipitron_task,
45
+ overrides: {
46
+ container_overrides: [
47
+ {
48
+ name: 'shipitron',
49
+ command: command_args(cluster)
50
+ }
51
+ ]
52
+ },
53
+ count: 1,
54
+ started_by: 'shipitron'
55
+ )
56
+
57
+ if !response.failures.empty?
58
+ response.failures.each do |failure|
59
+ fail_with_error! message: "ECS run_task failure: #{failure.arn}: #{failure.reason}"
60
+ end
61
+ end
62
+
63
+ rescue Aws::ECS::Errors::ServiceError => e
64
+ fail_with_errors!(messages: [
65
+ "Error: #{e.message}",
66
+ e.backtrace.join("\n")
67
+ ])
68
+ end
69
+ end
70
+ end
71
+
72
+ private
73
+ def application
74
+ context.application
75
+ end
76
+
77
+ def clusters
78
+ context.clusters
79
+ end
80
+
81
+ def shipitron_task
82
+ context.shipitron_task
83
+ end
84
+
85
+ def escape(str)
86
+ Shellwords.escape(str)
87
+ end
88
+
89
+ def escaped(sym)
90
+ escape(context[sym])
91
+ end
92
+
93
+ def command_args(cluster)
94
+ [
95
+ 'server_deploy',
96
+ '--name', escaped(:application),
97
+ '--repository', escaped(:repository_url),
98
+ '--bucket', escaped(:s3_cache_bucket),
99
+ '--image-name', escaped(:image_name),
100
+ '--region', escape(cluster.region),
101
+ '--cluster-name', escape(cluster.name),
102
+ ].tap do |ary|
103
+ ary << '--ecs-task-defs'
104
+ ary.concat(context.ecs_task_defs.each {|s| escape(s)})
105
+
106
+ ary << '--ecs-services'
107
+ ary.concat(context.ecs_services.each {|s| escape(s)})
108
+
109
+ if context.build_script != nil
110
+ ary.concat ['--build-script', escaped(:build_script)]
111
+ end
112
+
113
+ if !context.post_builds.empty?
114
+ ary << '--post-builds'
115
+ ary.concat(context.post_builds.map(&:to_s).each {|s| escape(s)})
116
+ end
117
+
118
+ if !context.ecs_task_def_templates.empty?
119
+ ary << '--ecs-task-def-templates'
120
+ ary.concat(context.ecs_task_def_templates.map {|t| Base64.urlsafe_encode64(t)})
121
+ end
122
+
123
+ if !context.ecs_service_templates.empty?
124
+ ary << '--ecs-service-templates'
125
+ ary.concat(context.ecs_service_templates.map {|t| Base64.urlsafe_encode64(t)})
126
+ end
127
+
128
+ if simulate?
129
+ Logger.info "server_deploy command: #{ary.join(' ')}"
130
+ else
131
+ Logger.debug "server_deploy command: #{ary.join(' ')}"
132
+ end
133
+ end
134
+ end
135
+
136
+ def simulate?
137
+ context.simulate == true
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,34 @@
1
+ require 'shipitron'
2
+ require 'diplomat'
3
+
4
+ module Shipitron
5
+ module ConsulKeys
6
+ extend self
7
+
8
+ def configure_consul_client!
9
+ if ENV['CONSUL_HOST'].nil?
10
+ raise 'Environment variable CONSUL_HOST required'
11
+ end
12
+
13
+ Diplomat.configure do |config|
14
+ config.url = "http://#{ENV['CONSUL_HOST']}:8500"
15
+ end
16
+ end
17
+
18
+ def fetch_key(key:)
19
+ Logger.debug "Fetching key #{key}"
20
+ value = Diplomat::Kv.get(key, {}, :return)
21
+ value = nil if value == ''
22
+ value
23
+ end
24
+
25
+ def fetch_key!(key:)
26
+ fetch_key(key: key).tap do |value|
27
+ if value.nil?
28
+ raise "Key #{key} not found in consul!"
29
+ end
30
+ end
31
+ end
32
+
33
+ end
34
+ end
@@ -0,0 +1,28 @@
1
+ require 'diplomat'
2
+
3
+ module Shipitron
4
+ module ConsulLock
5
+ extend self
6
+
7
+ class UnableToLock < StandardError; end
8
+
9
+ def with_lock(key:)
10
+ sessionid = nil
11
+ locked = false
12
+ sessionid = Diplomat::Session.create(Name: "#{key}.lock")
13
+ locked = Diplomat::Lock.acquire(key, sessionid)
14
+
15
+ if locked
16
+ yield
17
+ else
18
+ raise UnableToLock
19
+ end
20
+ ensure
21
+ if sessionid != nil
22
+ Diplomat::Lock.release(key, sessionid) if locked
23
+ Diplomat::Session.destroy(sessionid)
24
+ end
25
+ end
26
+
27
+ end
28
+ end
@@ -0,0 +1,23 @@
1
+ require 'shipitron'
2
+
3
+ module Shipitron
4
+ class DockerImage < Hashie::Dash
5
+ property :name
6
+ property :tag
7
+
8
+ def name_with_tag(tag_override = nil)
9
+ tag_str = [tag_override, tag, ''].find {|str| !str.nil? }
10
+ tag_str = tag_str.to_s
11
+
12
+ if !tag_str.empty? && !tag_str.start_with?(':')
13
+ tag_str = tag_str.dup.prepend(':')
14
+ end
15
+
16
+ "#{name}#{tag_str}"
17
+ end
18
+
19
+ def to_s
20
+ name_with_tag
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,22 @@
1
+ require 'aws-sdk'
2
+
3
+ module Shipitron
4
+ module EcsClient
5
+ def ecs_client(region:)
6
+ @ecs_clients ||= {}
7
+ @ecs_clients[region] ||= generate_ecs_client(region: region)
8
+ end
9
+
10
+ def generate_ecs_client(region:)
11
+ options = {region: region}
12
+ if Shipitron.config.aws_access_key_id? && Shipitron.config.aws_secret_access_key
13
+ options.merge!(
14
+ access_key_id: Shipitron.config.aws_access_key_id,
15
+ secret_access_key: Shipitron.config.aws_secret_access_key
16
+ )
17
+ end
18
+
19
+ Aws::ECS::Client.new(options)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ require 'shipitron'
2
+
3
+ module Shipitron
4
+ class EcsTaskDef < Hashie::Dash
5
+ property :name
6
+ property :revision
7
+ property :params
8
+
9
+ def name_with_revision
10
+ "#{name}:#{revision}"
11
+ end
12
+
13
+ def to_s
14
+ name_with_revision
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ require 'fog/aws'
2
+ require 'fog/local' if ENV['FOG_LOCAL']
3
+
4
+ module Shipitron
5
+ class FetchBucket
6
+ include Metaractor
7
+
8
+ required :name
9
+
10
+ def call
11
+ if ENV['FOG_LOCAL']
12
+ Logger.debug 'Using fog local storage'
13
+ storage = Fog::Storage.new provider: 'Local', local_root: '/fog'
14
+ context.bucket = storage.directories.create(key: name)
15
+ else
16
+ storage = Fog::Storage.new provider: 'AWS', use_iam_profile: true
17
+ context.bucket = storage.directories.get(name)
18
+ end
19
+ end
20
+
21
+ private
22
+ def name
23
+ context.name
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,33 @@
1
+ require 'logger'
2
+
3
+ module Shipitron
4
+ class Logger
5
+ class << self
6
+ %i[debug info warn error fatal].each do |sym|
7
+ define_method(sym) do |message|
8
+ logger.send(sym, "#{Thread.current[:logger_tag]}#{message}")
9
+ end
10
+ end
11
+ end
12
+
13
+ def self.tagged(tag)
14
+ existing_tag = Thread.current[:logger_tag]
15
+ Thread.current[:logger_tag] = "[#{tag}] "
16
+ yield
17
+ ensure
18
+ Thread.current[:logger_tag] = existing_tag
19
+ end
20
+
21
+ def self.logger
22
+ Thread.current[:logger] ||= ::Logger.new(STDOUT)
23
+ end
24
+
25
+ def self.level
26
+ logger.level
27
+ end
28
+
29
+ def self.level=(new_level)
30
+ logger.level = new_level
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,22 @@
1
+ require 'mustache'
2
+ require 'yaml'
3
+
4
+ module Shipitron
5
+ class MustacheYamlParser
6
+ def initialize(context:nil, view:nil)
7
+ if (context.nil? && view.nil?) || (!context.nil? && !view.nil?)
8
+ raise ArgumentError, 'Either context or view required'
9
+ end
10
+
11
+ @context = context
12
+ @view = view
13
+
14
+ @view ||= Mustache
15
+ end
16
+
17
+ def perform(file_path)
18
+ file_path = file_path.is_a?(Pathname) ? file_path.to_s : file_path
19
+ YAML.load(@view.render(File.read(file_path), @context))
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,32 @@
1
+ require 'shipitron'
2
+ require 'yaml'
3
+ require 'mustache'
4
+
5
+ module Shipitron
6
+ module Server
7
+ class ParseTemplates
8
+ include Metaractor
9
+
10
+ required :templates
11
+ required :template_context
12
+
13
+ def call
14
+ parsed = []
15
+ templates.each do |template|
16
+ parsed << Smash.new(YAML.load(Mustache.render(template, template_context)))
17
+ end
18
+
19
+ context.parsed_templates = parsed
20
+ end
21
+
22
+ private
23
+ def templates
24
+ context.templates
25
+ end
26
+
27
+ def template_context
28
+ context.template_context
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,32 @@
1
+ require 'shipitron'
2
+
3
+ module Shipitron
4
+ class PostBuild < Hashie::Dash
5
+ property :ecs_task
6
+ property :container_name
7
+ property :command
8
+
9
+ # String is of the format:
10
+ # 'ecs_task:task,container_name:name,command:command
11
+ def self.parse(str)
12
+ PostBuild.new.tap do |post_build|
13
+ str.split(',').each do |part|
14
+ part.match(/([^:]+):(.+)/) do |m|
15
+ prop = m[1].to_sym
16
+ if property?(prop)
17
+ post_build[prop] = m[2]
18
+ end
19
+ end
20
+ end
21
+
22
+ properties.each do |prop|
23
+ raise "post build argument missing '#{prop}'" if post_build[prop].nil?
24
+ end
25
+ end
26
+ end
27
+
28
+ def to_s
29
+ "ecs_task:#{ecs_task},container_name:#{container_name},command:#{command}"
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,75 @@
1
+ require 'shipitron'
2
+ require 'shipitron/consul_lock'
3
+ require 'shipitron/server/git/pull_repo'
4
+ require 'shipitron/server/docker/configure'
5
+ require 'shipitron/server/docker/build_image'
6
+ require 'shipitron/server/docker/push_image'
7
+ require 'shipitron/server/update_ecs_task_definitions'
8
+ require 'shipitron/server/run_post_build'
9
+ require 'shipitron/server/update_ecs_services'
10
+
11
+ module Shipitron
12
+ module Server
13
+ class DeployApplication
14
+ include Metaractor
15
+ include Interactor::Organizer
16
+ include ConsulLock
17
+
18
+ required :application
19
+ required :repository_url
20
+ required :s3_cache_bucket
21
+ required :docker_image
22
+ required :region
23
+ required :cluster_name
24
+ required :ecs_task_defs
25
+ optional :ecs_task_def_templates
26
+ required :ecs_services
27
+ optional :ecs_service_templates
28
+ optional :build_script
29
+ optional :post_builds
30
+ optional :repository_branch
31
+
32
+ around do |interactor|
33
+ if ENV['CONSUL_HOST'].nil?
34
+ fail_with_error!(message: 'Environment variable CONSUL_HOST required')
35
+ end
36
+
37
+ Diplomat.configure do |config|
38
+ config.url = "http://#{ENV['CONSUL_HOST']}:8500"
39
+ end
40
+
41
+ begin
42
+ with_lock(key: "shipitron/#{application}/deploy_lock") do
43
+ interactor.call
44
+ end
45
+ rescue UnableToLock
46
+ fail_with_errors!(messages: [
47
+ 'Shipitron says: THERE CAN BE ONLY ONE',
48
+ 'Unable to acquire deploy lock.'
49
+ ])
50
+ end
51
+ end
52
+
53
+ organize [
54
+ Git::PullRepo,
55
+ Docker::Configure,
56
+ Docker::BuildImage,
57
+ Docker::PushImage,
58
+ UpdateEcsTaskDefinitions,
59
+ RunPostBuild,
60
+ UpdateEcsServices
61
+ ]
62
+
63
+ def call
64
+ Logger.info "==> Deploying #{application} (server-side)"
65
+ super
66
+ Logger.info "==> Done"
67
+ end
68
+
69
+ private
70
+ def application
71
+ context.application
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,25 @@
1
+ require 'shipitron'
2
+ require 'shipitron/server/download_build_cache'
3
+ require 'shipitron/server/docker/run_build_script'
4
+ require 'shipitron/server/upload_build_cache'
5
+
6
+ module Shipitron
7
+ module Server
8
+ module Docker
9
+ class BuildImage
10
+ include Metaractor
11
+ include Interactor::Organizer
12
+
13
+ required :application
14
+ required :docker_image
15
+ required :git_sha
16
+
17
+ organize [
18
+ DownloadBuildCache,
19
+ Docker::RunBuildScript,
20
+ UploadBuildCache
21
+ ]
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,35 @@
1
+ require 'shipitron'
2
+ require 'shipitron/consul_keys'
3
+
4
+ module Shipitron
5
+ module Server
6
+ module Docker
7
+ class Configure
8
+ include Metaractor
9
+ include ConsulKeys
10
+
11
+ required :application
12
+
13
+ before do
14
+ configure_consul_client!
15
+ end
16
+
17
+ def call
18
+ docker_auth = fetch_key!(key: "shipitron/#{application}/docker_auth")
19
+ auth_file = Pathname.new('/home/shipitron/.docker/config.json')
20
+ auth_file.parent.mkpath
21
+ auth_file.open('wb') do |file|
22
+ file.puts(docker_auth.to_s)
23
+ file.chmod(0600)
24
+ end
25
+ end
26
+
27
+ private
28
+ def application
29
+ context.application
30
+ end
31
+
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,37 @@
1
+ require 'shipitron'
2
+
3
+ module Shipitron
4
+ module Server
5
+ module Docker
6
+ class PushImage
7
+ include Metaractor
8
+
9
+ required :docker_image
10
+
11
+ def call
12
+ Logger.info "Pushing docker image #{docker_image} and #{docker_image.name_with_tag(:latest)}"
13
+
14
+ Logger.info `docker tag #{docker_image} #{docker_image.name_with_tag(:latest)}`
15
+ if $? != 0
16
+ fail_with_error!(message: 'Docker tag failed.')
17
+ end
18
+
19
+ Logger.info `docker push #{docker_image}`
20
+ if $? != 0
21
+ fail_with_error!(message: 'Docker push failed.')
22
+ end
23
+
24
+ Logger.info `docker push #{docker_image.name_with_tag(:latest)}`
25
+ if $? != 0
26
+ fail_with_error!(message: 'Docker push (latest) failed.')
27
+ end
28
+ end
29
+
30
+ private
31
+ def docker_image
32
+ context.docker_image
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end