13561: Sync selected fields changes with older versions, with tests.
[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!
223     if !Rails.configuration.collection_versioning || new_record? || (!self.changes.include?('uuid') && current_version_uuid != uuid)
224       return super
225     end
226     changes = self.changes
227     # Updates that will be synced with older versions
228     synced_updates = ['uuid', 'owner_uuid', 'delete_at', 'trash_at', 'is_trashed',
229                       'replication_desired', 'storage_classes_desired'] & changes.keys
230     # Updates that will produce a new version
231     versionable_updates = ['manifest_text', 'description', 'properties', 'name'] & changes.keys
232
233     if versionable_updates.empty? && synced_updates.empty?
234       # Updates don't include interesting attributes, so don't save a new snapshot nor
235       # sync older versions.
236       return super
237     end
238
239     # Does row locking (transaction is implicit) because 'version'
240     # may be incremented and the older versions synced.
241     # Note that 'with_lock' reloads the object after locking.
242     with_lock do
243       # Sync older versions.
244       if !synced_updates.empty?
245         updates = {}
246         synced_updates.each do |attr|
247           if attr == 'uuid'
248             # Point old versions to current version's new UUID
249             updates['current_version_uuid'] = changes[attr].last
250           else
251             updates[attr] = changes[attr].last
252           end
253         end
254         Collection.where('current_version_uuid = ? AND uuid != ?', uuid, uuid).each do |c|
255           c.update_attributes!(updates)
256         end
257         # Also update current object just in case a new version will be created,
258         # as it has to receive the same values for the synced attributes.
259         self.attributes = updates
260       end
261       snapshot = nil
262       # Make a new version if applicable.
263       if !versionable_updates.empty?
264         # Create a snapshot of the original collection
265         snapshot = self.dup
266         snapshot.uuid = nil # Reset UUID so it's created as a new record
267         # Update current version number
268         self.version += 1
269       end
270       # Restore requested changes on the current version
271       changes.keys.each do |attr|
272         next if attr == 'version'
273         self.attributes = {attr => changes[attr].last}
274       end
275       # Save current version first to avoid index collision
276       super
277       # Save the snapshot with previous state (if applicable)
278       snapshot.andand.save!
279       return true
280     end
281   end
282
283   def check_encoding
284     if manifest_text.encoding.name == 'UTF-8' and manifest_text.valid_encoding?
285       true
286     else
287       begin
288         # If Ruby thinks the encoding is something else, like 7-bit
289         # ASCII, but its stored bytes are equal to the (valid) UTF-8
290         # encoding of the same string, we declare it to be a UTF-8
291         # string.
292         utf8 = manifest_text
293         utf8.force_encoding Encoding::UTF_8
294         if utf8.valid_encoding? and utf8 == manifest_text.encode(Encoding::UTF_8)
295           self.manifest_text = utf8
296           return true
297         end
298       rescue
299       end
300       errors.add :manifest_text, "must use UTF-8 encoding"
301       false
302     end
303   end
304
305   def check_manifest_validity
306     begin
307       Keep::Manifest.validate! manifest_text
308       true
309     rescue ArgumentError => e
310       errors.add :manifest_text, e.message
311       false
312     end
313   end
314
315   def signed_manifest_text
316     if !has_attribute? :manifest_text
317       return nil
318     elsif is_trashed
319       return manifest_text
320     else
321       token = current_api_client_authorization.andand.api_token
322       exp = [db_current_time.to_i + Rails.configuration.blob_signature_ttl,
323              trash_at].compact.map(&:to_i).min
324       self.class.sign_manifest manifest_text, token, exp
325     end
326   end
327
328   def self.sign_manifest manifest, token, exp=nil
329     if exp.nil?
330       exp = db_current_time.to_i + Rails.configuration.blob_signature_ttl
331     end
332     signing_opts = {
333       api_token: token,
334       expire: exp,
335     }
336     m = munge_manifest_locators(manifest) do |match|
337       Blob.sign_locator(match[0], signing_opts)
338     end
339     return m
340   end
341
342   def self.munge_manifest_locators manifest
343     # Given a manifest text and a block, yield the regexp MatchData
344     # for each locator. Return a new manifest in which each locator
345     # has been replaced by the block's return value.
346     return nil if !manifest
347     return '' if manifest == ''
348
349     new_lines = []
350     manifest.each_line do |line|
351       line.rstrip!
352       new_words = []
353       line.split(' ').each do |word|
354         if new_words.empty?
355           new_words << word
356         elsif match = Keep::Locator::LOCATOR_REGEXP.match(word)
357           new_words << yield(match)
358         else
359           new_words << word
360         end
361       end
362       new_lines << new_words.join(' ')
363     end
364     new_lines.join("\n") + "\n"
365   end
366
367   def self.each_manifest_locator manifest
368     # Given a manifest text and a block, yield the regexp match object
369     # for each locator.
370     manifest.each_line do |line|
371       # line will have a trailing newline, but the last token is never
372       # a locator, so it's harmless here.
373       line.split(' ').each do |word|
374         if match = Keep::Locator::LOCATOR_REGEXP.match(word)
375           yield(match)
376         end
377       end
378     end
379   end
380
381   def self.normalize_uuid uuid
382     hash_part = nil
383     size_part = nil
384     uuid.split('+').each do |token|
385       if token.match(/^[0-9a-f]{32,}$/)
386         raise "uuid #{uuid} has multiple hash parts" if hash_part
387         hash_part = token
388       elsif token.match(/^\d+$/)
389         raise "uuid #{uuid} has multiple size parts" if size_part
390         size_part = token
391       end
392     end
393     raise "uuid #{uuid} has no hash part" if !hash_part
394     [hash_part, size_part].compact.join '+'
395   end
396
397   def self.get_compatible_images(readers, pattern, collections)
398     if collections.empty?
399       return []
400     end
401
402     migrations = Hash[
403       Link.where('tail_uuid in (?) AND link_class=? AND links.owner_uuid=?',
404                  collections.map(&:portable_data_hash),
405                  'docker_image_migration',
406                  system_user_uuid).
407       order('links.created_at asc').
408       map { |l|
409         [l.tail_uuid, l.head_uuid]
410       }]
411
412     migrated_collections = Hash[
413       Collection.readable_by(*readers).
414       where('portable_data_hash in (?)', migrations.values).
415       map { |c|
416         [c.portable_data_hash, c]
417       }]
418
419     collections.map { |c|
420       # Check if the listed image is compatible first, if not, then try the
421       # migration link.
422       manifest = Keep::Manifest.new(c.manifest_text)
423       if manifest.exact_file_count?(1) and manifest.files[0][1] =~ pattern
424         c
425       elsif m = migrated_collections[migrations[c.portable_data_hash]]
426         manifest = Keep::Manifest.new(m.manifest_text)
427         if manifest.exact_file_count?(1) and manifest.files[0][1] =~ pattern
428           m
429         end
430       end
431     }.compact
432   end
433
434   # Resolve a Docker repo+tag, hash, or collection PDH to an array of
435   # Collection objects, sorted by timestamp starting with the most recent
436   # match.
437   #
438   # If filter_compatible_format is true (the default), only return image
439   # collections which are support by the installation as indicated by
440   # Rails.configuration.docker_image_formats.  Will follow
441   # 'docker_image_migration' links if search_term resolves to an incompatible
442   # image, but an equivalent compatible image is available.
443   def self.find_all_for_docker_image(search_term, search_tag=nil, readers=nil, filter_compatible_format: true)
444     readers ||= [Thread.current[:user]]
445     base_search = Link.
446       readable_by(*readers).
447       readable_by(*readers, table_name: "collections").
448       joins("JOIN collections ON links.head_uuid = collections.uuid").
449       order("links.created_at DESC")
450
451     if (Rails.configuration.docker_image_formats.include? 'v1' and
452         Rails.configuration.docker_image_formats.include? 'v2') or filter_compatible_format == false
453       pattern = /^(sha256:)?[0-9A-Fa-f]{64}\.tar$/
454     elsif Rails.configuration.docker_image_formats.include? 'v2'
455       pattern = /^(sha256:)[0-9A-Fa-f]{64}\.tar$/
456     elsif Rails.configuration.docker_image_formats.include? 'v1'
457       pattern = /^[0-9A-Fa-f]{64}\.tar$/
458     else
459       raise "Unrecognized configuration for docker_image_formats #{Rails.configuration.docker_image_formats}"
460     end
461
462     # If the search term is a Collection locator that contains one file
463     # that looks like a Docker image, return it.
464     if loc = Keep::Locator.parse(search_term)
465       loc.strip_hints!
466       coll_match = readable_by(*readers).where(portable_data_hash: loc.to_s).limit(1)
467       return get_compatible_images(readers, pattern, coll_match)
468     end
469
470     if search_tag.nil? and (n = search_term.index(":"))
471       search_tag = search_term[n+1..-1]
472       search_term = search_term[0..n-1]
473     end
474
475     # Find Collections with matching Docker image repository+tag pairs.
476     matches = base_search.
477       where(link_class: "docker_image_repo+tag",
478             name: "#{search_term}:#{search_tag || 'latest'}")
479
480     # If that didn't work, find Collections with matching Docker image hashes.
481     if matches.empty?
482       matches = base_search.
483         where("link_class = ? and links.name LIKE ?",
484               "docker_image_hash", "#{search_term}%")
485     end
486
487     # Generate an order key for each result.  We want to order the results
488     # so that anything with an image timestamp is considered more recent than
489     # anything without; then we use the link's created_at as a tiebreaker.
490     uuid_timestamps = {}
491     matches.each do |link|
492       uuid_timestamps[link.head_uuid] = [(-link.properties["image_timestamp"].to_datetime.to_i rescue 0),
493        -link.created_at.to_i]
494      end
495
496     sorted = Collection.where('uuid in (?)', uuid_timestamps.keys).sort_by { |c|
497       uuid_timestamps[c.uuid]
498     }
499     compatible = get_compatible_images(readers, pattern, sorted)
500     if sorted.length > 0 and compatible.empty?
501       raise ArvadosModel::UnresolvableContainerError.new "Matching Docker image is incompatible with 'docker_image_formats' configuration."
502     end
503     compatible
504   end
505
506   def self.for_latest_docker_image(search_term, search_tag=nil, readers=nil)
507     find_all_for_docker_image(search_term, search_tag, readers).first
508   end
509
510   def self.searchable_columns operator
511     super - ["manifest_text", "current_version_uuid"]
512   end
513
514   def self.full_text_searchable_columns
515     super - ["manifest_text", "storage_classes_desired", "storage_classes_confirmed", "current_version_uuid"]
516   end
517
518   def self.where *args
519     SweepTrashedObjects.sweep_if_stale
520     super
521   end
522
523   protected
524
525   # Although the defaults for these columns is already set up on the schema,
526   # collection creation from an API client seems to ignore them, making the
527   # validation on empty desired storage classes return an error.
528   def default_storage_classes
529     if self.storage_classes_desired.nil? || self.storage_classes_desired.empty?
530       self.storage_classes_desired = ["default"]
531     end
532     self.storage_classes_confirmed ||= []
533   end
534
535   def portable_manifest_text
536     self.class.munge_manifest_locators(manifest_text) do |match|
537       if match[2] # size
538         match[1] + match[2]
539       else
540         match[1]
541       end
542     end
543   end
544
545   def compute_pdh
546     portable_manifest = portable_manifest_text
547     (Digest::MD5.hexdigest(portable_manifest) +
548      '+' +
549      portable_manifest.bytesize.to_s)
550   end
551
552   def computed_pdh
553     if @computed_pdh_for_manifest_text == manifest_text
554       return @computed_pdh
555     end
556     @computed_pdh = compute_pdh
557     @computed_pdh_for_manifest_text = manifest_text.dup
558     @computed_pdh
559   end
560
561   def ensure_permission_to_save
562     if (not current_user.andand.is_admin)
563       if (replication_confirmed_at_changed? or replication_confirmed_changed?) and
564         not (replication_confirmed_at.nil? and replication_confirmed.nil?)
565         raise ArvadosModel::PermissionDeniedError.new("replication_confirmed and replication_confirmed_at attributes cannot be changed, except by setting both to nil")
566       end
567       if (storage_classes_confirmed_changed? or storage_classes_confirmed_at_changed?) and
568         not (storage_classes_confirmed == [] and storage_classes_confirmed_at.nil?)
569         raise ArvadosModel::PermissionDeniedError.new("storage_classes_confirmed and storage_classes_confirmed_at attributes cannot be changed, except by setting them to [] and nil respectively")
570       end
571     end
572     super
573   end
574
575   def ensure_storage_classes_desired_is_not_empty
576     if self.storage_classes_desired.empty?
577       raise ArvadosModel::InvalidStateTransitionError.new("storage_classes_desired shouldn't be empty")
578     end
579   end
580
581   def ensure_storage_classes_contain_non_empty_strings
582     (self.storage_classes_desired + self.storage_classes_confirmed).each do |c|
583       if !c.is_a?(String) || c == ''
584         raise ArvadosModel::InvalidStateTransitionError.new("storage classes should only be non-empty strings")
585       end
586     end
587   end
588 end