capistrano-data_plane_api 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.
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