Merge branch '13994-proxy-remote'
[arvados.git] / services / api / app / models / collection.rb
index 91d1d1e108fa3962f355fa0df28134c1f479176e..ddb85862a26c6af6751680a8358147ce4fe841f6 100644 (file)
@@ -1,35 +1,56 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
 require 'arvados/keep'
+require 'sweep_trashed_objects'
+require 'trashable'
 
 class Collection < ArvadosModel
+  extend CurrentApiClient
   extend DbCurrentTime
   include HasUuid
   include KindAndEtag
   include CommonApiTemplate
+  include Trashable
 
   serialize :properties, Hash
+  serialize :storage_classes_desired, Array
+  serialize :storage_classes_confirmed, Array
 
+  before_validation :default_empty_manifest
+  before_validation :default_storage_classes, on: :create
   before_validation :check_encoding
+  before_validation :check_manifest_validity
   before_validation :check_signatures
   before_validation :strip_signatures_and_update_replication_confirmed
-  before_validation :set_portable_data_hash
   validate :ensure_pdh_matches_manifest_text
+  validate :ensure_storage_classes_desired_is_not_empty
+  validate :ensure_storage_classes_contain_non_empty_strings
   before_save :set_file_names
 
-  # Query only undeleted collections by default.
-  default_scope where("expires_at IS NULL or expires_at > CURRENT_TIMESTAMP")
-
   api_accessible :user, extend: :common do |t|
     t.add :name
     t.add :description
     t.add :properties
     t.add :portable_data_hash
     t.add :signed_manifest_text, as: :manifest_text
+    t.add :manifest_text, as: :unsigned_manifest_text
     t.add :replication_desired
     t.add :replication_confirmed
     t.add :replication_confirmed_at
+    t.add :storage_classes_desired
+    t.add :storage_classes_confirmed
+    t.add :storage_classes_confirmed_at
+    t.add :delete_at
+    t.add :trash_at
+    t.add :is_trashed
   end
 
-  LOCATOR_REGEXP = /^([[:xdigit:]]{32})(\+([[:digit:]]+))?(\+([[:upper:]][[:alnum:]+@_-]*))?$/
+  after_initialize do
+    @signatures_checked = false
+    @computed_pdh_for_manifest_text = false
+  end
 
   def self.attributes_required_columns
     super.merge(
@@ -38,10 +59,22 @@ class Collection < ArvadosModel
                 # expose signed_manifest_text as manifest_text in the
                 # API response, and never let clients select the
                 # manifest_text column.
-                'manifest_text' => ['manifest_text'],
+                #
+                # We need trash_at and is_trashed to determine the
+                # correct timestamp in signed_manifest_text.
+                'manifest_text' => ['manifest_text', 'trash_at', 'is_trashed'],
+                'unsigned_manifest_text' => ['manifest_text'],
                 )
   end
 
+  def self.ignored_select_attributes
+    super + ["updated_at", "file_names"]
+  end
+
+  def self.limit_index_columns_read
+    ["manifest_text"]
+  end
+
   FILE_TOKEN = /^[[:digit:]]+:[[:digit:]]+:/
   def check_signatures
     return false if self.manifest_text.nil?
@@ -53,20 +86,24 @@ class Collection < ArvadosModel
     # subsequent passes without checking any signatures. This is
     # important because the signatures have probably been stripped off
     # by the time we get to a second validation pass!
-    return true if @signatures_checked and @signatures_checked == computed_pdh
+    if @signatures_checked && @signatures_checked == computed_pdh
+      return true
+    end
 
     if self.manifest_text_changed?
       # Check permissions on the collection manifest.
       # If any signature cannot be verified, raise PermissionDeniedError
       # which will return 403 Permission denied to the client.
-      api_token = current_api_client_authorization.andand.api_token
+      api_token = Thread.current[:token]
       signing_opts = {
         api_token: api_token,
-        now: db_current_time.to_i,
+        now: @validation_timestamp.to_i,
       }
-      self.manifest_text.lines.each do |entry|
-        entry.split[1..-1].each do |tok|
-          if tok =~ FILE_TOKEN
+      self.manifest_text.each_line do |entry|
+        entry.split.each do |tok|
+          if tok == '.' or tok.starts_with? './'
+            # Stream name token.
+          elsif tok =~ FILE_TOKEN
             # This is a filename token, not a blob locator. Note that we
             # keep checking tokens after this, even though manifest
             # format dictates that all subsequent tokens will also be
@@ -95,66 +132,53 @@ class Collection < ArvadosModel
   def strip_signatures_and_update_replication_confirmed
     if self.manifest_text_changed?
       in_old_manifest = {}
-      self.class.munge_manifest_locators!(manifest_text_was) do |match|
-        in_old_manifest[match[1]] = true
+      if not self.replication_confirmed.nil?
+        self.class.each_manifest_locator(manifest_text_was) do |match|
+          in_old_manifest[match[1]] = true
+        end
       end
 
-      cleared_replication_confirmed = false
-
-      # Remove any permission signatures from the manifest.
-      self[:manifest_text] = self.class.munge_manifest_locators!(self[:manifest_text]) do |match|
-        if not cleared_replication_confirmed  and not in_old_manifest[match[1]]
+      stripped_manifest = self.class.munge_manifest_locators(manifest_text) do |match|
+        if not self.replication_confirmed.nil? and not in_old_manifest[match[1]]
+          # If the new manifest_text contains locators whose hashes
+          # weren't in the old manifest_text, storage replication is no
+          # longer confirmed.
           self.replication_confirmed_at = nil
           self.replication_confirmed = nil
-          cleared_replication_confirmed = true
         end
-        self.class.locator_without_signature(match)
-      end
-    end
-    @computed_pdh_for_manifest_text = self[:manifest_text] if @computed_pdh_for_manifest_text
-    true
-  end
 
-  def self.locator_without_signature match
-    without_signature = match[1]
-    without_signature += match[2] if match[2]
-    if match[4]
-      hints = match[4].split('+').reject { |hint| hint.start_with?("A") }
-      without_signature += hints.join('+')
-    end
-    without_signature
-  end
+        # Return the locator with all permission signatures removed,
+        # but otherwise intact.
+        match[0].gsub(/\+A[^+]*/, '')
+      end
 
-  def set_portable_data_hash
-    if (portable_data_hash.nil? or
-        portable_data_hash == "" or
-        (manifest_text_changed? and !portable_data_hash_changed?))
-      self.portable_data_hash = computed_pdh
-    elsif portable_data_hash_changed?
-      begin
-        loc = Keep::Locator.parse!(self.portable_data_hash)
-        loc.strip_hints!
-        if loc.size
-          self.portable_data_hash = loc.to_s
-        else
-          self.portable_data_hash = "#{loc.hash}+#{portable_manifest_text.bytesize}"
-        end
-      rescue ArgumentError => e
-        errors.add(:portable_data_hash, "#{e}")
-        return false
+      if @computed_pdh_for_manifest_text == manifest_text
+        # If the cached PDH was valid before stripping, it is still
+        # valid after stripping.
+        @computed_pdh_for_manifest_text = stripped_manifest.dup
       end
+
+      self[:manifest_text] = stripped_manifest
     end
     true
   end
 
   def ensure_pdh_matches_manifest_text
-    return true unless manifest_text_changed? or portable_data_hash_changed?
-    # No need verify it if :set_portable_data_hash just computed it!
-    expect_pdh = computed_pdh
-    if expect_pdh != portable_data_hash
+    if not manifest_text_changed? and not portable_data_hash_changed?
+      true
+    elsif portable_data_hash.nil? or not portable_data_hash_changed?
+      self.portable_data_hash = computed_pdh
+    elsif portable_data_hash !~ Keep::Locator::LOCATOR_REGEXP
+      errors.add(:portable_data_hash, "is not a valid locator")
+      false
+    elsif portable_data_hash[0..31] != computed_pdh[0..31]
       errors.add(:portable_data_hash,
-                 "does not match computed hash #{expect_pdh}")
-      return false
+                 "'#{portable_data_hash}' does not match computed hash '#{computed_pdh}'")
+      false
+    else
+      # Ignore the client-provided size part: always store
+      # computed_pdh in the database.
+      self.portable_data_hash = computed_pdh
     end
   end
 
@@ -166,22 +190,20 @@ class Collection < ArvadosModel
   end
 
   def manifest_files
+    return '' if !self.manifest_text
+
     names = ''
-    if self.manifest_text
-      self.manifest_text.scan(/ \d+:\d+:(\S+)/) do |name|
-        names << name.first.gsub('\040',' ') + "\n"
-        break if names.length > 2**12
-      end
+    self.manifest_text.scan(/ \d+:\d+:(\S+)/) do |name|
+      names << name.first.gsub('\040',' ') + "\n"
     end
-
-    if self.manifest_text and names.length < 2**12
-      self.manifest_text.scan(/^\.\/(\S+)/m) do |stream_name|
-        names << stream_name.first.gsub('\040',' ') + "\n"
-        break if names.length > 2**12
-      end
+    self.manifest_text.scan(/^\.\/(\S+)/m) do |stream_name|
+      names << stream_name.first.gsub('\040',' ') + "\n"
     end
+    names
+  end
 
-    names[0,2**12]
+  def default_empty_manifest
+    self.manifest_text ||= ''
   end
 
   def check_encoding
@@ -196,7 +218,7 @@ class Collection < ArvadosModel
         utf8 = manifest_text
         utf8.force_encoding Encoding::UTF_8
         if utf8.valid_encoding? and utf8 == manifest_text.encode(Encoding::UTF_8)
-          manifest_text = utf8
+          self.manifest_text = utf8
           return true
         end
       rescue
@@ -206,60 +228,90 @@ class Collection < ArvadosModel
     end
   end
 
+  def check_manifest_validity
+    begin
+      Keep::Manifest.validate! manifest_text
+      true
+    rescue ArgumentError => e
+      errors.add :manifest_text, e.message
+      false
+    end
+  end
+
   def signed_manifest_text
-    if has_attribute? :manifest_text
-      token = current_api_client_authorization.andand.api_token
-      @signed_manifest_text = self.class.sign_manifest manifest_text, token
+    if !has_attribute? :manifest_text
+      return nil
+    elsif is_trashed
+      return manifest_text
+    else
+      token = Thread.current[:token]
+      exp = [db_current_time.to_i + Rails.configuration.blob_signature_ttl,
+             trash_at].compact.map(&:to_i).min
+      self.class.sign_manifest manifest_text, token, exp
     end
   end
 
-  def self.sign_manifest manifest, token
+  def self.sign_manifest manifest, token, exp=nil
+    if exp.nil?
+      exp = db_current_time.to_i + Rails.configuration.blob_signature_ttl
+    end
     signing_opts = {
       api_token: token,
-      expire: db_current_time.to_i + Rails.configuration.blob_signature_ttl,
+      expire: exp,
     }
-    m = manifest.dup
-    m = munge_manifest_locators!(m) do |match|
-      Blob.sign_locator(locator_without_signature(match), signing_opts)
+    m = munge_manifest_locators(manifest) do |match|
+      Blob.sign_locator(match[0], signing_opts)
     end
     return m
   end
 
-  def self.munge_manifest_locators! manifest
-    # Given a manifest text and a block, yield each locator,
-    # and replace it with whatever the block returns.
+  def self.munge_manifest_locators manifest
+    # Given a manifest text and a block, yield the regexp MatchData
+    # for each locator. Return a new manifest in which each locator
+    # has been replaced by the block's return value.
+    return nil if !manifest
+    return '' if manifest == ''
+
     new_lines = []
-    lines = manifest.andand.split("\n")
-    lines.andand.each do |line|
-      words = line.split(' ')
+    manifest.each_line do |line|
+      line.rstrip!
       new_words = []
-      words.each do |word|
-        if match = LOCATOR_REGEXP.match(word.strip)
+      line.split(' ').each do |word|
+        if new_words.empty?
+          new_words << word
+        elsif match = Keep::Locator::LOCATOR_REGEXP.match(word)
           new_words << yield(match)
         else
-          new_words << word.strip
+          new_words << word
         end
       end
       new_lines << new_words.join(' ')
     end
+    new_lines.join("\n") + "\n"
+  end
 
-    if !new_lines.empty?
-      ends_with_newline = manifest.end_with?("\n")
-      manifest = new_lines.join("\n")
-      manifest += "\n" if ends_with_newline
+  def self.each_manifest_locator manifest
+    # Given a manifest text and a block, yield the regexp match object
+    # for each locator.
+    manifest.each_line do |line|
+      # line will have a trailing newline, but the last token is never
+      # a locator, so it's harmless here.
+      line.split(' ').each do |word|
+        if match = Keep::Locator::LOCATOR_REGEXP.match(word)
+          yield(match)
+        end
+      end
     end
-
-    manifest
   end
 
   def self.normalize_uuid uuid
     hash_part = nil
     size_part = nil
     uuid.split('+').each do |token|
-      if token.match /^[0-9a-f]{32,}$/
+      if token.match(/^[0-9a-f]{32,}$/)
         raise "uuid #{uuid} has multiple hash parts" if hash_part
         hash_part = token
-      elsif token.match /^\d+$/
+      elsif token.match(/^\d+$/)
         raise "uuid #{uuid} has multiple size parts" if size_part
         size_part = token
       end
@@ -268,8 +320,53 @@ class Collection < ArvadosModel
     [hash_part, size_part].compact.join '+'
   end
 
-  # Return array of Collection objects
-  def self.find_all_for_docker_image(search_term, search_tag=nil, readers=nil)
+  def self.get_compatible_images(readers, pattern, collections)
+    if collections.empty?
+      return []
+    end
+
+    migrations = Hash[
+      Link.where('tail_uuid in (?) AND link_class=? AND links.owner_uuid=?',
+                 collections.map(&:portable_data_hash),
+                 'docker_image_migration',
+                 system_user_uuid).
+      order('links.created_at asc').
+      map { |l|
+        [l.tail_uuid, l.head_uuid]
+      }]
+
+    migrated_collections = Hash[
+      Collection.readable_by(*readers).
+      where('portable_data_hash in (?)', migrations.values).
+      map { |c|
+        [c.portable_data_hash, c]
+      }]
+
+    collections.map { |c|
+      # Check if the listed image is compatible first, if not, then try the
+      # migration link.
+      manifest = Keep::Manifest.new(c.manifest_text)
+      if manifest.exact_file_count?(1) and manifest.files[0][1] =~ pattern
+        c
+      elsif m = migrated_collections[migrations[c.portable_data_hash]]
+        manifest = Keep::Manifest.new(m.manifest_text)
+        if manifest.exact_file_count?(1) and manifest.files[0][1] =~ pattern
+          m
+        end
+      end
+    }.compact
+  end
+
+  # Resolve a Docker repo+tag, hash, or collection PDH to an array of
+  # Collection objects, sorted by timestamp starting with the most recent
+  # match.
+  #
+  # If filter_compatible_format is true (the default), only return image
+  # collections which are support by the installation as indicated by
+  # Rails.configuration.docker_image_formats.  Will follow
+  # 'docker_image_migration' links if search_term resolves to an incompatible
+  # image, but an equivalent compatible image is available.
+  def self.find_all_for_docker_image(search_term, search_tag=nil, readers=nil, filter_compatible_format: true)
     readers ||= [Thread.current[:user]]
     base_search = Link.
       readable_by(*readers).
@@ -277,20 +374,23 @@ class Collection < ArvadosModel
       joins("JOIN collections ON links.head_uuid = collections.uuid").
       order("links.created_at DESC")
 
+    if (Rails.configuration.docker_image_formats.include? 'v1' and
+        Rails.configuration.docker_image_formats.include? 'v2') or filter_compatible_format == false
+      pattern = /^(sha256:)?[0-9A-Fa-f]{64}\.tar$/
+    elsif Rails.configuration.docker_image_formats.include? 'v2'
+      pattern = /^(sha256:)[0-9A-Fa-f]{64}\.tar$/
+    elsif Rails.configuration.docker_image_formats.include? 'v1'
+      pattern = /^[0-9A-Fa-f]{64}\.tar$/
+    else
+      raise "Unrecognized configuration for docker_image_formats #{Rails.configuration.docker_image_formats}"
+    end
+
     # If the search term is a Collection locator that contains one file
     # that looks like a Docker image, return it.
     if loc = Keep::Locator.parse(search_term)
       loc.strip_hints!
-      coll_match = readable_by(*readers).where(portable_data_hash: loc.to_s).limit(1).first
-      if coll_match
-        # Check if the Collection contains exactly one file whose name
-        # looks like a saved Docker image.
-        manifest = Keep::Manifest.new(coll_match.manifest_text)
-        if manifest.exact_file_count?(1) and
-            (manifest.files[0][1] =~ /^[0-9A-Fa-f]{64}\.tar$/)
-          return [coll_match]
-        end
-      end
+      coll_match = readable_by(*readers).where(portable_data_hash: loc.to_s).limit(1)
+      return get_compatible_images(readers, pattern, coll_match)
     end
 
     if search_tag.nil? and (n = search_term.index(":"))
@@ -314,11 +414,19 @@ class Collection < ArvadosModel
     # so that anything with an image timestamp is considered more recent than
     # anything without; then we use the link's created_at as a tiebreaker.
     uuid_timestamps = {}
-    matches.all.map do |link|
+    matches.each do |link|
       uuid_timestamps[link.head_uuid] = [(-link.properties["image_timestamp"].to_datetime.to_i rescue 0),
        -link.created_at.to_i]
+     end
+
+    sorted = Collection.where('uuid in (?)', uuid_timestamps.keys).sort_by { |c|
+      uuid_timestamps[c.uuid]
+    }
+    compatible = get_compatible_images(readers, pattern, sorted)
+    if sorted.length > 0 and compatible.empty?
+      raise ArvadosModel::UnresolvableContainerError.new "Matching Docker image is incompatible with 'docker_image_formats' configuration."
     end
-    Collection.where('uuid in (?)', uuid_timestamps.keys).sort_by { |c| uuid_timestamps[c.uuid] }
+    compatible
   end
 
   def self.for_latest_docker_image(search_term, search_tag=nil, readers=nil)
@@ -330,20 +438,34 @@ class Collection < ArvadosModel
   end
 
   def self.full_text_searchable_columns
-    super - ["manifest_text"]
+    super - ["manifest_text", "storage_classes_desired", "storage_classes_confirmed"]
+  end
+
+  def self.where *args
+    SweepTrashedObjects.sweep_if_stale
+    super
   end
 
   protected
+
+  # Although the defaults for these columns is already set up on the schema,
+  # collection creation from an API client seems to ignore them, making the
+  # validation on empty desired storage classes return an error.
+  def default_storage_classes
+    if self.storage_classes_desired.nil? || self.storage_classes_desired.empty?
+      self.storage_classes_desired = ["default"]
+    end
+    self.storage_classes_confirmed ||= []
+  end
+
   def portable_manifest_text
-    portable_manifest = self[:manifest_text].dup
-    portable_manifest = self.class.munge_manifest_locators!(portable_manifest) do |match|
+    self.class.munge_manifest_locators(manifest_text) do |match|
       if match[2] # size
         match[1] + match[2]
       else
         match[1]
       end
     end
-    portable_manifest
   end
 
   def compute_pdh
@@ -363,11 +485,30 @@ class Collection < ArvadosModel
   end
 
   def ensure_permission_to_save
-    if (not current_user.andand.is_admin and
-        (replication_confirmed_at_changed? or replication_confirmed_changed?) and
-        not (replication_confirmed_at.nil? and replication_confirmed.nil?))
-      raise ArvadosModel::PermissionDeniedError.new("replication_confirmed and replication_confirmed_at attributes cannot be changed, except by setting both to nil")
+    if (not current_user.andand.is_admin)
+      if (replication_confirmed_at_changed? or replication_confirmed_changed?) and
+        not (replication_confirmed_at.nil? and replication_confirmed.nil?)
+        raise ArvadosModel::PermissionDeniedError.new("replication_confirmed and replication_confirmed_at attributes cannot be changed, except by setting both to nil")
+      end
+      if (storage_classes_confirmed_changed? or storage_classes_confirmed_at_changed?) and
+        not (storage_classes_confirmed == [] and storage_classes_confirmed_at.nil?)
+        raise ArvadosModel::PermissionDeniedError.new("storage_classes_confirmed and storage_classes_confirmed_at attributes cannot be changed, except by setting them to [] and nil respectively")
+      end
     end
     super
   end
+
+  def ensure_storage_classes_desired_is_not_empty
+    if self.storage_classes_desired.empty?
+      raise ArvadosModel::InvalidStateTransitionError.new("storage_classes_desired shouldn't be empty")
+    end
+  end
+
+  def ensure_storage_classes_contain_non_empty_strings
+    (self.storage_classes_desired + self.storage_classes_confirmed).each do |c|
+      if !c.is_a?(String) || c == ''
+        raise ArvadosModel::InvalidStateTransitionError.new("storage classes should only be non-empty strings")
+      end
+    end
+  end
 end