require 'arvados/keep'
class Collection < ArvadosModel
+ extend DbCurrentTime
include HasUuid
include KindAndEtag
include CommonApiTemplate
+ serialize :properties, Hash
+
+ before_validation :default_empty_manifest
before_validation :check_encoding
+ before_validation :check_manifest_validity
before_validation :check_signatures
- before_validation :strip_manifest_text
- before_validation :set_portable_data_hash
- validate :ensure_hash_matches_manifest_text
+ before_validation :strip_signatures_and_update_replication_confirmed
+ validate :ensure_pdh_matches_manifest_text
+ before_save :set_file_names
# Query only undeleted collections by default.
default_scope where("expires_at IS NULL or expires_at > CURRENT_TIMESTAMP")
t.add :properties
t.add :portable_data_hash
t.add :signed_manifest_text, as: :manifest_text
+ t.add :replication_desired
+ t.add :replication_confirmed
+ t.add :replication_confirmed_at
end
def self.attributes_required_columns
- # If we don't list this explicitly, the params[:select] code gets
- # confused by the way we expose signed_manifest_text as
- # manifest_text in the API response, and never let clients select
- # the manifest_text column.
- super.merge('manifest_text' => ['manifest_text'])
+ super.merge(
+ # If we don't list manifest_text explicitly, the
+ # params[:select] code gets confused by the way we
+ # expose signed_manifest_text as manifest_text in the
+ # API response, and never let clients select the
+ # manifest_text column.
+ 'manifest_text' => ['manifest_text'],
+ )
end
+ FILE_TOKEN = /^[[:digit:]]+:[[:digit:]]+:/
def check_signatures
return false if self.manifest_text.nil?
# 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 == compute_pdh
+ return true if @signatures_checked and @signatures_checked == computed_pdh
if self.manifest_text_changed?
# Check permissions on the collection manifest.
# which will return 403 Permission denied to the client.
api_token = current_api_client_authorization.andand.api_token
signing_opts = {
- key: Rails.configuration.blob_signing_key,
api_token: api_token,
- ttl: Rails.configuration.blob_signing_ttl,
+ now: db_current_time.to_i,
}
- self.manifest_text.lines.each do |entry|
- entry.split[1..-1].each do |tok|
- if /^[[:digit:]]+:[[:digit:]]+:/.match tok
+ 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
end
end
end
- @signatures_checked = compute_pdh
+ @signatures_checked = computed_pdh
end
- def strip_manifest_text
+ def strip_signatures_and_update_replication_confirmed
if self.manifest_text_changed?
- # Remove any permission signatures from the manifest.
- self.class.munge_manifest_locators!(self[:manifest_text]) do |loc|
- loc.without_signature.to_s
+ in_old_manifest = {}
+ if not self.replication_confirmed.nil?
+ self.class.each_manifest_locator(manifest_text_was) do |match|
+ in_old_manifest[match[1]] = true
+ end
end
- end
- true
- end
- def set_portable_data_hash
- if (portable_data_hash.nil? or
- portable_data_hash == "" or
- (manifest_text_changed? and !portable_data_hash_changed?))
- @need_pdh_validation = false
- self.portable_data_hash = compute_pdh
- elsif portable_data_hash_changed?
- @need_pdh_validation = true
- 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}"
+ 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
end
- rescue ArgumentError => e
- errors.add(:portable_data_hash, "#{e}")
- return false
+
+ # Return the locator with all permission signatures removed,
+ # but otherwise intact.
+ match[0].gsub(/\+A[^+]*/, '')
+ end
+
+ 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_hash_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!
- return true if not @need_pdh_validation
- expect_pdh = compute_pdh
- if expect_pdh != portable_data_hash
+ def ensure_pdh_matches_manifest_text
+ 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
+ "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
+
+ def set_file_names
+ if self.manifest_text_changed?
+ self.file_names = manifest_files
+ end
+ true
+ end
+
+ def manifest_files
+ 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
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
+ end
+
+ names[0,2**12]
+ end
+
+ def default_empty_manifest
+ self.manifest_text ||= ''
end
def check_encoding
end
end
- def redundancy_status
- if redundancy_confirmed_as.nil?
- 'unconfirmed'
- elsif redundancy_confirmed_as < redundancy
- 'degraded'
- else
- if redundancy_confirmed_at.nil?
- 'unconfirmed'
- elsif Time.now - redundancy_confirmed_at < 7.days
- 'OK'
- else
- 'stale'
- 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 self.sign_manifest manifest, token
signing_opts = {
- key: Rails.configuration.blob_signing_key,
api_token: token,
- ttl: Rails.configuration.blob_signing_ttl,
+ expire: db_current_time.to_i + Rails.configuration.blob_signature_ttl,
}
- m = manifest.dup
- munge_manifest_locators!(m) do |loc|
- Blob.sign_locator(loc.to_s, 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.
- manifest.andand.gsub!(/ [[:xdigit:]]{32}(\+[[:digit:]]+)?(\+\S+)/) do |word|
- if loc = Keep::Locator.parse(word.strip)
- " " + yield(loc)
- else
- " " + word
+ 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 = []
+ manifest.each_line do |line|
+ line.rstrip!
+ new_words = []
+ 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
+ end
+ end
+ new_lines << new_words.join(' ')
+ end
+ new_lines.join("\n") + "\n"
+ end
+
+ 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
end
find_all_for_docker_image(search_term, search_tag, readers).first
end
+ def self.searchable_columns operator
+ super - ["manifest_text"]
+ end
+
+ def self.full_text_searchable_columns
+ super - ["manifest_text"]
+ end
+
protected
def portable_manifest_text
- portable_manifest = self[:manifest_text].dup
- self.class.munge_manifest_locators!(portable_manifest) do |loc|
- loc.hash + '+' + loc.size.to_s
+ 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
'+' +
portable_manifest.bytesize.to_s)
end
+
+ def computed_pdh
+ if @computed_pdh_for_manifest_text == manifest_text
+ return @computed_pdh
+ end
+ @computed_pdh = compute_pdh
+ @computed_pdh_for_manifest_text = manifest_text.dup
+ @computed_pdh
+ 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")
+ end
+ super
+ end
end