etna 0.1.44 → 0.1.45

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.
data/lib/commands.rb CHANGED
@@ -2,6 +2,7 @@ require 'date'
2
2
  require 'logger'
3
3
  require 'rollbar'
4
4
  require 'tempfile'
5
+ require 'securerandom'
5
6
  require_relative 'helpers'
6
7
  require 'yaml'
7
8
 
@@ -88,6 +89,123 @@ class EtnaApp
88
89
  end
89
90
  end
90
91
 
92
+ class Metis
93
+ include Etna::CommandExecutor
94
+ string_flags << "--project-name"
95
+ string_flags << "--bucket-name"
96
+ string_flags << "--path"
97
+ ROLLING_COUNT = 14
98
+
99
+ class PutArchive < Etna::Command
100
+ include WithEtnaClients
101
+
102
+ def execute(file, project_name:, bucket_name:, path:)
103
+ @project_name = project_name
104
+ @bucket_name = bucket_name
105
+ basename = ::File.basename(path)
106
+ dir = ::File.dirname(path)
107
+
108
+ puts "Creating archive folder"
109
+ metis_client.create_folder(Etna::Clients::Metis::CreateFolderRequest.new(
110
+ project_name: project_name,
111
+ bucket_name: bucket_name,
112
+ folder_path: dir
113
+ ))
114
+
115
+ puts "Listing archive folder"
116
+ files = metis_client.list_folder(Etna::Clients::Metis::ListFolderRequest.new(
117
+ project_name: project_name,
118
+ bucket_name: bucket_name,
119
+ folder_path: dir
120
+ )).files.all
121
+
122
+ if files.select { |f| f.file_name == basename }.length > 0
123
+ timestr = DateTime.now.strftime("%Y%m%d_%H%M")
124
+ puts "Backing up #{dir}/#{basename} to #{dir}/#{basename}.#{timestr}"
125
+ metis_client.rename_file(Etna::Clients::Metis::RenameFileRequest.new(
126
+ project_name: project_name,
127
+ bucket_name: bucket_name,
128
+ file_path: ::File.join(dir, basename),
129
+ new_bucket_name: bucket_name,
130
+ new_file_path: ::File.join(dir, "#{basename}.#{timestr}"),
131
+ ))
132
+ end
133
+
134
+ create_upload_workflow.do_upload(
135
+ ::File.open(file, "r"),
136
+ path
137
+ ) do |progress|
138
+ case progress[0]
139
+ when :error
140
+ puts("Error while uploading: #{progress[1].to_s}")
141
+ else
142
+ end
143
+ end
144
+ puts "Completed upload"
145
+
146
+ backups = files.select { |f| f.file_name != basename }.sort_by(&:file_name).reverse
147
+ if backups.length > ROLLING_COUNT
148
+ backups.slice(ROLLING_COUNT..-1).reverse.each do |f|
149
+ puts "Removing rolling back up #{f.file_name}"
150
+ metis_client.delete_file(Etna::Clients::Metis::DeleteFileRequest.new(
151
+ project_name: project_name,
152
+ bucket_name: bucket_name,
153
+ file_path: ::File.join(dir, f.file_name)
154
+ ))
155
+ end
156
+ end
157
+ end
158
+
159
+ def create_upload_workflow
160
+ Etna::Clients::Metis::MetisUploadWorkflow.new(
161
+ metis_client: @metis_client,
162
+ metis_uid: SecureRandom.hex,
163
+ project_name: @project_name,
164
+ bucket_name: @bucket_name,
165
+ max_attempts: 3
166
+ )
167
+ end
168
+ end
169
+
170
+ class PullArchive < Etna::Command
171
+ include WithEtnaClients
172
+
173
+ def execute(project_name:, bucket_name:, path:)
174
+ @project_name = project_name
175
+ @bucket_name = bucket_name
176
+ basename = ::File.basename(path)
177
+ dir = ::File.dirname(path)
178
+
179
+ files = metis_client.list_folder(Etna::Clients::Metis::ListFolderRequest.new(
180
+ project_name: project_name,
181
+ bucket_name: bucket_name,
182
+ folder_path: dir
183
+ )).files.all
184
+
185
+ files.sort_by(&:file_name).reverse
186
+
187
+ archived = files.select { |f| f.file_name == basename }.first
188
+ if archived.nil?
189
+ archived = files.first
190
+ end
191
+
192
+ tmp = Tempfile.new('download', Dir.pwd)
193
+ begin
194
+ puts "Downloading to #{basename} from #{archived.file_name}"
195
+ metis_client.download_file(archived) do |chunk|
196
+ tmp.write(chunk)
197
+ end
198
+
199
+ # Atomic operation -- ensure we only place the file in position when it successfully loads
200
+ ::File.rename(tmp.path, ::File.join(::Dir.pwd, basename))
201
+ rescue
202
+ tmp.close!
203
+ raise
204
+ end
205
+ end
206
+ end
207
+ end
208
+
91
209
  class Administrate
92
210
  include Etna::CommandExecutor
93
211
 
@@ -50,41 +50,8 @@ module Etna::Application
50
50
  Etna::Application.register(self)
51
51
  end
52
52
 
53
- # a <- b
54
- def deep_merge(a, b)
55
- if a.is_a?(Hash)
56
- if b.is_a?(Hash)
57
- b.keys.each do |b_key|
58
- a[b_key] = deep_merge(a[b_key], b[b_key])
59
- end
60
-
61
- return a
62
- end
63
- end
64
-
65
- a.nil? ? b : a
66
- end
67
-
68
53
  def configure(opts)
69
- @config = opts
70
-
71
- # Apply environmental variables of the form "ETNA__x__y__z_FILE"
72
- # by reading the given file.
73
- prefix = "ETNA__"
74
- ENV.keys.select { |k| k.start_with?(prefix) && k.end_with?("_FILE") }.each do |key|
75
- path = key.sub(/_FILE/, '').split("__", -1)
76
- path.shift # drop the first, just app name
77
-
78
- target = @config
79
- while path.length > 1
80
- n = path.shift
81
- target = (target[n.downcase.to_sym] ||= {})
82
- end
83
-
84
- v = YAML.load(File.read(ENV[key]))
85
- target[path.last.downcase.to_sym] =
86
- deep_merge(target[path.last.downcase.to_sym], v)
87
- end
54
+ @config = Etna::EnvironmentVariables.load_from_env('ETNA', root: opts) { |path, value| load_config_from_env_path(path, value) }
88
55
 
89
56
  if (rollbar_config = config(:rollbar)) && rollbar_config[:access_token]
90
57
  Rollbar.configure do |config|
@@ -93,6 +60,14 @@ module Etna::Application
93
60
  end
94
61
  end
95
62
 
63
+ def load_config_from_env_path(path, value)
64
+ return nil if path.empty?
65
+ return nil unless path.last.end_with?('_file')
66
+ path.last.sub!(/_file$/, '')
67
+
68
+ [path.map(&:to_sym), YAML.load(File.read(value))]
69
+ end
70
+
96
71
  def setup_yabeda
97
72
  application = self.id
98
73
  Yabeda.configure do
@@ -186,6 +161,10 @@ module Etna::Application
186
161
  (ENV["#{self.class.name.upcase}_ENV"] || :development).to_sym
187
162
  end
188
163
 
164
+ def test?
165
+ environment == "test"
166
+ end
167
+
189
168
  def id
190
169
  ENV["APP_NAME"] || self.class.name.snake_case.split(/::/).last
191
170
  end
data/lib/etna/auth.rb CHANGED
@@ -18,12 +18,16 @@ module Etna
18
18
  if [ approve_noauth(request), approve_hmac(request), approve_user(request) ].all?{|approved| !approved}
19
19
  return fail_or_redirect(request)
20
20
  end
21
-
21
+
22
22
  @app.call(request.env)
23
23
  end
24
24
 
25
25
  private
26
26
 
27
+ def janus
28
+ @janus ||= Etna::JanusUtils.new
29
+ end
30
+
27
31
  def application
28
32
  @application ||= Etna::Application.instance
29
33
  end
@@ -61,7 +65,7 @@ module Etna
61
65
  application.config(:auth_redirect).chomp('/') + '/login'
62
66
  )
63
67
  uri.query = URI.encode_www_form(refer: request.url)
64
- return [ 302, { 'Location' => uri.to_s }, [] ]
68
+ Etna::Redirect(request).to(uri.to_s)
65
69
  end
66
70
 
67
71
  def approve_noauth(request)
@@ -77,52 +81,26 @@ module Etna
77
81
  return true if route && route.ignore_janus?
78
82
 
79
83
  # process task tokens
80
- payload['task'] ? valid_task_token?(token) : true
81
- end
82
-
83
- def resource_projects(token)
84
- return [] unless has_janus_config?
85
-
86
- janus_client(token).get_projects.projects.select do |project|
87
- project.resource
88
- end
89
- rescue
90
- # If encounter any issue with Janus, we'll return no resource projects
91
- []
92
- end
93
-
94
- def janus_client(token)
95
- Etna::Clients::Janus.new(
96
- token: token,
97
- host: application.config(:janus)[:host]
98
- )
84
+ payload[:task] ? janus.valid_task_token?(token) : true
99
85
  end
100
86
 
101
- def valid_task_token?(token)
102
- return false unless has_janus_config?
103
-
104
- response = janus_client(token).validate_task_token
105
-
106
- return false unless response.code == '200'
107
-
108
- return true
87
+ def symbolize_payload_keys(payload)
88
+ payload.map{|k,v| [k.to_sym, v]}.to_h
109
89
  end
110
90
 
111
- def has_janus_config?
112
- application.config(:janus) && application.config(:janus)[:host]
91
+ def permissions(payload)
92
+ Etna::Permissions.from_encoded_permissions(payload[:perm])
113
93
  end
114
94
 
115
95
  def update_payload(payload, token, request)
116
96
  route = server.find_route(request)
117
97
 
118
- payload = payload.map{|k,v| [k.to_sym, v]}.to_h
119
-
120
98
  return payload unless route
121
99
 
122
100
  begin
123
- permissions = Etna::Permissions.from_encoded_permissions(payload[:perm])
101
+ permissions = permissions(payload)
124
102
 
125
- resource_projects(token).each do |resource_project|
103
+ janus.resource_projects(token).each do |resource_project|
126
104
  permissions.add_permission(
127
105
  Etna::Permission.new('v', resource_project.project_name)
128
106
  )
@@ -141,6 +119,8 @@ module Etna
141
119
  begin
142
120
  payload, header = application.sign.jwt_decode(token)
143
121
 
122
+ payload = symbolize_payload_keys(payload)
123
+
144
124
  return false unless janus_approved?(payload, token, request)
145
125
  return request.env['etna.user'] = Etna::User.new(
146
126
  update_payload(payload, token, request),
@@ -115,6 +115,10 @@ module Etna
115
115
  def resource
116
116
  !!@raw[:resource]
117
117
  end
118
+
119
+ def requires_agreement
120
+ !!@raw[:requires_agreement]
121
+ end
118
122
  end
119
123
 
120
124
  class TokenResponse
@@ -59,7 +59,7 @@ module Etna
59
59
  end
60
60
 
61
61
  def create_magma_project_record!
62
- magma_client.update(Etna::Clients::Magma::UpdateRequest.new(
62
+ magma_client.update_json(Etna::Clients::Magma::UpdateRequest.new(
63
63
  project_name: project_name,
64
64
  revisions: {
65
65
  'project' => { project_name => { name: project_name } },
@@ -1,4 +1,5 @@
1
1
  require 'erb'
2
+ require 'net/smtp'
2
3
 
3
4
  module Etna
4
5
  class Controller
@@ -44,7 +45,7 @@ module Etna
44
45
  rescue Exception => e
45
46
  error = e
46
47
  ensure
47
- log_request
48
+ log_request if !@request.env['etna.dont_log'] || error
48
49
  return handle_error(error) if error
49
50
  end
50
51
 
@@ -76,6 +77,24 @@ module Etna
76
77
  end
77
78
  end
78
79
 
80
+ def send_email(to_name, to_email, subject, content)
81
+ message = <<MESSAGE_END
82
+ From: Data Library <noreply@janus>
83
+ To: #{to_name} <#{to_email}>
84
+ Subject: #{subject}
85
+
86
+ #{content}
87
+ MESSAGE_END
88
+
89
+ unless @server.send(:application).test?
90
+ Net::SMTP.start('smtp.ucsf.edu') do |smtp|
91
+ smtp.send_message message, 'noreply@janus', to_email
92
+ end
93
+ end
94
+ rescue => e
95
+ @logger.log_error(e)
96
+ end
97
+
79
98
  def require_params(*params)
80
99
  missing_params = params.reject{|p| @params.key?(p) }
81
100
  raise Etna::BadRequest, "Missing param #{missing_params.join(', ')}" unless missing_params.empty?
data/lib/etna/cwl.rb CHANGED
@@ -559,6 +559,14 @@ module Etna
559
559
  def id
560
560
  @attributes['id']
561
561
  end
562
+
563
+ def type
564
+ @attributes['type']
565
+ end
566
+
567
+ def format
568
+ @attributes['format']
569
+ end
562
570
  end
563
571
 
564
572
  class WorkflowInputParameter < Cwl
@@ -0,0 +1,50 @@
1
+ module Etna
2
+ module EnvironmentVariables
3
+ # a <- b
4
+ def self.deep_merge(a, b)
5
+ if a.is_a?(Hash)
6
+ if b.is_a?(Hash)
7
+ b.keys.each do |b_key|
8
+ a[b_key] = deep_merge(a[b_key], b[b_key])
9
+ end
10
+
11
+ return a
12
+ end
13
+ end
14
+
15
+ a.nil? ? b : a
16
+ end
17
+
18
+ def self.load_from_env(prefix, root: {}, env: ENV, downcase: true, sep: '__', &path_to_value_mapper)
19
+ env.keys.each do |key|
20
+ next unless key.start_with?(prefix + sep)
21
+
22
+ path = key.split(sep, -1)
23
+ path.shift
24
+ if downcase
25
+ path.each(&:downcase!)
26
+ end
27
+
28
+ result = path_to_value_mapper.call(path, env[key])
29
+ next unless result
30
+
31
+ path, value = result
32
+
33
+ if path.empty?
34
+ root = EnvironmentVariables.deep_merge(root, value)
35
+ next
36
+ end
37
+
38
+ target = root
39
+ while path.length > 1
40
+ n = path.shift
41
+ target = (target[n] ||= {})
42
+ end
43
+
44
+ target[path.last] = EnvironmentVariables.deep_merge(target[path.last], value)
45
+ end
46
+
47
+ root
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,57 @@
1
+ module Etna
2
+ class Injection
3
+ # Extend into class
4
+ module FromHash
5
+ def from_hash(hash, hash_has_string_keys, rest: nil, key_rest: nil)
6
+ ::Etna::Injection.inject_new(self, hash ,hash_has_string_keys, rest: rest, key_rest: key_rest) do |missing_p|
7
+ raise "required argument '#{missing_p}' of #{self.name} is missing!"
8
+ end
9
+ end
10
+ end
11
+
12
+ def self.inject_new(cls, hash, hash_has_string_keys, rest: nil, key_rest: nil, &missing_req_param_cb)
13
+ args, kwds = prep_args(
14
+ cls.instance_method(:initialize), hash, hash_has_string_keys,
15
+ rest: rest, key_rest: key_rest, &missing_req_param_cb
16
+ )
17
+
18
+ cls.new(*args, **kwds)
19
+ end
20
+
21
+ def self.prep_args(method, hash, hash_has_string_keys, rest: nil, key_rest: nil, &missing_req_param_cb)
22
+ new_k_params = {}
23
+ new_p_params = []
24
+
25
+ method.parameters.each do |type, p_key|
26
+ h_key = hash_has_string_keys ? p_key.to_s : p_key
27
+ if type == :rest && rest
28
+ new_p_params.append(*rest)
29
+ elsif type == :keyrest && key_rest
30
+ new_k_params.update(key_rest)
31
+ elsif type == :req || type == :opt
32
+ if hash.include?(h_key)
33
+ new_p_params << hash[h_key]
34
+ elsif type == :req
35
+ if block_given?
36
+ new_p_params << missing_req_param_cb.call(h_key)
37
+ else
38
+ new_p_params << nil
39
+ end
40
+ end
41
+ elsif type == :keyreq || type == :key
42
+ if hash.include?(h_key)
43
+ new_k_params[p_key] = hash[h_key]
44
+ elsif type == :keyreq
45
+ if block_given?
46
+ new_k_params[p_key] = missing_req_param_cb.call(h_key)
47
+ else
48
+ new_k_params[p_key] = nil
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ [new_p_params, new_k_params]
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,55 @@
1
+ # Utility class to work with Janus for authz and authn
2
+ module Etna
3
+ class JanusUtils
4
+ def initialize
5
+ end
6
+
7
+ def projects(token)
8
+ return [] unless has_janus_config?
9
+
10
+ janus_client(token).get_projects.projects
11
+ rescue
12
+ # If encounter any issue with Janus, we'll return no projects
13
+ []
14
+ end
15
+
16
+ def resource_projects(token)
17
+ projects(token).select do |project|
18
+ !!project.resource && !project.requires_agreement
19
+ end
20
+ end
21
+
22
+ def community_projects(token)
23
+ projects(token).select do |project|
24
+ !!project.resource && !!project.requires_agreement
25
+ end
26
+ end
27
+
28
+ def janus_client(token)
29
+ Etna::Clients::Janus.new(
30
+ token: token,
31
+ host: application.config(:janus)[:host],
32
+ )
33
+ end
34
+
35
+ def valid_task_token?(token)
36
+ return false unless has_janus_config?
37
+
38
+ response = janus_client(token).validate_task_token
39
+
40
+ return false unless response.code == "200"
41
+
42
+ return true
43
+ end
44
+
45
+ private
46
+
47
+ def application
48
+ @application ||= Etna::Application.instance
49
+ end
50
+
51
+ def has_janus_config?
52
+ application.config(:janus) && application.config(:janus)[:host]
53
+ end
54
+ end
55
+ end
@@ -50,6 +50,10 @@ module Etna
50
50
  @permissions << permission
51
51
  end
52
52
 
53
+ def any?(&block)
54
+ @permissions.any?(&block)
55
+ end
56
+
53
57
  private
54
58
 
55
59
  def current_project_names
@@ -64,6 +68,7 @@ module Etna
64
68
  "A" => :admin,
65
69
  "E" => :editor,
66
70
  "V" => :viewer,
71
+ "G" => :guest
67
72
  }
68
73
 
69
74
  def initialize(role_key, project_name)
@@ -0,0 +1,36 @@
1
+ module Etna
2
+ def self.Redirect(req)
3
+ Redirect.new(req)
4
+ end
5
+
6
+ class Redirect
7
+ def initialize(request)
8
+ @request = request
9
+ end
10
+
11
+ def to(path, &block)
12
+ return Rack::Response.new(
13
+ [ { errors: [ 'Cannot redirect out of domain' ] }.to_json ], 422,
14
+ { 'Content-Type' => 'application/json' }
15
+ ).finish unless matches_domain?(path)
16
+
17
+ response = Rack::Response.new
18
+
19
+ response.redirect(path.gsub("http://", "https://"), 302)
20
+
21
+ yield response if block_given?
22
+
23
+ response.finish
24
+ end
25
+
26
+ private
27
+
28
+ def matches_domain?(path)
29
+ top_domain(@request.hostname) == top_domain(URI(path).host)
30
+ end
31
+
32
+ def top_domain(host_name)
33
+ host_name.split('.')[-2..-1].join('.')
34
+ end
35
+ end
36
+ end
data/lib/etna/route.rb CHANGED
@@ -15,6 +15,7 @@ module Etna
15
15
  @block = block
16
16
  @match_ext = options[:match_ext]
17
17
  @log_redact_keys = options[:log_redact_keys]
18
+ @dont_log = options[:dont_log]
18
19
  end
19
20
 
20
21
  def to_hash
@@ -41,7 +42,7 @@ module Etna
41
42
  if params
42
43
  PARAM_TYPES.reduce(route) do |path,pat|
43
44
  path.gsub(pat) do
44
- URI.encode( params[$1.to_sym], UNSAFE)
45
+ params[$1.to_sym].split('/').map { |c| URI.encode_www_form_component(c) }.join('/')
45
46
  end
46
47
  end
47
48
  else
@@ -123,10 +124,19 @@ module Etna
123
124
  update_params(request)
124
125
 
125
126
  unless authorized?(request)
127
+ if cc_available?(request)
128
+ if request.content_type == 'application/json'
129
+ return [ 403, { 'Content-Type' => 'application/json' }, [ { error: 'You are forbidden from performing this action, but you can visit the project home page and request access.' }.to_json ] ]
130
+ else
131
+ return cc_redirect(request)
132
+ end
133
+ end
134
+
126
135
  return [ 403, { 'Content-Type' => 'application/json' }, [ { error: 'You are forbidden from performing this action.' }.to_json ] ]
127
136
  end
128
137
 
129
138
  request.env['etna.redact_keys'] = @log_redact_keys
139
+ request.env['etna.dont_log'] = @dont_log
130
140
 
131
141
  if @action
132
142
  controller, action = @action.split('#')
@@ -160,6 +170,23 @@ module Etna
160
170
 
161
171
  private
162
172
 
173
+ def janus
174
+ @janus ||= Etna::JanusUtils.new
175
+ end
176
+
177
+ # If the application asks for a code of conduct redirect
178
+ def cc_redirect(request, msg = 'You are unauthorized')
179
+ return [ 401, { 'Content-Type' => 'text/html' }, [msg] ] unless application.config(:auth_redirect)
180
+
181
+ params = request.env['rack.request.params']
182
+
183
+ uri = URI(
184
+ application.config(:auth_redirect).chomp('/') + "/#{params[:project_name]}/cc"
185
+ )
186
+ uri.query = URI.encode_www_form(refer: request.url)
187
+ Etna::Redirect(request).to(uri.to_s)
188
+ end
189
+
163
190
  def application
164
191
  @application ||= Etna::Application.instance
165
192
  end
@@ -189,6 +216,24 @@ module Etna
189
216
  end
190
217
  end
191
218
 
219
+ def cc_available?(request)
220
+ user = request.env['etna.user']
221
+
222
+ return false unless user
223
+
224
+ params = request.env['rack.request.params']
225
+
226
+ return false unless params[:project_name]
227
+
228
+ # Only check for a CC if the user does not currently have permissions
229
+ # for the project
230
+ return false if user.permissions.keys.include?(params[:project_name])
231
+
232
+ !janus.community_projects(user.token).select do |project|
233
+ project.project_name == params[:project_name]
234
+ end.first.nil?
235
+ end
236
+
192
237
  def hmac_authorized?(request)
193
238
  # either there is no hmac requirement, or we have a valid hmac
194
239
  !@auth[:hmac] || request.env['etna.hmac']&.valid?
@@ -211,7 +256,7 @@ module Etna
211
256
  Hash[
212
257
  match.names.map(&:to_sym).zip(
213
258
  match.captures.map do |capture|
214
- URI.decode(capture)
259
+ capture.split('/').map {|c| URI.decode_www_form_component(c) }.join('/')
215
260
  end
216
261
  )
217
262
  ]