10816: Use a recursive postgres query instead of building the permission graph in...
[arvados.git] / services / api / app / models / container_request.rb
1 require 'whitelist_update'
2
3 class ContainerRequest < ArvadosModel
4   include HasUuid
5   include KindAndEtag
6   include CommonApiTemplate
7   include WhitelistUpdate
8
9   serialize :properties, Hash
10   serialize :environment, Hash
11   serialize :mounts, Hash
12   serialize :runtime_constraints, Hash
13   serialize :command, Array
14   serialize :scheduling_parameters, Hash
15
16   before_validation :fill_field_defaults, :if => :new_record?
17   before_validation :validate_runtime_constraints
18   before_validation :validate_scheduling_parameters
19   before_validation :set_container
20   validates :command, :container_image, :output_path, :cwd, :presence => true
21   validate :validate_state_change
22   validate :validate_change
23   after_save :update_priority
24   after_save :finalize_if_needed
25   before_create :set_requesting_container_uuid
26
27   api_accessible :user, extend: :common do |t|
28     t.add :command
29     t.add :container_count
30     t.add :container_count_max
31     t.add :container_image
32     t.add :container_uuid
33     t.add :cwd
34     t.add :description
35     t.add :environment
36     t.add :expires_at
37     t.add :filters
38     t.add :log_uuid
39     t.add :mounts
40     t.add :name
41     t.add :output_name
42     t.add :output_path
43     t.add :output_uuid
44     t.add :priority
45     t.add :properties
46     t.add :requesting_container_uuid
47     t.add :runtime_constraints
48     t.add :scheduling_parameters
49     t.add :state
50     t.add :use_existing
51   end
52
53   # Supported states for a container request
54   States =
55     [
56      (Uncommitted = 'Uncommitted'),
57      (Committed = 'Committed'),
58      (Final = 'Final'),
59     ]
60
61   State_transitions = {
62     nil => [Uncommitted, Committed],
63     Uncommitted => [Committed],
64     Committed => [Final]
65   }
66
67   def state_transitions
68     State_transitions
69   end
70
71   def skip_uuid_read_permission_check
72     # XXX temporary until permissions are sorted out.
73     %w(modified_by_client_uuid container_uuid requesting_container_uuid)
74   end
75
76   def finalize_if_needed
77     if state == Committed && Container.find_by_uuid(container_uuid).final?
78       reload
79       act_as_system_user do
80         finalize!
81       end
82     end
83   end
84
85   # Finalize the container request after the container has
86   # finished/cancelled.
87   def finalize!
88     out_coll = nil
89     log_coll = nil
90     c = Container.find_by_uuid(container_uuid)
91     ['output', 'log'].each do |out_type|
92       pdh = c.send(out_type)
93       next if pdh.nil?
94       if self.output_name and out_type == 'output'
95         coll_name = self.output_name
96       else
97         coll_name = "Container #{out_type} for request #{uuid}"
98       end
99       manifest = Collection.where(portable_data_hash: pdh).first.manifest_text
100       begin
101         coll = Collection.create!(owner_uuid: owner_uuid,
102                                   manifest_text: manifest,
103                                   portable_data_hash: pdh,
104                                   name: coll_name,
105                                   properties: {
106                                     'type' => out_type,
107                                     'container_request' => uuid,
108                                   })
109       rescue ActiveRecord::RecordNotUnique => rn
110         # In case this is executed as part of a transaction: When a Postgres exception happens,
111         # the following statements on the same transaction become invalid, so a rollback is
112         # needed. One example are Unit Tests, every test is enclosed inside a transaction so
113         # that the database can be reverted before every new test starts.
114         # See: http://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html#module-ActiveRecord::Transactions::ClassMethods-label-Exception+handling+and+rolling+back
115         ActiveRecord::Base.connection.execute 'ROLLBACK'
116         raise unless out_type == 'output' and self.output_name
117         # Postgres specific unique name check. See ApplicationController#create for
118         # a detailed explanation.
119         raise unless rn.original_exception.is_a? PG::UniqueViolation
120         err = rn.original_exception
121         detail = err.result.error_field(PG::Result::PG_DIAG_MESSAGE_DETAIL)
122         raise unless /^Key \(owner_uuid, name\)=\([a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}, .*?\) already exists\./.match detail
123         # Output collection name collision detected: append a timestamp.
124         coll_name = "#{self.output_name} #{Time.now.getgm.strftime('%FT%TZ')}"
125         retry
126       end
127       if out_type == 'output'
128         out_coll = coll.uuid
129       else
130         log_coll = coll.uuid
131       end
132     end
133     update_attributes!(state: Final, output_uuid: out_coll, log_uuid: log_coll)
134   end
135
136   protected
137
138   def fill_field_defaults
139     self.state ||= Uncommitted
140     self.environment ||= {}
141     self.runtime_constraints ||= {}
142     self.mounts ||= {}
143     self.cwd ||= "."
144     self.container_count_max ||= Rails.configuration.container_count_max
145     self.scheduling_parameters ||= {}
146   end
147
148   # Create a new container (or find an existing one) to satisfy this
149   # request.
150   def resolve
151     c_mounts = mounts_for_container
152     c_runtime_constraints = runtime_constraints_for_container
153     c_container_image = container_image_for_container
154     c = act_as_system_user do
155       c_attrs = {command: self.command,
156                  cwd: self.cwd,
157                  environment: self.environment,
158                  output_path: self.output_path,
159                  container_image: c_container_image,
160                  mounts: c_mounts,
161                  runtime_constraints: c_runtime_constraints}
162
163       reusable = self.use_existing ? Container.find_reusable(c_attrs) : nil
164       if not reusable.nil?
165         reusable
166       else
167         c_attrs[:scheduling_parameters] = self.scheduling_parameters
168         Container.create!(c_attrs)
169       end
170     end
171     self.container_uuid = c.uuid
172   end
173
174   # Return a runtime_constraints hash that complies with
175   # self.runtime_constraints but is suitable for saving in a container
176   # record, i.e., has specific values instead of ranges.
177   #
178   # Doing this as a step separate from other resolutions, like "git
179   # revision range to commit hash", makes sense only when there is no
180   # opportunity to reuse an existing container (e.g., container reuse
181   # is not implemented yet, or we have already found that no existing
182   # containers are suitable).
183   def runtime_constraints_for_container
184     rc = {}
185     runtime_constraints.each do |k, v|
186       if v.is_a? Array
187         rc[k] = v[0]
188       else
189         rc[k] = v
190       end
191     end
192     rc
193   end
194
195   # Return a mounts hash suitable for a Container, i.e., with every
196   # readonly collection UUID resolved to a PDH.
197   def mounts_for_container
198     c_mounts = {}
199     mounts.each do |k, mount|
200       mount = mount.dup
201       c_mounts[k] = mount
202       if mount['kind'] != 'collection'
203         next
204       end
205       if (uuid = mount.delete 'uuid')
206         c = Collection.
207           readable_by(current_user).
208           where(uuid: uuid).
209           select(:portable_data_hash).
210           first
211         if !c
212           raise ArvadosModel::UnresolvableContainerError.new "cannot mount collection #{uuid.inspect}: not found"
213         end
214         if mount['portable_data_hash'].nil?
215           # PDH not supplied by client
216           mount['portable_data_hash'] = c.portable_data_hash
217         elsif mount['portable_data_hash'] != c.portable_data_hash
218           # UUID and PDH supplied by client, but they don't agree
219           raise ArgumentError.new "cannot mount collection #{uuid.inspect}: current portable_data_hash #{c.portable_data_hash.inspect} does not match #{c['portable_data_hash'].inspect} in request"
220         end
221       end
222     end
223     return c_mounts
224   end
225
226   # Return a container_image PDH suitable for a Container.
227   def container_image_for_container
228     coll = Collection.for_latest_docker_image(container_image)
229     if !coll
230       raise ArvadosModel::UnresolvableContainerError.new "docker image #{container_image.inspect} not found"
231     end
232     return coll.portable_data_hash
233   end
234
235   def set_container
236     if (container_uuid_changed? and
237         not current_user.andand.is_admin and
238         not container_uuid.nil?)
239       errors.add :container_uuid, "can only be updated to nil."
240       return false
241     end
242     if state_changed? and state == Committed and container_uuid.nil?
243       resolve
244     end
245     if self.container_uuid != self.container_uuid_was
246       if self.container_count_changed?
247         errors.add :container_count, "cannot be updated directly."
248         return false
249       else
250         self.container_count += 1
251       end
252     end
253   end
254
255   def validate_runtime_constraints
256     case self.state
257     when Committed
258       ['vcpus', 'ram'].each do |k|
259         if not (runtime_constraints.include? k and
260                 runtime_constraints[k].is_a? Integer and
261                 runtime_constraints[k] > 0)
262           errors.add :runtime_constraints, "#{k} must be a positive integer"
263         end
264       end
265
266       if runtime_constraints.include? 'keep_cache_ram' and
267          (!runtime_constraints['keep_cache_ram'].is_a?(Integer) or
268           runtime_constraints['keep_cache_ram'] <= 0)
269             errors.add :runtime_constraints, "keep_cache_ram must be a positive integer"
270       elsif !runtime_constraints.include? 'keep_cache_ram'
271         runtime_constraints['keep_cache_ram'] = Rails.configuration.container_default_keep_cache_ram
272       end
273     end
274   end
275
276   def validate_scheduling_parameters
277     if self.state == Committed
278       if scheduling_parameters.include? 'partitions' and
279          (!scheduling_parameters['partitions'].is_a?(Array) ||
280           scheduling_parameters['partitions'].reject{|x| !x.is_a?(String)}.size !=
281             scheduling_parameters['partitions'].size)
282             errors.add :scheduling_parameters, "partitions must be an array of strings"
283       end
284     end
285   end
286
287   def validate_change
288     permitted = [:owner_uuid]
289
290     case self.state
291     when Uncommitted
292       # Permit updating most fields
293       permitted.push :command, :container_count_max,
294                      :container_image, :cwd, :description, :environment,
295                      :filters, :mounts, :name, :output_path, :priority,
296                      :properties, :requesting_container_uuid, :runtime_constraints,
297                      :state, :container_uuid, :use_existing, :scheduling_parameters,
298                      :output_name
299
300     when Committed
301       if container_uuid.nil?
302         errors.add :container_uuid, "has not been resolved to a container."
303       end
304
305       if priority.nil?
306         errors.add :priority, "cannot be nil"
307       end
308
309       # Can update priority, container count, name and description
310       permitted.push :priority, :container_count, :container_count_max, :container_uuid,
311                      :name, :description
312
313       if self.state_changed?
314         # Allow create-and-commit in a single operation.
315         permitted.push :command, :container_image, :cwd, :description, :environment,
316                        :filters, :mounts, :name, :output_path, :properties,
317                        :requesting_container_uuid, :runtime_constraints,
318                        :state, :container_uuid, :use_existing, :scheduling_parameters,
319                        :output_name
320       end
321
322     when Final
323       if not current_user.andand.is_admin and not (self.name_changed? || self.description_changed?)
324         errors.add :state, "of container request can only be set to Final by system."
325       end
326
327       if self.state_changed? || self.name_changed? || self.description_changed? || self.output_uuid_changed? || self.log_uuid_changed?
328           permitted.push :state, :name, :description, :output_uuid, :log_uuid
329       else
330         errors.add :state, "does not allow updates"
331       end
332
333     else
334       errors.add :state, "invalid value"
335     end
336
337     check_update_whitelist permitted
338   end
339
340   def update_priority
341     if self.state_changed? or
342         self.priority_changed? or
343         self.container_uuid_changed?
344       act_as_system_user do
345         Container.
346           where('uuid in (?)',
347                 [self.container_uuid_was, self.container_uuid].compact).
348           map(&:update_priority!)
349       end
350     end
351   end
352
353   def set_requesting_container_uuid
354     return !new_record? if self.requesting_container_uuid   # already set
355
356     token_uuid = current_api_client_authorization.andand.uuid
357     container = Container.where('auth_uuid=?', token_uuid).order('created_at desc').first
358     self.requesting_container_uuid = container.uuid if container
359     true
360   end
361 end