capistrano-data_plane_api 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +5 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +5 -0
  5. data/Gemfile +15 -0
  6. data/Gemfile.lock +128 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +197 -0
  9. data/Rakefile +16 -0
  10. data/capistrano-data_plane_api.gemspec +41 -0
  11. data/exe/cap_data_plane_api +37 -0
  12. data/lib/capistrano/data_plane_api/configuration/backend.rb +18 -0
  13. data/lib/capistrano/data_plane_api/configuration/server.rb +22 -0
  14. data/lib/capistrano/data_plane_api/configuration/symbol.rb +16 -0
  15. data/lib/capistrano/data_plane_api/configuration.rb +33 -0
  16. data/lib/capistrano/data_plane_api/deploy/args.rb +241 -0
  17. data/lib/capistrano/data_plane_api/deploy/deployment_stats.rb +117 -0
  18. data/lib/capistrano/data_plane_api/deploy/group.rb +100 -0
  19. data/lib/capistrano/data_plane_api/deploy/helper.rb +51 -0
  20. data/lib/capistrano/data_plane_api/deploy/server_stats.rb +110 -0
  21. data/lib/capistrano/data_plane_api/deploy.rb +27 -0
  22. data/lib/capistrano/data_plane_api/diggable.rb +31 -0
  23. data/lib/capistrano/data_plane_api/equatable.rb +32 -0
  24. data/lib/capistrano/data_plane_api/helper.rb +56 -0
  25. data/lib/capistrano/data_plane_api/hooks.rb +7 -0
  26. data/lib/capistrano/data_plane_api/show_state.rb +86 -0
  27. data/lib/capistrano/data_plane_api/tasks.rb +30 -0
  28. data/lib/capistrano/data_plane_api/terminal_print_loop.rb +43 -0
  29. data/lib/capistrano/data_plane_api/type.rb +29 -0
  30. data/lib/capistrano/data_plane_api/version.rb +8 -0
  31. data/lib/capistrano/data_plane_api.rb +296 -0
  32. data/readme/failed_deployment_summary.png +0 -0
  33. data/readme/haproxy_state.png +0 -0
  34. data/sig/capistrano/data_plane_api.rbs +6 -0
  35. data/templates/bin/deploy +5 -0
  36. data/templates/bin/deploy.rb +6 -0
  37. data/templates/config/data_plane_api.rb +6 -0
  38. data/templates/config/data_plane_api.yml +51 -0
  39. metadata +177 -0
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Capistrano
6
+ module DataPlaneApi
7
+ module Deploy
8
+ # Class which parses all provided command-line arguments
9
+ # passed to the deployment script and saves them in
10
+ # an object.
11
+ class Args
12
+ # @return [Array<String>]
13
+ PRINTABLE_ENV_VARS = %w[BRANCH NO_MIGRATIONS].freeze
14
+
15
+ # @param options [Array, nil]
16
+ # @return [self]
17
+ def self.parse(options = nil) # rubocop:disable Metrics/MethodLength, Style/ClassMethodsDefinitions
18
+ args = new
19
+
20
+ opt_parser = ::OptionParser.new do |parser| # rubocop:disable Metrics/BlockLength
21
+ parser.banner = <<~BANNER
22
+ Usage: bin/deploy [options]
23
+
24
+ This script can be used to deploy this app to remote servers.
25
+
26
+ BANNER
27
+
28
+ parser.on(
29
+ '-c',
30
+ '--current',
31
+ 'Deploy from the currently checked out branch'
32
+ ) do |_val|
33
+ args.branch = `git branch --show-current`.strip
34
+ ::ENV['BRANCH'] = args.branch
35
+ end
36
+
37
+ parser.on(
38
+ '-t',
39
+ '--test',
40
+ 'Show the commands that would be executed but do not carry out the deployment'
41
+ ) do |val|
42
+ args.test = val
43
+ end
44
+
45
+ parser.on(
46
+ '-g GROUP',
47
+ '--group=GROUP',
48
+ 'Deploy the code to every server in the passed HAProxy backend/group'
49
+ ) do |val|
50
+ args.group = val
51
+ end
52
+
53
+ parser.on(
54
+ '--no-haproxy',
55
+ 'Do not modify the state of any server in HAProxy'
56
+ ) do |val|
57
+ args.no_haproxy = val
58
+ ::ENV['NO_HAPROXY'] = 'true'
59
+ end
60
+
61
+ parser.on(
62
+ '--force-haproxy',
63
+ 'Ignore the current state of servers in HAProxy'
64
+ ) do |val|
65
+ args.force_haproxy = val
66
+ ::ENV['FORCE_HAPROXY'] = 'true'
67
+ end
68
+
69
+ parser.on(
70
+ '-o ONLY',
71
+ '--only=ONLY',
72
+ 'Deploy the code only to the passed servers in the same order'
73
+ ) do |val|
74
+ next unless val
75
+
76
+ args.only = val.split(',').map(&:strip).uniq
77
+ end
78
+
79
+ parser.on(
80
+ '-H',
81
+ '--haproxy-config',
82
+ 'Show the current HAProxy configuration'
83
+ ) do |val|
84
+ next unless val
85
+
86
+ ::Signal.trap('INT') { exit }
87
+ ::Capistrano::DataPlaneApi.show_config
88
+ exit
89
+ end
90
+
91
+ parser.on(
92
+ '-S',
93
+ '--haproxy-state',
94
+ 'Show the current HAProxy state'
95
+ ) do |val|
96
+ next unless val
97
+
98
+ ::Signal.trap('INT') { exit }
99
+ ::Capistrano::DataPlaneApi.show_state
100
+ exit
101
+ end
102
+
103
+ parser.on(
104
+ '-T',
105
+ '--tasks',
106
+ 'Print a list of all available deployment Rake tasks'
107
+ ) do |val|
108
+ next unless val
109
+
110
+ puts COLORS.bold.blue('Available Rake Tasks')
111
+ `cap -T`.each_line do |line|
112
+ puts line.delete_prefix('cap ')
113
+ end
114
+ exit
115
+ end
116
+
117
+ parser.on(
118
+ '-r RAKE',
119
+ '--rake=RAKE',
120
+ 'Carry out a particular Rake task on the server'
121
+ ) do |val|
122
+ next unless val
123
+
124
+ args.rake = val
125
+ end
126
+
127
+ parser.on('-h', '--help', 'Prints this help') do
128
+ puts parser
129
+ exit
130
+ end
131
+
132
+ parser.on(
133
+ '-b BRANCH',
134
+ '--branch=BRANCH',
135
+ 'Deploy the code from the passed Git branch'
136
+ ) do |val|
137
+ args.branch = val
138
+ ::ENV['BRANCH'] = val
139
+ end
140
+
141
+ parser.on(
142
+ '--no-migrations',
143
+ 'Do not carry out migrations'
144
+ ) do |val|
145
+ args.no_migrations = val
146
+ ::ENV['NO_MIGRATIONS'] = 'true'
147
+ end
148
+ end
149
+
150
+ opt_parser.parse!(options || ::ARGV)
151
+ args.stage = ::ARGV.first&.start_with?('-') ? nil : ::ARGV.first
152
+ args.prepare_if_one_server
153
+ args
154
+ end
155
+
156
+ # @return [String, nil] Git branch that the code will be deployed to
157
+ attr_accessor :branch
158
+ # @return [Boolean] Runs in test mode if true, only prints commands without executing them
159
+ attr_accessor :test
160
+ # @return [String, nil] Name of the HAProxy server group/backend
161
+ attr_accessor :group
162
+ # @return [Boolean]
163
+ attr_accessor :no_haproxy
164
+ # @return [Boolean]
165
+ attr_accessor :no_migrations
166
+ # @return [Boolean]
167
+ attr_accessor :force_haproxy
168
+ # @return [Array<String>, nil] Ordered list of servers to which the app will be deployed
169
+ attr_accessor :only
170
+ # @return [String, nil] Rake command that will be called remotely (`deploy` by default)
171
+ attr_accessor :rake
172
+ # @return [String, nil] Name of the deployment stage/server
173
+ attr_accessor :stage
174
+
175
+ alias test? test
176
+
177
+ def initialize
178
+ @rake = 'deploy'
179
+ end
180
+
181
+ # @return [Boolean]
182
+ def only?
183
+ return false if @only.nil?
184
+
185
+ @only.any?
186
+ end
187
+
188
+ # @return [void]
189
+ def prepare_if_one_server
190
+ return unless one_server?
191
+
192
+ server, backend = ::Capistrano::DataPlaneApi.find_server_and_backend(@stage)
193
+ @only = [server['name']]
194
+ @group = backend['name']
195
+ end
196
+
197
+ # @param stage [String, Symbol, nil]
198
+ # @return [String]
199
+ def deploy_command(stage = nil)
200
+ used_stage = stage || self.stage
201
+ "cap #{used_stage} #{rake}"
202
+ end
203
+
204
+ # @param stage [String, Symbol, nil]
205
+ # @return [String]
206
+ def humanized_deploy_command(stage = nil)
207
+ result = ::String.new
208
+ PRINTABLE_ENV_VARS.each do |env_var_name|
209
+ next unless (value = ::ENV[env_var_name])
210
+
211
+ result << "#{env_var_name}=#{value} "
212
+ end
213
+
214
+ result << deploy_command(stage)
215
+ result
216
+ end
217
+
218
+ # @param key [Symbol, String]
219
+ # @return [Object]
220
+ def [](key)
221
+ public_send(key)
222
+ end
223
+
224
+ # @param key [Symbol, String]
225
+ # @param val [Object]
226
+ # @return [Object]
227
+ def []=(key, val)
228
+ public_send("#{key}=", val)
229
+ end
230
+
231
+ private
232
+
233
+ # @return [Boolean]
234
+ def one_server?
235
+ @stage && @group.nil?
236
+ end
237
+ end
238
+ end
239
+
240
+ end
241
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+
5
+ module Capistrano
6
+ module DataPlaneApi
7
+ module Deploy
8
+ # Represents a collection of deployment stats for particular servers.
9
+ class DeploymentStats
10
+ # @return [Capistrano::DataPlaneApi::Configuration::Backend, nil]
11
+ # Configuration data of a particular HAProxy backend
12
+ attr_accessor :backend
13
+
14
+ # @return [Time, nil]
15
+ attr_accessor :start_time
16
+
17
+ # @return [Time, nil]
18
+ attr_accessor :end_time
19
+
20
+ # @return [Hash{String => Deploy::ServerStats}]
21
+ attr_accessor :server_stats
22
+
23
+ # @return [Boolean]
24
+ attr_accessor :success
25
+
26
+ def initialize
27
+ @backend = nil
28
+ @start_time = nil
29
+ @end_time = nil
30
+ @success = true
31
+ @server_stats = {}
32
+ end
33
+
34
+ # @param key [String]
35
+ # @return [Deploy::ServerStats]
36
+ def [](key)
37
+ @server_stats[key]
38
+ end
39
+
40
+ # @param key [String]
41
+ # @param val [Deploy::ServerStats]
42
+ def []=(key, val)
43
+ @server_stats[key] = val
44
+ end
45
+
46
+ # @param servers [Array<Capistrano::DataPlaneApi::Configuration::Server>, Capistrano::DataPlaneApi::Configuration::Server]
47
+ # @return [void]
48
+ def create_stats_for(servers)
49
+ servers = *servers
50
+
51
+ servers.each do |server|
52
+ @server_stats[server.name] = ServerStats.new(server.name, @backend.name)
53
+ end
54
+ end
55
+
56
+ # @return [String]
57
+ def to_s
58
+ update_states_in_stats
59
+
60
+ time_string = COLORS.bold.yellow ::Time.now.to_s
61
+ if success
62
+ state = COLORS.bold.green 'Successful'
63
+ time_sentence = 'took'
64
+ else
65
+ state = COLORS.bold.red 'Failed'
66
+ time_sentence = 'failed after'
67
+ end
68
+
69
+ result = ::String.new
70
+ result << "\n#{time_string}\n\n"
71
+ result << "#{state} deployment to #{::Capistrano::DataPlaneApi.humanize_backend_name(@backend)}\n"
72
+ result << " #{time_sentence} #{Helper.humanize_time(seconds)}\n"
73
+
74
+ @server_stats.each_value do |stats|
75
+ result << "\n#{stats}"
76
+ end
77
+
78
+ result
79
+ end
80
+
81
+ # @return [Integer, nil] How much time has the deployment taken
82
+ def seconds
83
+ @seconds ||= Helper.seconds_since(@start_time, to: @end_time)
84
+ end
85
+
86
+ private
87
+
88
+ # @return [void]
89
+ def update_states_in_stats
90
+ return if @update_states_in_stats
91
+
92
+ @update_states_in_stats = true
93
+ update_states_in_stats!
94
+ end
95
+
96
+ def update_states_in_stats!
97
+ server_states = begin
98
+ ::Capistrano::DataPlaneApi.get_backend_servers_settings(@backend.name).body
99
+ rescue Error
100
+ nil
101
+ end
102
+
103
+ return unless server_states
104
+
105
+ server_states.each do |server_state|
106
+ @server_stats[server_state['name']]&.then do |s|
107
+ s.admin_state = server_state['admin_state']
108
+ s.operational_state = server_state['operational_state']
109
+ end
110
+ end
111
+ end
112
+
113
+ end
114
+
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capistrano
4
+ module DataPlaneApi
5
+ module Deploy
6
+ # Class which deploys the app to all servers
7
+ # in a particular HAProxy backend/group.
8
+ class Group
9
+ class << self
10
+ # @param args [DeployArgs]
11
+ # @return [void]
12
+ def call(args)
13
+ new(args).call
14
+ end
15
+ end
16
+
17
+ # @param args [DeployArgs]
18
+ def initialize(args)
19
+ @args = args
20
+ @deployment_stats = DeploymentStats.new
21
+ end
22
+
23
+ # @return [Boolean, nil] Whether the deployment has been successful
24
+ def call
25
+ @backend = ::Capistrano::DataPlaneApi.find_backend(@args.group)
26
+ @servers = servers(@backend)
27
+ start_deployment
28
+
29
+ success = nil
30
+ @servers.each do |server|
31
+ server_stats = @deployment_stats[server.name]
32
+ puts COLORS.bold.blue("Deploying the app to `#{server.stage}` -- `#{@backend.name}:#{server.name}`")
33
+
34
+ puts @args.humanized_deploy_command(server.stage)
35
+ puts
36
+
37
+ next if @args.test?
38
+
39
+ server_stats.start_time = ::Time.now
40
+ deploy_command = @args.deploy_command(server.stage)
41
+ success = system deploy_command
42
+
43
+ server_stats.end_time = ::Time.now
44
+ server_stats.success = success
45
+
46
+ next if success
47
+
48
+ puts COLORS.bold.red("Command `#{deploy_command}` failed")
49
+ break
50
+ end
51
+
52
+ return if @args.test?
53
+
54
+ finish_deployment(success: success)
55
+ print_summary
56
+ success
57
+ end
58
+
59
+ private
60
+
61
+ # @return [void]
62
+ def start_deployment
63
+ @deployment_stats.tap do |d|
64
+ d.start_time = ::Time.now
65
+ d.backend = @backend
66
+ d.create_stats_for @servers
67
+ end
68
+ end
69
+
70
+ # @param success [Boolean]
71
+ def finish_deployment(success: true)
72
+ @deployment_stats.end_time = ::Time.now
73
+ @deployment_stats.success = success
74
+ end
75
+
76
+ # @return [void]
77
+ def print_summary
78
+ puts @deployment_stats
79
+ end
80
+
81
+ # @param backend [Capistrano::DataPlaneApi::Configuration::Backend]
82
+ # @return [Array<Capistrano::DataPlaneApi::Configuration::Server>]
83
+ def servers(backend)
84
+ return backend.servers unless @args.only?
85
+
86
+ chosen_servers = []
87
+ @args.only.each do |current_server_name|
88
+ backend.servers.each do |server|
89
+ next unless server.name == current_server_name || server.stage == current_server_name
90
+
91
+ chosen_servers << server
92
+ end
93
+ end
94
+
95
+ chosen_servers
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capistrano
4
+ module DataPlaneApi
5
+ module Deploy
6
+ # A module which provides some generic helper methods used
7
+ # in the deployment script.
8
+ module Helper
9
+ extend self
10
+
11
+ # @param seconds [Integer]
12
+ # @return [String]
13
+ def humanize_time(seconds)
14
+ hours = seconds / 3600
15
+ rest_seconds = seconds - (hours * 3600)
16
+ minutes = rest_seconds / 60
17
+ rest_seconds = seconds - (minutes * 60)
18
+
19
+ result = ::String.new
20
+
21
+ if rest_seconds.positive?
22
+ result.prepend "#{rest_seconds}s"
23
+ styles = %i[bright_green]
24
+ end
25
+
26
+ if minutes.positive?
27
+ result.prepend "#{minutes}min "
28
+ styles = %i[bright_yellow]
29
+ end
30
+
31
+ if hours.positive?
32
+ result.prepend "#{hours}h "
33
+ styles = %i[bright_red]
34
+ end
35
+
36
+ COLORS.decorate(result.strip, *styles)
37
+ end
38
+
39
+ # Calculate how many seconds have passed
40
+ # since the given point in time.
41
+ #
42
+ # @param time [Time]
43
+ # @param to [Time]
44
+ # @return [Integer]
45
+ def seconds_since(time, to: ::Time.now)
46
+ (to - time).to_i
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capistrano
4
+ module DataPlaneApi
5
+ module Deploy
6
+ # Represents the stats of a deployment to a particular server
7
+ class ServerStats
8
+ # @return [Boolean, nil] `nil` when the deployment hasn't begun
9
+ # `true` when it has finished successfully, `false` when it has failed
10
+ attr_accessor :success
11
+
12
+ # @return [Time, nil]
13
+ attr_accessor :start_time
14
+
15
+ # @return [Time, nil]
16
+ attr_accessor :end_time
17
+
18
+ # @return [String]
19
+ attr_accessor :server_name
20
+
21
+ # @return [String]
22
+ attr_accessor :backend_name
23
+
24
+ # @return [String, nil]
25
+ attr_accessor :admin_state
26
+
27
+ # @return [String, nil]
28
+ attr_accessor :operational_state
29
+
30
+ # @param server_name [String]
31
+ # @param backend_name [String]
32
+ def initialize(server_name, backend_name)
33
+ @server_name = server_name
34
+ @backend_name = backend_name
35
+ @success = nil
36
+ @seconds = nil
37
+ end
38
+
39
+ # @return [String]
40
+ def to_s
41
+ time_string =
42
+ case @success
43
+ when nil then 'skipped'
44
+ when false then "failed after #{Helper.humanize_time(seconds)}"
45
+ when true then "took #{Helper.humanize_time(seconds)}"
46
+ end
47
+
48
+ " #{state_emoji} #{server_title} #{time_string}#{haproxy_states}"
49
+ end
50
+
51
+ # @return [Integer, nil] How much time has the deployment taken
52
+ def seconds
53
+ @seconds ||= Helper.seconds_since(@start_time, to: @end_time)
54
+ end
55
+
56
+ private
57
+
58
+ # @return [String, nil]
59
+ def humanize_admin_state
60
+ ::Capistrano::DataPlaneApi.humanize_admin_state(@admin_state)
61
+ end
62
+
63
+ # @return [String, nil]
64
+ def humanize_operational_state
65
+ ::Capistrano::DataPlaneApi.humanize_operational_state(@operational_state)
66
+ end
67
+
68
+ # @return [String, nil]
69
+ def haproxy_states
70
+ <<-HAPROXY
71
+
72
+ admin_state: #{humanize_admin_state}
73
+ operational_state: #{humanize_operational_state}
74
+ HAPROXY
75
+ end
76
+
77
+ # @return [Hash{String => Symbol}]
78
+ SERVER_TITLE_COLORS = {
79
+ nil => :yellow,
80
+ false => :red,
81
+ true => :green
82
+ }.freeze
83
+ private_constant :SERVER_TITLE_COLORS
84
+
85
+ # @return [String]
86
+ def server_title
87
+ COLORS.decorate(server_id, :bold, SERVER_TITLE_COLORS[@success])
88
+ end
89
+
90
+ # @return [String]
91
+ def server_id
92
+ "#{@backend_name}:#{@server_name}"
93
+ end
94
+
95
+ # @return [Hash{Boolean, nil => Symbol}]
96
+ STATE_EMOJIS = {
97
+ nil => '🟡',
98
+ false => '❌',
99
+ true => '✅'
100
+ }.freeze
101
+ private_constant :STATE_EMOJIS
102
+
103
+ # @return [String]
104
+ def state_emoji
105
+ STATE_EMOJIS[@success]
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../data_plane_api'
4
+
5
+ module Capistrano
6
+ module DataPlaneApi
7
+ # Contains code used in the deployment script.
8
+ module Deploy
9
+ class << self
10
+ # @return [void]
11
+ def call
12
+ args = Args.parse
13
+ puts COLORS.bold.blue('Running the deployment script')
14
+
15
+ result = Group.call(args)
16
+ abort if result == false
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+
23
+ require_relative 'deploy/args'
24
+ require_relative 'deploy/helper'
25
+ require_relative 'deploy/deployment_stats'
26
+ require_relative 'deploy/server_stats'
27
+ require_relative 'deploy/group'
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capistrano
4
+ module DataPlaneApi
5
+ # Include in a class to grant it the `#dig` method.
6
+ # It's implemented so that it calls public methods.
7
+ module Diggable
8
+ # Extracts the nested value specified by the sequence of key objects by calling `dig` at each step,
9
+ # returning `nil` if any intermediate step is `nil`.
10
+ #
11
+ # This implementation of `dig` uses `public_send` under the hood.
12
+ #
13
+ # @raise [TypeError] value has no #dig method
14
+ # @return [Object]
15
+ def dig(*args)
16
+ return unless args.size.positive?
17
+
18
+ return unless respond_to?(key = args.shift)
19
+
20
+ value = public_send(key)
21
+ return if value.nil?
22
+ return value if args.size.zero?
23
+ raise ::TypeError, "#{value.class} does not have #dig method" unless value.respond_to?(:dig)
24
+
25
+ value.dig(*args)
26
+ rescue ::ArgumentError
27
+ nil
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capistrano
4
+ module DataPlaneApi
5
+ # Include in a class to make its instances capable
6
+ # of comparing themselves with other objects of the same class
7
+ # by calling `==` on their instance variables.
8
+ module Equatable
9
+ # @param other [Object]
10
+ # @return [Boolean]
11
+ def eql?(other)
12
+ return true if equal?(other)
13
+ return false unless other.is_a?(self.class) || is_a?(other.class)
14
+
15
+ # @type [Set<Symbol>]
16
+ self_ivars = instance_variables.to_set
17
+ # @type [Set<Symbol>]
18
+ other_ivars = other.instance_variables.to_set
19
+
20
+ return false unless self_ivars == other_ivars
21
+
22
+ self_ivars.each do |ivar|
23
+ return false if instance_variable_get(ivar) != other.instance_variable_get(ivar)
24
+ end
25
+
26
+ true
27
+ end
28
+
29
+ alias == eql?
30
+ end
31
+ end
32
+ end