reinarb 0.1.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.
@@ -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: []