etna 0.1.15 → 0.1.21

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/bin/etna +18 -0
  3. data/etna.completion +1001 -0
  4. data/etna_app.completion +133 -0
  5. data/ext/completions/extconf.rb +20 -0
  6. data/lib/commands.rb +395 -0
  7. data/lib/etna.rb +7 -0
  8. data/lib/etna/application.rb +46 -22
  9. data/lib/etna/client.rb +82 -48
  10. data/lib/etna/clients.rb +4 -0
  11. data/lib/etna/clients/enum.rb +9 -0
  12. data/lib/etna/clients/janus.rb +2 -0
  13. data/lib/etna/clients/janus/client.rb +73 -0
  14. data/lib/etna/clients/janus/models.rb +78 -0
  15. data/lib/etna/clients/magma.rb +4 -0
  16. data/lib/etna/clients/magma/client.rb +80 -0
  17. data/lib/etna/clients/magma/formatting.rb +1 -0
  18. data/lib/etna/clients/magma/formatting/models_csv.rb +354 -0
  19. data/lib/etna/clients/magma/models.rb +630 -0
  20. data/lib/etna/clients/magma/workflows.rb +10 -0
  21. data/lib/etna/clients/magma/workflows/add_project_models_workflow.rb +67 -0
  22. data/lib/etna/clients/magma/workflows/attribute_actions_from_json_workflow.rb +62 -0
  23. data/lib/etna/clients/magma/workflows/create_project_workflow.rb +123 -0
  24. data/lib/etna/clients/magma/workflows/crud_workflow.rb +85 -0
  25. data/lib/etna/clients/magma/workflows/ensure_containing_record_workflow.rb +44 -0
  26. data/lib/etna/clients/magma/workflows/file_attributes_blank_workflow.rb +68 -0
  27. data/lib/etna/clients/magma/workflows/file_linking_workflow.rb +115 -0
  28. data/lib/etna/clients/magma/workflows/json_converters.rb +81 -0
  29. data/lib/etna/clients/magma/workflows/json_validators.rb +452 -0
  30. data/lib/etna/clients/magma/workflows/model_synchronization_workflow.rb +306 -0
  31. data/lib/etna/clients/magma/workflows/record_synchronization_workflow.rb +63 -0
  32. data/lib/etna/clients/magma/workflows/update_attributes_from_csv_workflow.rb +246 -0
  33. data/lib/etna/clients/metis.rb +3 -0
  34. data/lib/etna/clients/metis/client.rb +239 -0
  35. data/lib/etna/clients/metis/models.rb +313 -0
  36. data/lib/etna/clients/metis/workflows.rb +2 -0
  37. data/lib/etna/clients/metis/workflows/metis_download_workflow.rb +37 -0
  38. data/lib/etna/clients/metis/workflows/metis_upload_workflow.rb +137 -0
  39. data/lib/etna/clients/polyphemus.rb +3 -0
  40. data/lib/etna/clients/polyphemus/client.rb +33 -0
  41. data/lib/etna/clients/polyphemus/models.rb +68 -0
  42. data/lib/etna/clients/polyphemus/workflows.rb +1 -0
  43. data/lib/etna/clients/polyphemus/workflows/set_configuration_workflow.rb +47 -0
  44. data/lib/etna/command.rb +243 -5
  45. data/lib/etna/controller.rb +4 -0
  46. data/lib/etna/csvs.rb +159 -0
  47. data/lib/etna/directed_graph.rb +56 -0
  48. data/lib/etna/environment_scoped.rb +19 -0
  49. data/lib/etna/errors.rb +6 -0
  50. data/lib/etna/generate_autocompletion_script.rb +131 -0
  51. data/lib/etna/json_serializable_struct.rb +37 -0
  52. data/lib/etna/logger.rb +24 -2
  53. data/lib/etna/multipart_serializable_nested_hash.rb +50 -0
  54. data/lib/etna/route.rb +1 -1
  55. data/lib/etna/server.rb +3 -0
  56. data/lib/etna/spec/vcr.rb +99 -0
  57. data/lib/etna/templates/attribute_actions_template.json +43 -0
  58. data/lib/etna/test_auth.rb +3 -1
  59. data/lib/etna/user.rb +4 -0
  60. data/lib/helpers.rb +90 -0
  61. metadata +70 -5
@@ -0,0 +1,2 @@
1
+ require_relative './workflows/metis_download_workflow'
2
+ require_relative './workflows/metis_upload_workflow'
@@ -0,0 +1,37 @@
1
+ require 'ostruct'
2
+ require 'digest'
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+
6
+ module Etna
7
+ module Clients
8
+ class Metis
9
+ class MetisDownloadWorkflow < Struct.new(:metis_client, :project_name, :bucket_name, :max_attempts, keyword_init: true)
10
+
11
+ def initialize(args)
12
+ super({max_attempts: 3}.update(args))
13
+ end
14
+
15
+ # TODO: Might be possible to use range headers to select and resume downloads on failure in the future.
16
+ def do_download(dest_file, metis_file, &block)
17
+ size = metis_file.size
18
+ completed = 0.0
19
+ start = Time.now
20
+
21
+ ::File.open(dest_file, "w") do |io|
22
+ metis_client.download_file(metis_file) do |chunk|
23
+ io.write chunk
24
+ completed += chunk.size
25
+
26
+ block.call([
27
+ :progress,
28
+ size == 0 ? 1 : completed / size,
29
+ (completed / (Time.now - start)).round(2),
30
+ ]) unless block.nil?
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,137 @@
1
+ require 'ostruct'
2
+ require 'digest'
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+ require 'securerandom'
6
+
7
+ module Etna
8
+ module Clients
9
+ class Metis
10
+ class MetisUploadWorkflow < Struct.new(:metis_client, :metis_uid, :project_name, :bucket_name, :max_attempts, keyword_init: true)
11
+
12
+ def initialize(args)
13
+ super({max_attempts: 3, metis_uid: SecureRandom.hex}.update(args))
14
+ end
15
+
16
+ def do_upload(source_file, dest_path, &block)
17
+ upload = Upload.new(source_file: source_file)
18
+
19
+ dir = ::File.dirname(dest_path)
20
+ metis_client.create_folder(CreateFolderRequest.new(
21
+ project_name: project_name,
22
+ bucket_name: bucket_name,
23
+ folder_path: dir,
24
+ ))
25
+
26
+ authorize_response = metis_client.authorize_upload(AuthorizeUploadRequest.new(
27
+ project_name: project_name,
28
+ bucket_name: bucket_name,
29
+ file_path: dest_path,
30
+ ))
31
+
32
+ upload_parts(upload, authorize_response.upload_path, &block)
33
+ end
34
+
35
+ private
36
+
37
+ def upload_parts(upload, upload_path, attempt_number = 1, reset = false, &block)
38
+ if attempt_number > max_attempts
39
+ raise "Upload failed after (#{attempt_number}) attempts."
40
+ end
41
+
42
+ start = Time.now
43
+ unsent_zero_byte_file = upload.current_byte_position == 0
44
+
45
+ upload.resume_from!(metis_client.upload_start(UploadStartRequest.new(
46
+ upload_path: upload_path,
47
+ file_size: upload.file_size,
48
+ next_blob_size: upload.next_blob_size,
49
+ next_blob_hash: upload.next_blob_hash,
50
+ metis_uid: metis_uid,
51
+ reset: reset,
52
+ )))
53
+
54
+ until upload.complete? && !unsent_zero_byte_file
55
+ begin
56
+ blob_bytes = upload.next_blob_bytes
57
+ byte_position = upload.current_byte_position
58
+
59
+ upload.advance_position!
60
+ metis_client.upload_blob(UploadBlobRequest.new(
61
+ upload_path: upload_path,
62
+ next_blob_size: upload.next_blob_size,
63
+ next_blob_hash: upload.next_blob_hash,
64
+ blob_data: StringIO.new(blob_bytes),
65
+ metis_uid: metis_uid,
66
+ current_byte_position: byte_position,
67
+ ))
68
+
69
+ unsent_zero_byte_file = false
70
+ rescue Etna::Error => e
71
+ m = yield [:error, e] unless block.nil?
72
+ if m == false
73
+ raise e
74
+ end
75
+
76
+ if e.status == 422
77
+ return upload_parts(upload, upload_path, attempt_number + 1, true, &block)
78
+ elsif e.status >= 500
79
+ return upload_parts(upload, upload_path, attempt_number + 1, &block)
80
+ end
81
+
82
+ raise e
83
+ end
84
+
85
+ yield [
86
+ :progress,
87
+ upload.file_size == 0 ? 1.0 : upload.current_byte_position.to_f / upload.file_size,
88
+ (upload.current_byte_position / (Time.now - start).to_f).round(2),
89
+ ] unless block.nil?
90
+ end
91
+ end
92
+
93
+ class Upload < Struct.new(:source_file, :next_blob_size, :current_byte_position, keyword_init: true)
94
+ INITIAL_BLOB_SIZE = 2 ** 10
95
+ MAX_BLOB_SIZE = 2 ** 22
96
+ ZERO_HASH = 'd41d8cd98f00b204e9800998ecf8427e'
97
+
98
+ def initialize(**args)
99
+ super
100
+ self.next_blob_size = [file_size, INITIAL_BLOB_SIZE].min
101
+ self.current_byte_position = 0
102
+ end
103
+
104
+ def file_size
105
+ ::File.size(source_file)
106
+ end
107
+
108
+ def advance_position!
109
+ self.current_byte_position = self.current_byte_position + self.next_blob_size
110
+ self.next_blob_size = [
111
+ MAX_BLOB_SIZE,
112
+ # in fact we should stop when we hit the end of the file
113
+ file_size - current_byte_position
114
+ ].min
115
+ end
116
+
117
+ def complete?
118
+ current_byte_position >= file_size
119
+ end
120
+
121
+ def next_blob_hash
122
+ Digest::MD5.hexdigest(next_blob_bytes)
123
+ end
124
+
125
+ def next_blob_bytes
126
+ IO.binread(source_file, next_blob_size, current_byte_position)
127
+ end
128
+
129
+ def resume_from!(upload_response)
130
+ self.current_byte_position = upload_response.current_byte_position
131
+ self.next_blob_size = upload_response.next_blob_size
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,3 @@
1
+ require_relative './polyphemus/client'
2
+ require_relative './polyphemus/models'
3
+ require_relative './polyphemus/workflows'
@@ -0,0 +1,33 @@
1
+ require 'net/http/persistent'
2
+ require 'net/http/post/multipart'
3
+ require 'singleton'
4
+ require_relative '../../client'
5
+ require_relative './models'
6
+
7
+ module Etna
8
+ module Clients
9
+ class Polyphemus
10
+ def initialize(host:, token:, ignore_ssl: false, persistent: true)
11
+ raise 'Polyphemus client configuration is missing host.' unless host
12
+ raise 'Polyphemus client configuration is missing token.' unless token
13
+ @etna_client = ::Etna::Client.new(
14
+ host,
15
+ token,
16
+ routes_available: false,
17
+ persistent: persistent,
18
+ ignore_ssl: ignore_ssl)
19
+ end
20
+
21
+ def configuration(configuration_request = ConfigurationRequest.new)
22
+ json = nil
23
+ @etna_client.get(
24
+ "/configuration",
25
+ configuration_request) do |res|
26
+ json = JSON.parse(res.body, symbolize_names: true)
27
+ end
28
+
29
+ ConfigurationResponse.new(json)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,68 @@
1
+ require 'ostruct'
2
+ require_relative '../../json_serializable_struct'
3
+
4
+ # TODO: In the near future, I'd like to transition to specifying apis via SWAGGER and generating model stubs from the
5
+ # common definitions. For nowe I've written them out by hand here.
6
+ module Etna
7
+ module Clients
8
+ class Polyphemus
9
+ class ConfigurationRequest
10
+ def map
11
+ []
12
+ end
13
+ end
14
+
15
+ class ConfigurationResponse
16
+ attr_reader :raw
17
+
18
+ def initialize(raw = '')
19
+ @raw = raw
20
+ end
21
+
22
+ def environment
23
+ @raw.keys.first
24
+ end
25
+
26
+ def environments
27
+ @raw.keys
28
+ end
29
+
30
+ def environment_configuration(env = environment)
31
+ EnvironmentConfiguration.new(@raw[env])
32
+ end
33
+ end
34
+
35
+ class EnvironmentConfiguration
36
+ attr_reader :raw
37
+
38
+ def initialize(raw = {})
39
+ @raw = raw
40
+ end
41
+
42
+ def magma
43
+ @raw['magma']
44
+ end
45
+
46
+ def metis
47
+ @raw['magma']
48
+ end
49
+
50
+ def janus
51
+ @raw['magma']
52
+ end
53
+
54
+ def timur
55
+ @raw['magma']
56
+ end
57
+
58
+ def polyphemus
59
+ @raw['magma']
60
+ end
61
+
62
+ def auth_redirect
63
+ @raw['auth_redirect']
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1 @@
1
+ require_relative './workflows/set_configuration_workflow'
@@ -0,0 +1,47 @@
1
+ # Given an environment (i.e. test, development, staging),
2
+ # and a polyphemus host, fetches the configuration
3
+ # hash and sets it up in a local config file,
4
+ # ~/.etna.json by default.
5
+
6
+ # Adds a model to a project, from a JSON file.
7
+ # This workflow:
8
+ # 1) Validates the model JSON file.
9
+ # 2) Validates that the model parent and any link attributes exist in Magma.
10
+ # 3) Adds the model to the Magma project.
11
+ # 4) Adds any attributes to Magma.
12
+
13
+ require 'json'
14
+ require 'yaml'
15
+ require 'ostruct'
16
+
17
+ module Etna
18
+ module Clients
19
+ class Polyphemus
20
+ class SetConfigurationWorkflow < Struct.new(:polyphemus_client, :config_file, keyword_init: true)
21
+ def fetch_configuration
22
+ polyphemus_client.configuration
23
+ end
24
+
25
+ def update_configuration_file(**additional_config)
26
+ if File.exist?(config_file)
27
+ etna_config = YAML.load_file(config_file) || {}
28
+ else
29
+ etna_config = {}
30
+ end
31
+
32
+ config = polyphemus_client.configuration
33
+ env = config.environment.to_sym
34
+
35
+ # Ensure that env is the last key in the result, which becomes the new 'default' config.
36
+ etna_config.delete(env) if etna_config.include?(env)
37
+ env_config = config.environment_configuration.raw.dup
38
+ env_config.update(additional_config)
39
+ etna_config.update({ env => env_config })
40
+
41
+ File.open(config_file, 'w') { |f| YAML.dump(etna_config, f) }
42
+ config
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,26 +1,264 @@
1
+ require 'rollbar'
2
+
3
+ # Commands resolution works by starting from a class that includes CommandExecutor, and searches for
4
+ # INNER (belong to their scope) classes that include :find_command (generally subclasses of Etna::Command or
5
+ # classes that include CommandExecutor).
6
+ # A command completes the find_command chain by returning itself, while CommandExecutors consume one of the arguments
7
+ # to dispatch to a subcommand.
8
+ # eg of subcommands
9
+ #
10
+ # ./bin/my_app my_command help
11
+ # ./bin/my_app my_command subcommand
12
+ # class MyApp < Etna::Application
13
+ # class MyCommand
14
+ # include Etna::CommandExecutor
15
+ #
16
+ # class Subcommand < Etna::Command
17
+ # def execute
18
+ # end
19
+ # end
20
+ # end
21
+ # end
22
+ #
1
23
  module Etna
2
- class Command
3
- class << self
24
+ # Provides the usage DSL and method that includes information about a CommandExecutor or a Command.
25
+ # Commands or CommandExecutors can call 'usage' in their definition to provide a specific description
26
+ # to be given about that command. By default, the command name + desc method will be shown.
27
+ module CommandOrExecutor
28
+ module Dsl
4
29
  def usage(desc)
5
30
  define_method :usage do
6
- " #{"%-30s" % name}#{desc}"
31
+ " #{"%-45s" % command_name}#{desc}"
7
32
  end
8
33
  end
34
+
35
+ def boolean_flags
36
+ @boolean_flags ||= []
37
+ end
38
+
39
+ def string_flags
40
+ @string_flags ||= []
41
+ end
42
+ end
43
+
44
+ def flag_as_parameter(flag)
45
+ flag.gsub('--', '').gsub('-', '_')
9
46
  end
10
47
 
11
- def name
12
- self.class.name.snake_case.split(/::/).last.to_sym
48
+ def parse_flags(*args)
49
+ new_args = []
50
+ flags = {}
51
+ found_non_flag = false
52
+
53
+ until args.empty?
54
+ next_arg = args.shift
55
+
56
+ unless next_arg.start_with? '--'
57
+ new_args << next_arg
58
+ found_non_flag = true
59
+ next
60
+ end
61
+
62
+ arg_name = flag_as_parameter(next_arg).to_sym
63
+
64
+ if self.class.boolean_flags.include?(next_arg)
65
+ flags[arg_name] = true
66
+ elsif self.class.string_flags.include?(next_arg)
67
+ if args.empty?
68
+ raise "flag #{next_arg} requires an argument"
69
+ else
70
+ flags[arg_name] = args.shift
71
+ end
72
+ elsif !found_non_flag
73
+ raise "#{program_name} does not recognize flag #{next_arg}"
74
+ else
75
+ new_args << next_arg
76
+ end
77
+ end
78
+
79
+ [flags, new_args]
80
+ end
81
+
82
+ def completions_for(parameter)
83
+ if parameter == 'env' || parameter == 'environment' || parameter =~ /_env/
84
+ ['production', 'staging', 'development']
85
+ else
86
+ ["__#{parameter}__"]
87
+ end
88
+ end
89
+
90
+ def command_name
91
+ self.class.name.snake_case.split(/::/).last
92
+ end
93
+
94
+ def usage
95
+ " #{"%-45s" % command_name}#{desc}"
96
+ end
97
+
98
+ def flag_argspec
99
+ @argspec ||= []
100
+ end
101
+
102
+ # Program name is used to compose the usage display. The top level executor will just assume the running
103
+ # process's bin name, while other executors will use the 'command name' of the Command / Exectuor
104
+ # (class name derived) and whatever program_name exists for its parent scope.
105
+ def program_name
106
+ if parent.nil? || !parent.respond_to?(:program_name)
107
+ $PROGRAM_NAME
108
+ else
109
+ parent.program_name + " " + command_name
110
+ end
111
+ end
112
+
113
+ # By default, the description of a command maps the execute parameters into CLI descriptions,
114
+ # where as CommandExecutors will display their subcommands.
115
+ def desc
116
+ if respond_to?(:execute)
117
+ method(:execute).parameters.map do |type, name|
118
+ name = "..." if name.nil?
119
+
120
+ case type
121
+ when :req
122
+ "<#{name}>"
123
+ when :opt
124
+ "[<#{name}>]"
125
+ when :rest
126
+ "<#{name}>..."
127
+ when :keyrest
128
+ "[flags...]"
129
+ when :key
130
+ flag = "--#{name.to_s.gsub('_', '-')}"
131
+ if self.class.boolean_flags.include?(flag)
132
+ "[#{flag}]"
133
+ else
134
+ "[#{flag} <#{name}>]"
135
+ end
136
+ else
137
+ raise "Invalid command execute argument specification #{type}, unsure how to format description."
138
+ end
139
+ end.join(' ')
140
+ elsif respond_to?(:subcommands)
141
+ '<command> <args>...'
142
+ end
143
+ end
144
+
145
+ def self.included(cls)
146
+ cls.extend(Dsl)
147
+ end
148
+ end
149
+
150
+ # Include this module into class that dispatches to child CommandExecutors | Commands that must exist as
151
+ # inner classes. Note that non-root CommandExecutors must accept and set their @parent just like commands.
152
+ module CommandExecutor
153
+ attr_reader :parent
154
+
155
+ def initialize(parent = nil)
156
+ super()
157
+ @parent = parent
158
+ end
159
+
160
+ def self.included(cls)
161
+ cls.include(CommandOrExecutor)
162
+ cls.const_set(:Help, Class.new(Etna::Command) do
163
+ usage 'List this help'
164
+
165
+ def execute
166
+ self.parent.help
167
+ end
168
+ end) unless cls.const_defined?(:Help)
169
+ end
170
+
171
+ def help
172
+ puts "usage: #{program_name} #{desc}"
173
+ subcommands.each do |name, cmd|
174
+ puts cmd.usage
175
+ end
176
+ end
177
+
178
+ def find_command(*args, **kwds)
179
+ flags, args = parse_flags(*args)
180
+ dispatch_to_subcommand(*args, **(kwds.update(flags)))
181
+ end
182
+
183
+ def dispatch_to_subcommand(cmd = 'help', *args, **kwds)
184
+ unless subcommands.include?(cmd)
185
+ cmd = 'help'
186
+ args = []
187
+ end
188
+
189
+ subcommands[cmd].find_command(*args, **kwds)
190
+ end
191
+
192
+ def subcommands
193
+ @subcommands ||= self.class.constants.sort.reduce({}) do |acc, n|
194
+ acc.tap do
195
+ c = self.class.const_get(n)
196
+ next unless c.instance_methods.include?(:find_command)
197
+ v = c.new(self)
198
+ acc[v.command_name] = v
199
+ end
200
+ end
201
+ end
202
+
203
+ def all_subcommands
204
+ subcommands.values + (subcommands.values.map { |s| s.respond_to?(:all_subcommands) ? s.all_subcommands : [] }.flatten)
205
+ end
206
+ end
207
+
208
+ class Command
209
+ include CommandOrExecutor
210
+
211
+ attr_reader :parent
212
+
213
+ def initialize(parent = nil)
214
+ @parent = parent
215
+ end
216
+
217
+ def self.parent_scope
218
+ parts = self.name.split('::')
219
+ parts.pop
220
+ Kernel.const_get(parts.join('::'))
221
+ end
222
+
223
+ def find_command(*args, **kwds)
224
+ flags, args = parse_flags(*args)
225
+ [self, args, kwds.update(flags)]
226
+ end
227
+
228
+ def fill_in_missing_params(args)
229
+ req_params = method(:execute).parameters.select { |type, name| type == :req }
230
+ args + (req_params[(args.length)..(req_params.length)] || []).map do |type, name|
231
+ puts "#{name}?"
232
+ STDIN.gets.chomp
233
+ end
234
+ end
235
+
236
+ def completions
237
+ method(:execute).parameters.map do |type, name|
238
+ name = "..." if name.nil?
239
+ if type == :req || type == :opt
240
+ [completions_for(name)]
241
+ else
242
+ []
243
+ end
244
+ end.inject([], &:+)
13
245
  end
14
246
 
15
247
  # To be overridden during inheritance.
16
248
  def execute
17
249
  raise 'Command is not implemented'
250
+ rescue => e
251
+ Rollbar.error(e)
252
+ raise
18
253
  end
19
254
 
20
255
  # To be overridden during inheritance, to e.g. connect to a database.
21
256
  # Should be called with super by inheriting method.
22
257
  def setup(config)
23
258
  Etna::Application.find(self.class).configure(config)
259
+ rescue => e
260
+ Rollbar.error(e)
261
+ raise
24
262
  end
25
263
  end
26
264
  end