reinarb 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a415a1d33bc56baa8ccd2e7d3ae8fd3fe6f426baa36031a7cfcd692035835970
4
+ data.tar.gz: 8e87077ee747bef1a456aa7cf6f1574dbf6db9f52303837b201a63809cf5a5d7
5
+ SHA512:
6
+ metadata.gz: ac3d85dc8e66d9c16ecf7ad14155786c355a044ab02803dcb5fe4622588f2abd2927dba92975b72533194077d293a27e712a02862e34f152aee730259cea5ddd
7
+ data.tar.gz: 6d67ffd0e61f719250ad0f28ebef6dfcbbd9724034fa160181202733c3c1216db1355ee6e5d5b50f35e394f32b473f17499d8f871e0a67a74ddca069ca4f1077
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env ruby
2
+ require 'reina'
3
+ require 'optparse'
4
+
5
+ options = {
6
+ force: false,
7
+ strict: false
8
+ }
9
+
10
+ OptionParser.new do |opts|
11
+ opts.banner = "Usage: reina [options]"
12
+
13
+ opts.on('-f', '--force', 'Delete the existing apps without asking for deletion') do
14
+ options[:force] = true
15
+ end
16
+
17
+ opts.on('-s', '--strict', 'Enable the strict mode in which only the apps given in the CLI are considered rather than the whole suite') do
18
+ options[:strict] = true
19
+ end
20
+
21
+ opts.on('-v', '--version', 'Print the version') do
22
+ puts Reina::VERSION
23
+ exit
24
+ end
25
+ end.parse!
26
+
27
+ reina = Reina::Controller.new(ARGV.dup, options[:strict])
28
+
29
+ if reina.existing_apps.present?
30
+ puts 'The following apps already exist on Heroku:'
31
+ puts reina.existing_apps.map { |a| "- #{a}" }
32
+
33
+ unless options[:force]
34
+ require 'readline'
35
+ abort if Readline.readline('Type "OK" to delete the apps above: ', true).strip != 'OK'
36
+ end
37
+
38
+ reina.delete_existing_apps!
39
+ end
40
+
41
+ apps_count = reina.apps.size
42
+
43
+ if apps_count > 1
44
+ puts "Starting to deploy #{apps_count} apps..."
45
+ else
46
+ puts "Starting to deploy one app..."
47
+ end
48
+
49
+ reina.deploy_parallel_apps!
50
+ reina.deploy_non_parallel_apps!
51
+
52
+ s = 's'.freeze if apps_count > 1
53
+ puts "Deployment#{s} finished. Live at #{url}."
@@ -0,0 +1,14 @@
1
+ require 'excon'
2
+ require 'platform-api'
3
+ require 'git'
4
+ require 'active_support/all'
5
+ require 'parallel'
6
+ require 'sinatra/base'
7
+ require 'octokit'
8
+
9
+ require 'reina/config'
10
+ require 'reina/app'
11
+ require 'reina/controller'
12
+ require 'reina/github_controller'
13
+ require 'reina/server'
14
+ require 'reina/version'
@@ -0,0 +1,190 @@
1
+ module Reina
2
+ class App
3
+ DEFAULT_REGION = 'eu'.freeze
4
+ DEFAULT_STAGE = 'staging'.freeze
5
+ DEFAULT_APP_NAME_PREFIX = CONFIG[:app_name_prefix].freeze
6
+
7
+ attr_reader :heroku, :name, :project, :issue_number, :branch, :g
8
+
9
+ def initialize(heroku, name, project, issue_number, branch)
10
+ @heroku = heroku
11
+ @name = name.to_s
12
+ @project = project
13
+ @issue_number = issue_number
14
+ @branch = branch
15
+ end
16
+
17
+ def fetch_repository
18
+ if Dir.exists?(name)
19
+ @g = Git.open(name)
20
+ else
21
+ @g = Git.clone(github_url, name)
22
+ end
23
+
24
+ g.pull('origin', branch)
25
+ g.checkout(g.branch(branch))
26
+
27
+ unless g.remotes.map(&:name).include?(remote_name)
28
+ g.add_remote(remote_name, remote_url)
29
+ end
30
+ end
31
+
32
+ def create_app
33
+ heroku.app.create(
34
+ 'name' => app_name,
35
+ 'region' => project.fetch(:region, DEFAULT_REGION)
36
+ )
37
+ end
38
+
39
+ def install_addons
40
+ addons = project.fetch(:addons, []) + app_json.fetch('addons', [])
41
+ addons.each do |addon|
42
+ if addon.is_a?(Hash) && addon.has_key?('options')
43
+ addon['config'] = addon.extract!('options')
44
+ else
45
+ addon = { 'plan' => addon }
46
+ end
47
+
48
+ heroku.addon.create(app_name, addon)
49
+ end
50
+ end
51
+
52
+ def add_buildpacks
53
+ buildpacks = project.fetch(:buildpacks, []).map do |buildpack|
54
+ { 'buildpack' => buildpack }
55
+ end + app_json.fetch('buildpacks', []).map do |buildpack|
56
+ { 'buildpack' => buildpack['url'] }
57
+ end
58
+
59
+ heroku.buildpack_installation.update(app_name, 'updates' => buildpacks.uniq)
60
+ end
61
+
62
+ def set_env_vars
63
+ config_vars = project.fetch(:config_vars, {})
64
+ except = config_vars[:except]
65
+
66
+ if config_vars.has_key?(:from)
67
+ copy = config_vars.fetch(:copy, [])
68
+ config_vars = heroku.config_var.info_for_app(config_vars[:from])
69
+
70
+ vars_cache = {}
71
+ copy.each do |h|
72
+ unless h[:from].include?('#')
73
+ s = config_vars[h[:from]]
74
+ s << h[:append] if h[:append].present?
75
+ config_vars[h[:to]] = s
76
+ next
77
+ end
78
+
79
+ source, var = h[:from].split('#')
80
+ source_app_name = app_name_for(source)
81
+
82
+ if var == 'url'.freeze
83
+ config_vars[h[:to]] = "https://#{domain_name_for(source_app_name)}"
84
+ else
85
+ vars_cache[source_app_name] ||= heroku.config_var.info_for_app(source_app_name)
86
+ config_vars[h[:to]] = vars_cache[source_app_name][var]
87
+ end
88
+ end
89
+ end
90
+
91
+ config_vars.except!(*except) if except.present?
92
+
93
+ config_vars['APP_NAME'] = app_name
94
+ config_vars['HEROKU_APP_NAME'] = app_name
95
+ config_vars['DOMAIN_NAME'] = domain_name
96
+ config_vars['COOKIE_DOMAIN'] = '.herokuapp.com'.freeze
97
+
98
+ app_json.fetch('env', {}).each do |key, hash|
99
+ next if hash['value'].blank? || config_vars[key].present?
100
+ config_vars[key] = hash['value']
101
+ end
102
+
103
+ heroku.config_var.update(app_name, config_vars)
104
+ end
105
+
106
+ def setup_dynos
107
+ formation = app_json.fetch('formation', {})
108
+ return if formation.blank?
109
+
110
+ formation.each do |k, h|
111
+ h['size'] = 'free'
112
+
113
+ heroku.formation.update(app_name, k, h)
114
+ end
115
+ end
116
+
117
+ def add_to_pipeline
118
+ return if project[:pipeline].blank?
119
+
120
+ pipeline_id = heroku.pipeline.info(project[:pipeline])['id']
121
+ heroku.pipeline_coupling.create(
122
+ 'app' => app_name,
123
+ 'pipeline' => pipeline_id,
124
+ 'stage' => DEFAULT_STAGE
125
+ )
126
+ end
127
+
128
+ def execute_postdeploy_scripts
129
+ script = app_json.dig('scripts', 'postdeploy')
130
+ return if script.blank?
131
+
132
+ return if heroku? && ENV['HEROKU_API_KEY'].blank?
133
+
134
+ `heroku run #{script} --app #{app_name}`
135
+ end
136
+
137
+ def deploy
138
+ g.push(remote_name, 'master')
139
+ end
140
+
141
+ def app_json
142
+ return @app_json if @app_json
143
+
144
+ f = File.join(name, 'app.json')
145
+ return {} unless File.exists?(f)
146
+
147
+ @app_json = JSON.parse(File.read(f))
148
+ end
149
+
150
+ def domain_name
151
+ domain_name_for(app_name)
152
+ end
153
+
154
+ def app_name
155
+ app_name_for(name)
156
+ end
157
+
158
+ def remote_name
159
+ "heroku-#{app_name}"
160
+ end
161
+
162
+ def parallel?
163
+ project[:parallel] != false
164
+ end
165
+
166
+ private
167
+
168
+ def domain_name_for(s)
169
+ "#{s}.herokuapp.com"
170
+ end
171
+
172
+ def app_name_for(s)
173
+ "#{DEFAULT_APP_NAME_PREFIX}#{s}-#{issue_number}"
174
+ end
175
+
176
+ def github_url
177
+ return "https://github.com/#{project[:github]}" if ENV['GITHUB_AUTH'].blank?
178
+
179
+ "https://#{ENV['GITHUB_AUTH']}@github.com/#{project[:github]}"
180
+ end
181
+
182
+ def remote_url
183
+ "https://git.heroku.com/#{app_name}.git"
184
+ end
185
+
186
+ def heroku?
187
+ ENV['DYNO'].present?
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,22 @@
1
+ module Reina
2
+ config_file = File.join(Dir.pwd, 'config.rb')
3
+
4
+ if File.exists?(config_file)
5
+ if ENV['CONFIG'].blank? || ENV['APPS'].blank?
6
+ require config_file
7
+ Reina::CONFIG = CONFIG
8
+ Reina::APPS = APPS
9
+ else
10
+ self.class.send(:remove_const, 'CONFIG')
11
+ self.class.send(:remove_const, 'APPS')
12
+ end
13
+ end
14
+
15
+ CONFIG = ActiveSupport::HashWithIndifferentAccess.new(
16
+ JSON.parse(ENV['CONFIG'])
17
+ ) if ENV['CONFIG'].present?
18
+
19
+ APPS = ActiveSupport::HashWithIndifferentAccess.new(
20
+ JSON.parse(ENV['APPS'])
21
+ ) if ENV['APPS'].present?
22
+ end
@@ -0,0 +1,132 @@
1
+ module Reina
2
+ class Controller
3
+ APP_COOLDOWN = 7 # seconds
4
+
5
+ def initialize(params, strict = false)
6
+ @params = params
7
+ @strict = strict
8
+
9
+ abort 'Please provide $PLATFORM_API' if CONFIG[:platform_api].blank?
10
+ abort 'Given PR number should be greater than 0' if issue_number <= 0
11
+
12
+ oversize = apps.select { |app| app.app_name.length >= 30 }.first
13
+ abort "#{oversize.app_name} is too long" if oversize.present?
14
+ end
15
+
16
+ def create_netrc
17
+ return if ENV['GITHUB_AUTH'].blank?
18
+
19
+ `git config --global user.name "#{ENV['GITHUB_NAME']}"`
20
+ `git config --global user.email "#{ENV['GITHUB_EMAIL']}"`
21
+
22
+ return if File.exists?('.netrc')
23
+
24
+ File.write(
25
+ '.netrc',
26
+ "machine git.heroku.com login #{ENV['GITHUB_EMAIL']} password #{ENV['HEROKU_API_KEY']}"
27
+ )
28
+ end
29
+
30
+ def deploy_parallel_apps!
31
+ Parallel.each(apps.select(&:parallel?)) do |app|
32
+ begin
33
+ deploy!(app)
34
+ rescue Git::GitExecuteError => e
35
+ puts "#{app.name}: #{e.message}"
36
+ rescue Exception => e
37
+ msg = e.respond_to?(:response) ? e.response.body : e.message
38
+ puts "#{app.name}: #{msg}"
39
+ end
40
+ end
41
+ end
42
+
43
+ def deploy_non_parallel_apps!
44
+ apps.reject(&:parallel?).each do |app|
45
+ begin
46
+ deploy!(app)
47
+ rescue Git::GitExecuteError => e
48
+ puts "#{app.name}: #{e.message}"
49
+ rescue Exception => e
50
+ msg = e.respond_to?(:response) ? e.response.body : e.message
51
+ puts "#{app.name}: #{msg}"
52
+ end
53
+ end
54
+ end
55
+
56
+ def delete_existing_apps!
57
+ existing_apps.each do |app|
58
+ puts "Deleting #{app}"
59
+ heroku.app.delete(app)
60
+ end
61
+ end
62
+
63
+ def existing_apps
64
+ # apps in common between heroku's list and ours
65
+ @_existing_apps ||= heroku.app.list.map { |a| a['name'] } & apps.map(&:app_name)
66
+ end
67
+
68
+ def heroku?
69
+ ENV['DYNO'].present?
70
+ end
71
+
72
+ def apps
73
+ return @_apps if @_apps.present?
74
+
75
+ # strict is when we only take in consideration the apps
76
+ # that are in both `params` and `APPS`
77
+ _apps = if strict
78
+ app_names = branches.keys
79
+ APPS.select { |name, _| app_names.include?(name) }
80
+ else
81
+ APPS
82
+ end
83
+
84
+ @_apps = _apps.map do |name, project|
85
+ branch = branches[name.to_s].presence || 'master'.freeze
86
+ App.new(heroku, name, project, issue_number, branch)
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ attr_reader :params, :strict
93
+
94
+ def heroku
95
+ @_heroku ||= PlatformAPI.connect_oauth(CONFIG[:platform_api])
96
+ end
97
+
98
+ def issue_number
99
+ @_issue_number ||= params.shift.to_i
100
+ end
101
+
102
+ def branches
103
+ @_branches ||= params.map { |param| param.split('#', 2) }.to_h
104
+ end
105
+
106
+ def deploy!(app)
107
+ puts "#{app.name}: Fetching from #{app.project[:github]}..."
108
+ app.fetch_repository
109
+
110
+ puts "#{app.name}: Provisioning #{app.app_name} on Heroku..."
111
+ app.create_app
112
+ app.install_addons
113
+ app.add_buildpacks
114
+ app.set_env_vars
115
+
116
+ puts "#{app.name}: Deploying to https://#{app.domain_name}..."
117
+ app.deploy
118
+
119
+ puts "#{app.name}: Cooldown..."
120
+ Kernel.sleep APP_COOLDOWN
121
+
122
+ puts "#{app.name}: Executing postdeploy scripts..."
123
+ app.execute_postdeploy_scripts
124
+
125
+ puts "#{app.name}: Setting up dynos..."
126
+ app.setup_dynos
127
+
128
+ puts "#{app.name}: Adding to pipeline..."
129
+ app.add_to_pipeline
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,176 @@
1
+ module Reina
2
+ class SignatureError < StandardError
3
+ def message
4
+ 'Signatures do not match'
5
+ end
6
+ end
7
+
8
+ class UnsupportedEventError < StandardError; end
9
+
10
+ class GitHubController
11
+ DEPLOY_TRIGGER = 'reina: d '.freeze
12
+ SINGLE_DEPLOY_TRIGGER = 'reina: r '.freeze
13
+ EVENTS = %w(issues issue_comment).freeze
14
+
15
+ def initialize(config)
16
+ @config = config
17
+ end
18
+
19
+ def dispatch(request)
20
+ @request = request
21
+
22
+ raise UnsupportedEventError unless EVENTS.include?(event)
23
+
24
+ authenticate!
25
+
26
+ if deploy_requested?
27
+ deploy!
28
+ elsif single_deploy_requested?
29
+ deploy!(true)
30
+ elsif issue_closed?
31
+ destroy!
32
+ end
33
+ end
34
+
35
+ def deployed_url
36
+ [
37
+ 'https://', CONFIG[:app_name_prefix], repo_name, '-', issue_number, '.herokuapp.com'
38
+ ].join
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :config, :request
44
+
45
+ def authenticate!
46
+ hash = OpenSSL::HMAC.hexdigest(hmac_digest, config[:webhook_secret], raw_payload)
47
+ hash.prepend('sha1=')
48
+ raise SignatureError unless Rack::Utils.secure_compare(hash, signature)
49
+ end
50
+
51
+ def deploy_requested?
52
+ action == 'created'.freeze && comment_body.start_with?(DEPLOY_TRIGGER)
53
+ end
54
+
55
+ def single_deploy_requested?
56
+ action == 'created'.freeze && comment_body.start_with?(SINGLE_DEPLOY_TRIGGER)
57
+ end
58
+
59
+ def issue_closed?
60
+ action == 'closed'.freeze
61
+ end
62
+
63
+ def deploy!(strict = false)
64
+ reina = Controller.new(params, strict)
65
+ should_comment = config[:oauth_token].present?
66
+ reply = ->(msg) { octokit.add_comment(repo_full_name, issue_number, msg) }
67
+ url = deployed_url
68
+
69
+ fork do
70
+ apps_count = reina.apps.size
71
+
72
+ if should_comment
73
+ if apps_count > 1
74
+ reply.call("Starting to deploy #{apps_count} apps...")
75
+ else
76
+ reply.call("Starting to deploy one app...")
77
+ end
78
+ end
79
+
80
+ reina.create_netrc if reina.heroku?
81
+ reina.delete_existing_apps!
82
+ reina.deploy_parallel_apps!
83
+ reina.deploy_non_parallel_apps!
84
+
85
+ s = 's'.freeze if apps_count > 1
86
+ reply.call("Deployment#{s} finished. Live at #{url}.") if should_comment
87
+ end
88
+ end
89
+
90
+ def destroy!
91
+ reina = Controller.new(params)
92
+ return if reina.existing_apps.empty?
93
+
94
+ should_comment = config[:oauth_token].present?
95
+ reply = ->(msg) { octokit.add_comment(repo_full_name, issue_number, msg) }
96
+
97
+ fork do
98
+ reina.create_netrc if reina.heroku?
99
+ reina.delete_existing_apps!
100
+
101
+ reply.call('All the staging apps related to this issue have been deleted.') if should_comment
102
+ end
103
+ end
104
+
105
+ def octokit
106
+ return @_octokit if @_octokit.present?
107
+
108
+ client = Octokit::Client.new(access_token: config[:oauth_token])
109
+ user = client.user
110
+ user.login
111
+ @_octokit = client
112
+ end
113
+
114
+ def params
115
+ return [issue_number] if comment_body.blank?
116
+
117
+ [
118
+ issue_number,
119
+ comment_body
120
+ .lines[0]
121
+ .split(/#{DEPLOY_TRIGGER}|#{SINGLE_DEPLOY_TRIGGER}/)[1]
122
+ .split(' ')
123
+ .reject(&:blank?)
124
+ ].flatten
125
+ end
126
+
127
+ def signature
128
+ request.env['HTTP_X_HUB_SIGNATURE']
129
+ end
130
+
131
+ def event
132
+ request.env['HTTP_X_GITHUB_EVENT']
133
+ end
134
+
135
+ def action
136
+ payload['action']
137
+ end
138
+
139
+ def issue_number
140
+ payload.dig('issue', 'number')
141
+ end
142
+
143
+ def repo_name
144
+ payload.dig('repository', 'name')
145
+ end
146
+
147
+ def repo_full_name
148
+ payload.dig('repository', 'full_name')
149
+ end
150
+
151
+ def comment_body
152
+ payload.dig('comment', 'body')&.strip.to_s
153
+ end
154
+
155
+ def comment_author
156
+ payload.dig('comment', 'user', 'login')
157
+ end
158
+
159
+ def payload
160
+ return @_payload if @_payload.present?
161
+
162
+ @_payload ||= JSON.parse(raw_payload)
163
+ end
164
+
165
+ def raw_payload
166
+ return @_raw_payload if @_raw_payload.present?
167
+
168
+ request.body.rewind
169
+ @_raw_payload ||= request.body.read
170
+ end
171
+
172
+ def hmac_digest
173
+ @_hmac_digest ||= OpenSSL::Digest.new('sha1')
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,27 @@
1
+ module Reina
2
+ class Server < Sinatra::Base
3
+ set :show_exceptions, false
4
+
5
+ error SignatureError do
6
+ halt 403, env['sinatra.error'].message
7
+ end
8
+
9
+ error UnsupportedEventError do
10
+ halt 403, env['sinatra.error'].message
11
+ end
12
+
13
+ error Exception do
14
+ halt 500, 'Something bad happened... probably'
15
+ end
16
+
17
+ get '/' do
18
+ '<img src="https://i.imgur.com/UDxbOsz.png?1">'
19
+ end
20
+
21
+ post '/github' do
22
+ GitHubController.new(CONFIG[:github]).dispatch(request)
23
+ status 202
24
+ body ''
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,3 @@
1
+ module Reina
2
+ VERSION = '0.1.0'.freeze
3
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: reinarb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Giovanni Capuano
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-04-30 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Either used as GitHub bot or a CLI tool, reina performs setup and deployment
14
+ of your applications on Heroku.
15
+ email: webmaster@giovannicapuano.net
16
+ executables:
17
+ - reina
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - bin/reina
22
+ - lib/reina.rb
23
+ - lib/reina/app.rb
24
+ - lib/reina/config.rb
25
+ - lib/reina/controller.rb
26
+ - lib/reina/github_controller.rb
27
+ - lib/reina/server.rb
28
+ - lib/reina/version.rb
29
+ homepage: http://github.com/honeypotio/reina
30
+ licenses:
31
+ - BSD-2-Clause
32
+ metadata: {}
33
+ post_install_message:
34
+ rdoc_options: []
35
+ require_paths:
36
+ - lib
37
+ required_ruby_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '0'
42
+ required_rubygems_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ requirements: []
48
+ rubyforge_project:
49
+ rubygems_version: 2.7.6
50
+ signing_key:
51
+ specification_version: 4
52
+ summary: Bot to handle deploys and orchestrations of feature stagings hosted on Heroku.
53
+ test_files: []