Merge branch 'master' into 3338-tab-counts
[arvados.git] / services / api / app / models / collection.rb
1 require 'arvados/keep'
2
3 class Collection < ArvadosModel
4   include HasUuid
5   include KindAndEtag
6   include CommonApiTemplate
7
8   before_validation :check_signatures
9   before_validation :strip_manifest_text
10   before_validation :set_portable_data_hash
11   validate :ensure_hash_matches_manifest_text
12
13   # Query only undeleted collections by default.
14   default_scope where("expires_at IS NULL or expires_at > CURRENT_TIMESTAMP")
15
16   api_accessible :user, extend: :common do |t|
17     t.add :name
18     t.add :description
19     t.add :properties
20     t.add :portable_data_hash
21     t.add :manifest_text
22   end
23
24   def check_signatures
25     return false if self.manifest_text.nil?
26
27     return true if current_user.andand.is_admin
28
29     if self.manifest_text_changed?
30       # Check permissions on the collection manifest.
31       # If any signature cannot be verified, raise PermissionDeniedError
32       # which will return 403 Permission denied to the client.
33       api_token = current_api_client_authorization.andand.api_token
34       signing_opts = {
35         key: Rails.configuration.blob_signing_key,
36         api_token: api_token,
37         ttl: Rails.configuration.blob_signing_ttl,
38       }
39       self.manifest_text.lines.each do |entry|
40         entry.split[1..-1].each do |tok|
41           if /^[[:digit:]]+:[[:digit:]]+:/.match tok
42             # This is a filename token, not a blob locator. Note that we
43             # keep checking tokens after this, even though manifest
44             # format dictates that all subsequent tokens will also be
45             # filenames. Safety first!
46           elsif Blob.verify_signature tok, signing_opts
47             # OK.
48           elsif Keep::Locator.parse(tok).andand.signature
49             # Signature provided, but verify_signature did not like it.
50             logger.warn "Invalid signature on locator #{tok}"
51             raise ArvadosModel::PermissionDeniedError
52           elsif Rails.configuration.permit_create_collection_with_unsigned_manifest
53             # No signature provided, but we are running in insecure mode.
54             logger.debug "Missing signature on locator #{tok} ignored"
55           elsif Blob.new(tok).empty?
56             # No signature provided -- but no data to protect, either.
57           else
58             logger.warn "Missing signature on locator #{tok}"
59             raise ArvadosModel::PermissionDeniedError
60           end
61         end
62       end
63     end
64     true
65   end
66
67   def strip_manifest_text
68     if self.manifest_text_changed?
69       # Remove any permission signatures from the manifest.
70       Collection.munge_manifest_locators(self[:manifest_text]) do |loc|
71         loc.without_signature.to_s
72       end
73     end
74     true
75   end
76
77   def set_portable_data_hash
78     if (self.portable_data_hash.nil? or (self.portable_data_hash == "") or (manifest_text_changed? and !portable_data_hash_changed?))
79       self.portable_data_hash = "#{Digest::MD5.hexdigest(manifest_text)}+#{manifest_text.length}"
80     elsif portable_data_hash_changed?
81       begin
82         loc = Keep::Locator.parse!(self.portable_data_hash)
83         loc.strip_hints!
84         if loc.size
85           self.portable_data_hash = loc.to_s
86         else
87           self.portable_data_hash = "#{loc.hash}+#{self.manifest_text.length}"
88         end
89       rescue ArgumentError => e
90         errors.add(:portable_data_hash, "#{e}")
91         return false
92       end
93     end
94     true
95   end
96
97   def ensure_hash_matches_manifest_text
98     if manifest_text_changed? or portable_data_hash_changed?
99       computed_hash = "#{Digest::MD5.hexdigest(manifest_text)}+#{manifest_text.length}"
100       unless computed_hash == portable_data_hash
101         logger.debug "(computed) '#{computed_hash}' != '#{portable_data_hash}' (provided)"
102         errors.add(:portable_data_hash, "does not match hash of manifest_text")
103         return false
104       end
105     end
106     true
107   end
108
109   def redundancy_status
110     if redundancy_confirmed_as.nil?
111       'unconfirmed'
112     elsif redundancy_confirmed_as < redundancy
113       'degraded'
114     else
115       if redundancy_confirmed_at.nil?
116         'unconfirmed'
117       elsif Time.now - redundancy_confirmed_at < 7.days
118         'OK'
119       else
120         'stale'
121       end
122     end
123   end
124
125   def self.munge_manifest_locators(manifest)
126     # Given a manifest text and a block, yield each locator,
127     # and replace it with whatever the block returns.
128     manifest.andand.gsub!(/ [[:xdigit:]]{32}(\+[[:digit:]]+)?(\+\S+)/) do |word|
129       if loc = Keep::Locator.parse(word.strip)
130         " " + yield(loc)
131       else
132         " " + word
133       end
134     end
135   end
136
137   def self.normalize_uuid uuid
138     hash_part = nil
139     size_part = nil
140     uuid.split('+').each do |token|
141       if token.match /^[0-9a-f]{32,}$/
142         raise "uuid #{uuid} has multiple hash parts" if hash_part
143         hash_part = token
144       elsif token.match /^\d+$/
145         raise "uuid #{uuid} has multiple size parts" if size_part
146         size_part = token
147       end
148     end
149     raise "uuid #{uuid} has no hash part" if !hash_part
150     [hash_part, size_part].compact.join '+'
151   end
152
153   # Return array of Collection objects
154   def self.find_all_for_docker_image(search_term, search_tag=nil, readers=nil)
155     readers ||= [Thread.current[:user]]
156     base_search = Link.
157       readable_by(*readers).
158       readable_by(*readers, table_name: "collections").
159       joins("JOIN collections ON links.head_uuid = collections.uuid").
160       order("links.created_at DESC")
161
162     # If the search term is a Collection locator that contains one file
163     # that looks like a Docker image, return it.
164     if loc = Keep::Locator.parse(search_term)
165       loc.strip_hints!
166       coll_match = readable_by(*readers).where(portable_data_hash: loc.to_s).limit(1).first
167       if coll_match
168         # Check if the Collection contains exactly one file whose name
169         # looks like a saved Docker image.
170         manifest = Keep::Manifest.new(coll_match.manifest_text)
171         if manifest.exact_file_count?(1) and
172             (manifest.files[0][1] =~ /^[0-9A-Fa-f]{64}\.tar$/)
173           return [coll_match]
174         end
175       end
176     end
177
178     if search_tag.nil? and (n = search_term.index(":"))
179       search_tag = search_term[n+1..-1]
180       search_term = search_term[0..n-1]
181     end
182
183     # Find Collections with matching Docker image repository+tag pairs.
184     matches = base_search.
185       where(link_class: "docker_image_repo+tag",
186             name: "#{search_term}:#{search_tag || 'latest'}")
187
188     # If that didn't work, find Collections with matching Docker image hashes.
189     if matches.empty?
190       matches = base_search.
191         where("link_class = ? and links.name LIKE ?",
192               "docker_image_hash", "#{search_term}%")
193     end
194
195     # Generate an order key for each result.  We want to order the results
196     # so that anything with an image timestamp is considered more recent than
197     # anything without; then we use the link's created_at as a tiebreaker.
198     uuid_timestamps = {}
199     matches.all.map do |link|
200       uuid_timestamps[link.head_uuid] = [(-link.properties["image_timestamp"].to_datetime.to_i rescue 0),
201        -link.created_at.to_i]
202     end
203     Collection.where('uuid in (?)', uuid_timestamps.keys).sort_by { |c| uuid_timestamps[c.uuid] }
204   end
205
206   def self.for_latest_docker_image(search_term, search_tag=nil, readers=nil)
207     find_all_for_docker_image(search_term, search_tag, readers).first
208   end
209 end