13561: Add basic support for collection versioning at the model level.
[arvados.git] / services / api / app / models / collection.rb
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: AGPL-3.0
4
5 require 'arvados/keep'
6 require 'sweep_trashed_objects'
7 require 'trashable'
8
9 class Collection < ArvadosModel
10   extend CurrentApiClient
11   extend DbCurrentTime
12   include HasUuid
13   include KindAndEtag
14   include CommonApiTemplate
15   include Trashable
16
17   serialize :properties, Hash
18   serialize :storage_classes_desired, Array
19   serialize :storage_classes_confirmed, Array
20
21   before_validation :default_empty_manifest
22   before_validation :default_storage_classes, on: :create
23   before_validation :check_encoding
24   before_validation :check_manifest_validity
25   before_validation :check_signatures
26   before_validation :strip_signatures_and_update_replication_confirmed
27   validate :ensure_pdh_matches_manifest_text
28   validate :ensure_storage_classes_desired_is_not_empty
29   validate :ensure_storage_classes_contain_non_empty_strings
30   before_save :set_file_names
31   before_create :set_current_version_uuid
32
33   api_accessible :user, extend: :common do |t|
34     t.add :name
35     t.add :description
36     t.add :properties
37     t.add :portable_data_hash
38     t.add :signed_manifest_text, as: :manifest_text
39     t.add :manifest_text, as: :unsigned_manifest_text
40     t.add :replication_desired
41     t.add :replication_confirmed
42     t.add :replication_confirmed_at
43     t.add :storage_classes_desired
44     t.add :storage_classes_confirmed
45     t.add :storage_classes_confirmed_at
46     t.add :delete_at
47     t.add :trash_at
48     t.add :is_trashed
49   end
50
51   after_initialize do
52     @signatures_checked = false
53     @computed_pdh_for_manifest_text = false
54   end
55
56   def self.attributes_required_columns
57     super.merge(
58                 # If we don't list manifest_text explicitly, the
59                 # params[:select] code gets confused by the way we
60                 # expose signed_manifest_text as manifest_text in the
61                 # API response, and never let clients select the
62                 # manifest_text column.
63                 #
64                 # We need trash_at and is_trashed to determine the
65                 # correct timestamp in signed_manifest_text.
66                 'manifest_text' => ['manifest_text', 'trash_at', 'is_trashed'],
67                 'unsigned_manifest_text' => ['manifest_text'],
68                 )
69   end
70
71   def self.ignored_select_attributes
72     super + ["updated_at", "file_names"]
73   end
74
75   def self.limit_index_columns_read
76     ["manifest_text"]
77   end
78
79   FILE_TOKEN = /^[[:digit:]]+:[[:digit:]]+:/
80   def check_signatures
81     return false if self.manifest_text.nil?
82
83     return true if current_user.andand.is_admin
84
85     # Provided the manifest_text hasn't changed materially since an
86     # earlier validation, it's safe to pass this validation on
87     # subsequent passes without checking any signatures. This is
88     # important because the signatures have probably been stripped off
89     # by the time we get to a second validation pass!
90     if @signatures_checked && @signatures_checked == computed_pdh
91       return true
92     end
93
94     if self.manifest_text_changed?
95       # Check permissions on the collection manifest.
96       # If any signature cannot be verified, raise PermissionDeniedError
97       # which will return 403 Permission denied to the client.
98       api_token = current_api_client_authorization.andand.api_token
99       signing_opts = {
100         api_token: api_token,
101         now: @validation_timestamp.to_i,
102       }
103       self.manifest_text.each_line do |entry|
104         entry.split.each do |tok|
105           if tok == '.' or tok.starts_with? './'
106             # Stream name token.
107           elsif tok =~ FILE_TOKEN
108             # This is a filename token, not a blob locator. Note that we
109             # keep checking tokens after this, even though manifest
110             # format dictates that all subsequent tokens will also be
111             # filenames. Safety first!
112           elsif Blob.verify_signature tok, signing_opts
113             # OK.
114           elsif Keep::Locator.parse(tok).andand.signature
115             # Signature provided, but verify_signature did not like it.
116             logger.warn "Invalid signature on locator #{tok}"
117             raise ArvadosModel::PermissionDeniedError
118           elsif Rails.configuration.permit_create_collection_with_unsigned_manifest
119             # No signature provided, but we are running in insecure mode.
120             logger.debug "Missing signature on locator #{tok} ignored"
121           elsif Blob.new(tok).empty?
122             # No signature provided -- but no data to protect, either.
123           else
124             logger.warn "Missing signature on locator #{tok}"
125             raise ArvadosModel::PermissionDeniedError
126           end
127         end
128       end
129     end
130     @signatures_checked = computed_pdh
131   end
132
133   def strip_signatures_and_update_replication_confirmed
134     if self.manifest_text_changed?
135       in_old_manifest = {}
136       if not self.replication_confirmed.nil?
137         self.class.each_manifest_locator(manifest_text_was) do |match|
138           in_old_manifest[match[1]] = true
139         end
140       end
141
142       stripped_manifest = self.class.munge_manifest_locators(manifest_text) do |match|
143         if not self.replication_confirmed.nil? and not in_old_manifest[match[1]]
144           # If the new manifest_text contains locators whose hashes
145           # weren't in the old manifest_text, storage replication is no
146           # longer confirmed.
147           self.replication_confirmed_at = nil
148           self.replication_confirmed = nil
149         end
150
151         # Return the locator with all permission signatures removed,
152         # but otherwise intact.
153         match[0].gsub(/\+A[^+]*/, '')
154       end
155
156       if @computed_pdh_for_manifest_text == manifest_text
157         # If the cached PDH was valid before stripping, it is still
158         # valid after stripping.
159         @computed_pdh_for_manifest_text = stripped_manifest.dup
160       end
161
162       self[:manifest_text] = stripped_manifest
163     end
164     true
165   end
166
167   def ensure_pdh_matches_manifest_text
168     if not manifest_text_changed? and not portable_data_hash_changed?
169       true
170     elsif portable_data_hash.nil? or not portable_data_hash_changed?
171       self.portable_data_hash = computed_pdh
172     elsif portable_data_hash !~ Keep::Locator::LOCATOR_REGEXP
173       errors.add(:portable_data_hash, "is not a valid locator")
174       false
175     elsif portable_data_hash[0..31] != computed_pdh[0..31]
176       errors.add(:portable_data_hash,
177                  "'#{portable_data_hash}' does not match computed hash '#{computed_pdh}'")
178       false
179     else
180       # Ignore the client-provided size part: always store
181       # computed_pdh in the database.
182       self.portable_data_hash = computed_pdh
183     end
184   end
185
186   def set_file_names
187     if self.manifest_text_changed?
188       self.file_names = manifest_files
189     end
190     true
191   end
192
193   def manifest_files
194     return '' if !self.manifest_text
195
196     names = ''
197     self.manifest_text.scan(/ \d+:\d+:(\S+)/) do |name|
198       names << name.first.gsub('\040',' ') + "\n"
199     end
200     self.manifest_text.scan(/^\.\/(\S+)/m) do |stream_name|
201       names << stream_name.first.gsub('\040',' ') + "\n"
202     end
203     names
204   end
205
206   def default_empty_manifest
207     self.manifest_text ||= ''
208   end
209
210   def skip_uuid_existence_check
211     # Avoid checking the existence of current_version_uuid, as it's
212     # assigned on creation of a new 'current version' collection, so
213     # the collection's UUID only lives on memory when the validation check
214     # is performed.
215     ['current_version_uuid']
216   end
217
218   def set_current_version_uuid
219     self.current_version_uuid ||= self.uuid
220   end
221
222   def save *arg
223     if !Rails.configuration.collection_versioning || new_record?
224       return super
225     end
226     versionable_updates = ['manifest_text', 'description', 'properties', 'name'] & self.changed_attributes.keys
227     if versionable_updates.empty?
228       return super
229     end
230     # Create a snapshot of the current collection before saving
231     Collection.transaction do
232       attrs = {}
233       # Collect attributes with pre-update values
234       versionable_updates.each do |attr|
235         attrs[attr] = self.changed_attributes[attr]
236       end
237       # Reset UUID
238       attrs[:uuid] = nil
239       snapshot = self.dup
240       # Update current version number & save
241       self.version += 1
242       super
243       # Save the snapshot with required attributes
244       snapshot.update_attributes!(attrs)
245       return true
246     end
247   end
248
249   def check_encoding
250     if manifest_text.encoding.name == 'UTF-8' and manifest_text.valid_encoding?
251       true
252     else
253       begin
254         # If Ruby thinks the encoding is something else, like 7-bit
255         # ASCII, but its stored bytes are equal to the (valid) UTF-8
256         # encoding of the same string, we declare it to be a UTF-8
257         # string.
258         utf8 = manifest_text
259         utf8.force_encoding Encoding::UTF_8
260         if utf8.valid_encoding? and utf8 == manifest_text.encode(Encoding::UTF_8)
261           self.manifest_text = utf8
262           return true
263         end
264       rescue
265       end
266       errors.add :manifest_text, "must use UTF-8 encoding"
267       false
268     end
269   end
270
271   def check_manifest_validity
272     begin
273       Keep::Manifest.validate! manifest_text
274       true
275     rescue ArgumentError => e
276       errors.add :manifest_text, e.message
277       false
278     end
279   end
280
281   def signed_manifest_text
282     if !has_attribute? :manifest_text
283       return nil
284     elsif is_trashed
285       return manifest_text
286     else
287       token = current_api_client_authorization.andand.api_token
288       exp = [db_current_time.to_i + Rails.configuration.blob_signature_ttl,
289              trash_at].compact.map(&:to_i).min
290       self.class.sign_manifest manifest_text, token, exp
291     end
292   end
293
294   def self.sign_manifest manifest, token, exp=nil
295     if exp.nil?
296       exp = db_current_time.to_i + Rails.configuration.blob_signature_ttl
297     end
298     signing_opts = {
299       api_token: token,
300       expire: exp,
301     }
302     m = munge_manifest_locators(manifest) do |match|
303       Blob.sign_locator(match[0], signing_opts)
304     end
305     return m
306   end
307
308   def self.munge_manifest_locators manifest
309     # Given a manifest text and a block, yield the regexp MatchData
310     # for each locator. Return a new manifest in which each locator
311     # has been replaced by the block's return value.
312     return nil if !manifest
313     return '' if manifest == ''
314
315     new_lines = []
316     manifest.each_line do |line|
317       line.rstrip!
318       new_words = []
319       line.split(' ').each do |word|
320         if new_words.empty?
321           new_words << word
322         elsif match = Keep::Locator::LOCATOR_REGEXP.match(word)
323           new_words << yield(match)
324         else
325           new_words << word
326         end
327       end
328       new_lines << new_words.join(' ')
329     end
330     new_lines.join("\n") + "\n"
331   end
332
333   def self.each_manifest_locator manifest
334     # Given a manifest text and a block, yield the regexp match object
335     # for each locator.
336     manifest.each_line do |line|
337       # line will have a trailing newline, but the last token is never
338       # a locator, so it's harmless here.
339       line.split(' ').each do |word|
340         if match = Keep::Locator::LOCATOR_REGEXP.match(word)
341           yield(match)
342         end
343       end
344     end
345   end
346
347   def self.normalize_uuid uuid
348     hash_part = nil
349     size_part = nil
350     uuid.split('+').each do |token|
351       if token.match(/^[0-9a-f]{32,}$/)
352         raise "uuid #{uuid} has multiple hash parts" if hash_part
353         hash_part = token
354       elsif token.match(/^\d+$/)
355         raise "uuid #{uuid} has multiple size parts" if size_part
356         size_part = token
357       end
358     end
359     raise "uuid #{uuid} has no hash part" if !hash_part
360     [hash_part, size_part].compact.join '+'
361   end
362
363   def self.get_compatible_images(readers, pattern, collections)
364     if collections.empty?
365       return []
366     end
367
368     migrations = Hash[
369       Link.where('tail_uuid in (?) AND link_class=? AND links.owner_uuid=?',
370                  collections.map(&:portable_data_hash),
371                  'docker_image_migration',
372                  system_user_uuid).
373       order('links.created_at asc').
374       map { |l|
375         [l.tail_uuid, l.head_uuid]
376       }]
377
378     migrated_collections = Hash[
379       Collection.readable_by(*readers).
380       where('portable_data_hash in (?)', migrations.values).
381       map { |c|
382         [c.portable_data_hash, c]
383       }]
384
385     collections.map { |c|
386       # Check if the listed image is compatible first, if not, then try the
387       # migration link.
388       manifest = Keep::Manifest.new(c.manifest_text)
389       if manifest.exact_file_count?(1) and manifest.files[0][1] =~ pattern
390         c
391       elsif m = migrated_collections[migrations[c.portable_data_hash]]
392         manifest = Keep::Manifest.new(m.manifest_text)
393         if manifest.exact_file_count?(1) and manifest.files[0][1] =~ pattern
394           m
395         end
396       end
397     }.compact
398   end
399
400   # Resolve a Docker repo+tag, hash, or collection PDH to an array of
401   # Collection objects, sorted by timestamp starting with the most recent
402   # match.
403   #
404   # If filter_compatible_format is true (the default), only return image
405   # collections which are support by the installation as indicated by
406   # Rails.configuration.docker_image_formats.  Will follow
407   # 'docker_image_migration' links if search_term resolves to an incompatible
408   # image, but an equivalent compatible image is available.
409   def self.find_all_for_docker_image(search_term, search_tag=nil, readers=nil, filter_compatible_format: true)
410     readers ||= [Thread.current[:user]]
411     base_search = Link.
412       readable_by(*readers).
413       readable_by(*readers, table_name: "collections").
414       joins("JOIN collections ON links.head_uuid = collections.uuid").
415       order("links.created_at DESC")
416
417     if (Rails.configuration.docker_image_formats.include? 'v1' and
418         Rails.configuration.docker_image_formats.include? 'v2') or filter_compatible_format == false
419       pattern = /^(sha256:)?[0-9A-Fa-f]{64}\.tar$/
420     elsif Rails.configuration.docker_image_formats.include? 'v2'
421       pattern = /^(sha256:)[0-9A-Fa-f]{64}\.tar$/
422     elsif Rails.configuration.docker_image_formats.include? 'v1'
423       pattern = /^[0-9A-Fa-f]{64}\.tar$/
424     else
425       raise "Unrecognized configuration for docker_image_formats #{Rails.configuration.docker_image_formats}"
426     end
427
428     # If the search term is a Collection locator that contains one file
429     # that looks like a Docker image, return it.
430     if loc = Keep::Locator.parse(search_term)
431       loc.strip_hints!
432       coll_match = readable_by(*readers).where(portable_data_hash: loc.to_s).limit(1)
433       return get_compatible_images(readers, pattern, coll_match)
434     end
435
436     if search_tag.nil? and (n = search_term.index(":"))
437       search_tag = search_term[n+1..-1]
438       search_term = search_term[0..n-1]
439     end
440
441     # Find Collections with matching Docker image repository+tag pairs.
442     matches = base_search.
443       where(link_class: "docker_image_repo+tag",
444             name: "#{search_term}:#{search_tag || 'latest'}")
445
446     # If that didn't work, find Collections with matching Docker image hashes.
447     if matches.empty?
448       matches = base_search.
449         where("link_class = ? and links.name LIKE ?",
450               "docker_image_hash", "#{search_term}%")
451     end
452
453     # Generate an order key for each result.  We want to order the results
454     # so that anything with an image timestamp is considered more recent than
455     # anything without; then we use the link's created_at as a tiebreaker.
456     uuid_timestamps = {}
457     matches.each do |link|
458       uuid_timestamps[link.head_uuid] = [(-link.properties["image_timestamp"].to_datetime.to_i rescue 0),
459        -link.created_at.to_i]
460      end
461
462     sorted = Collection.where('uuid in (?)', uuid_timestamps.keys).sort_by { |c|
463       uuid_timestamps[c.uuid]
464     }
465     compatible = get_compatible_images(readers, pattern, sorted)
466     if sorted.length > 0 and compatible.empty?
467       raise ArvadosModel::UnresolvableContainerError.new "Matching Docker image is incompatible with 'docker_image_formats' configuration."
468     end
469     compatible
470   end
471
472   def self.for_latest_docker_image(search_term, search_tag=nil, readers=nil)
473     find_all_for_docker_image(search_term, search_tag, readers).first
474   end
475
476   def self.searchable_columns operator
477     super - ["manifest_text", "current_version_uuid"]
478   end
479
480   def self.full_text_searchable_columns
481     super - ["manifest_text", "storage_classes_desired", "storage_classes_confirmed", "current_version_uuid"]
482   end
483
484   def self.where *args
485     SweepTrashedObjects.sweep_if_stale
486     super
487   end
488
489   protected
490
491   # Although the defaults for these columns is already set up on the schema,
492   # collection creation from an API client seems to ignore them, making the
493   # validation on empty desired storage classes return an error.
494   def default_storage_classes
495     if self.storage_classes_desired.nil? || self.storage_classes_desired.empty?
496       self.storage_classes_desired = ["default"]
497     end
498     self.storage_classes_confirmed ||= []
499   end
500
501   def portable_manifest_text
502     self.class.munge_manifest_locators(manifest_text) do |match|
503       if match[2] # size
504         match[1] + match[2]
505       else
506         match[1]
507       end
508     end
509   end
510
511   def compute_pdh
512     portable_manifest = portable_manifest_text
513     (Digest::MD5.hexdigest(portable_manifest) +
514      '+' +
515      portable_manifest.bytesize.to_s)
516   end
517
518   def computed_pdh
519     if @computed_pdh_for_manifest_text == manifest_text
520       return @computed_pdh
521     end
522     @computed_pdh = compute_pdh
523     @computed_pdh_for_manifest_text = manifest_text.dup
524     @computed_pdh
525   end
526
527   def ensure_permission_to_save
528     if (not current_user.andand.is_admin)
529       if (replication_confirmed_at_changed? or replication_confirmed_changed?) and
530         not (replication_confirmed_at.nil? and replication_confirmed.nil?)
531         raise ArvadosModel::PermissionDeniedError.new("replication_confirmed and replication_confirmed_at attributes cannot be changed, except by setting both to nil")
532       end
533       if (storage_classes_confirmed_changed? or storage_classes_confirmed_at_changed?) and
534         not (storage_classes_confirmed == [] and storage_classes_confirmed_at.nil?)
535         raise ArvadosModel::PermissionDeniedError.new("storage_classes_confirmed and storage_classes_confirmed_at attributes cannot be changed, except by setting them to [] and nil respectively")
536       end
537     end
538     super
539   end
540
541   def ensure_storage_classes_desired_is_not_empty
542     if self.storage_classes_desired.empty?
543       raise ArvadosModel::InvalidStateTransitionError.new("storage_classes_desired shouldn't be empty")
544     end
545   end
546
547   def ensure_storage_classes_contain_non_empty_strings
548     (self.storage_classes_desired + self.storage_classes_confirmed).each do |c|
549       if !c.is_a?(String) || c == ''
550         raise ArvadosModel::InvalidStateTransitionError.new("storage classes should only be non-empty strings")
551       end
552     end
553   end
554 end