5011: Merge branch 'master' into 5011-arv-put-replication
[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_encoding
9   before_validation :check_signatures
10   before_validation :strip_manifest_text
11   before_validation :set_portable_data_hash
12   validate :ensure_hash_matches_manifest_text
13   before_save :set_file_names
14
15   # Query only undeleted collections by default.
16   default_scope where("expires_at IS NULL or expires_at > CURRENT_TIMESTAMP")
17
18   api_accessible :user, extend: :common do |t|
19     t.add :name
20     t.add :description
21     t.add :properties
22     t.add :portable_data_hash
23     t.add :signed_manifest_text, as: :manifest_text
24     t.add :replication_desired
25   end
26
27   def self.attributes_required_columns
28     super.merge(
29                 # If we don't list manifest_text explicitly, the
30                 # params[:select] code gets confused by the way we
31                 # expose signed_manifest_text as manifest_text in the
32                 # API response, and never let clients select the
33                 # manifest_text column.
34                 'manifest_text' => ['manifest_text'],
35
36                 # This is a shim until the database column gets
37                 # renamed to replication_desired in #3410.
38                 'replication_desired' => ['redundancy'],
39                 )
40   end
41
42   def check_signatures
43     return false if self.manifest_text.nil?
44
45     return true if current_user.andand.is_admin
46
47     # Provided the manifest_text hasn't changed materially since an
48     # earlier validation, it's safe to pass this validation on
49     # subsequent passes without checking any signatures. This is
50     # important because the signatures have probably been stripped off
51     # by the time we get to a second validation pass!
52     return true if @signatures_checked and @signatures_checked == compute_pdh
53
54     if self.manifest_text_changed?
55       # Check permissions on the collection manifest.
56       # If any signature cannot be verified, raise PermissionDeniedError
57       # which will return 403 Permission denied to the client.
58       api_token = current_api_client_authorization.andand.api_token
59       signing_opts = {
60         key: Rails.configuration.blob_signing_key,
61         api_token: api_token,
62         ttl: Rails.configuration.blob_signing_ttl,
63       }
64       self.manifest_text.lines.each do |entry|
65         entry.split[1..-1].each do |tok|
66           if /^[[:digit:]]+:[[:digit:]]+:/.match tok
67             # This is a filename token, not a blob locator. Note that we
68             # keep checking tokens after this, even though manifest
69             # format dictates that all subsequent tokens will also be
70             # filenames. Safety first!
71           elsif Blob.verify_signature tok, signing_opts
72             # OK.
73           elsif Keep::Locator.parse(tok).andand.signature
74             # Signature provided, but verify_signature did not like it.
75             logger.warn "Invalid signature on locator #{tok}"
76             raise ArvadosModel::PermissionDeniedError
77           elsif Rails.configuration.permit_create_collection_with_unsigned_manifest
78             # No signature provided, but we are running in insecure mode.
79             logger.debug "Missing signature on locator #{tok} ignored"
80           elsif Blob.new(tok).empty?
81             # No signature provided -- but no data to protect, either.
82           else
83             logger.warn "Missing signature on locator #{tok}"
84             raise ArvadosModel::PermissionDeniedError
85           end
86         end
87       end
88     end
89     @signatures_checked = compute_pdh
90   end
91
92   def strip_manifest_text
93     if self.manifest_text_changed?
94       # Remove any permission signatures from the manifest.
95       self.class.munge_manifest_locators!(self[:manifest_text]) do |loc|
96         loc.without_signature.to_s
97       end
98     end
99     true
100   end
101
102   def set_portable_data_hash
103     if (portable_data_hash.nil? or
104         portable_data_hash == "" or
105         (manifest_text_changed? and !portable_data_hash_changed?))
106       @need_pdh_validation = false
107       self.portable_data_hash = compute_pdh
108     elsif portable_data_hash_changed?
109       @need_pdh_validation = true
110       begin
111         loc = Keep::Locator.parse!(self.portable_data_hash)
112         loc.strip_hints!
113         if loc.size
114           self.portable_data_hash = loc.to_s
115         else
116           self.portable_data_hash = "#{loc.hash}+#{portable_manifest_text.bytesize}"
117         end
118       rescue ArgumentError => e
119         errors.add(:portable_data_hash, "#{e}")
120         return false
121       end
122     end
123     true
124   end
125
126   def ensure_hash_matches_manifest_text
127     return true unless manifest_text_changed? or portable_data_hash_changed?
128     # No need verify it if :set_portable_data_hash just computed it!
129     return true if not @need_pdh_validation
130     expect_pdh = compute_pdh
131     if expect_pdh != portable_data_hash
132       errors.add(:portable_data_hash,
133                  "does not match computed hash #{expect_pdh}")
134       return false
135     end
136   end
137
138   def set_file_names
139     if self.manifest_text_changed?
140       self.file_names = manifest_files
141     end
142     true
143   end
144
145   def manifest_files
146     names = ''
147     if self.manifest_text
148       self.manifest_text.scan(/ \d+:\d+:(\S+)/) do |name|
149         names << name.first.gsub('\040',' ') + "\n"
150         break if names.length > 2**12
151       end
152     end
153
154     if self.manifest_text and names.length < 2**12
155       self.manifest_text.scan(/^\.\/(\S+)/m) do |stream_name|
156         names << stream_name.first.gsub('\040',' ') + "\n"
157         break if names.length > 2**12
158       end
159     end
160
161     names[0,2**12]
162   end
163
164   def check_encoding
165     if manifest_text.encoding.name == 'UTF-8' and manifest_text.valid_encoding?
166       true
167     else
168       begin
169         # If Ruby thinks the encoding is something else, like 7-bit
170         # ASCII, but its stored bytes are equal to the (valid) UTF-8
171         # encoding of the same string, we declare it to be a UTF-8
172         # string.
173         utf8 = manifest_text
174         utf8.force_encoding Encoding::UTF_8
175         if utf8.valid_encoding? and utf8 == manifest_text.encode(Encoding::UTF_8)
176           manifest_text = utf8
177           return true
178         end
179       rescue
180       end
181       errors.add :manifest_text, "must use UTF-8 encoding"
182       false
183     end
184   end
185
186   def replication_desired
187     # Shim until database columns get fixed up in #3410.
188     redundancy or 2
189   end
190
191   def redundancy_status
192     if redundancy_confirmed_as.nil?
193       'unconfirmed'
194     elsif redundancy_confirmed_as < redundancy
195       'degraded'
196     else
197       if redundancy_confirmed_at.nil?
198         'unconfirmed'
199       elsif Time.now - redundancy_confirmed_at < 7.days
200         'OK'
201       else
202         'stale'
203       end
204     end
205   end
206
207   def signed_manifest_text
208     if has_attribute? :manifest_text
209       token = current_api_client_authorization.andand.api_token
210       @signed_manifest_text = self.class.sign_manifest manifest_text, token
211     end
212   end
213
214   def self.sign_manifest manifest, token
215     signing_opts = {
216       key: Rails.configuration.blob_signing_key,
217       api_token: token,
218       ttl: Rails.configuration.blob_signing_ttl,
219     }
220     m = manifest.dup
221     munge_manifest_locators!(m) do |loc|
222       Blob.sign_locator(loc.to_s, signing_opts)
223     end
224     return m
225   end
226
227   def self.munge_manifest_locators! manifest
228     # Given a manifest text and a block, yield each locator,
229     # and replace it with whatever the block returns.
230     manifest.andand.gsub!(/ [[:xdigit:]]{32}(\+[[:digit:]]+)?(\+\S+)/) do |word|
231       if loc = Keep::Locator.parse(word.strip)
232         " " + yield(loc)
233       else
234         " " + word
235       end
236     end
237   end
238
239   def self.normalize_uuid uuid
240     hash_part = nil
241     size_part = nil
242     uuid.split('+').each do |token|
243       if token.match /^[0-9a-f]{32,}$/
244         raise "uuid #{uuid} has multiple hash parts" if hash_part
245         hash_part = token
246       elsif token.match /^\d+$/
247         raise "uuid #{uuid} has multiple size parts" if size_part
248         size_part = token
249       end
250     end
251     raise "uuid #{uuid} has no hash part" if !hash_part
252     [hash_part, size_part].compact.join '+'
253   end
254
255   # Return array of Collection objects
256   def self.find_all_for_docker_image(search_term, search_tag=nil, readers=nil)
257     readers ||= [Thread.current[:user]]
258     base_search = Link.
259       readable_by(*readers).
260       readable_by(*readers, table_name: "collections").
261       joins("JOIN collections ON links.head_uuid = collections.uuid").
262       order("links.created_at DESC")
263
264     # If the search term is a Collection locator that contains one file
265     # that looks like a Docker image, return it.
266     if loc = Keep::Locator.parse(search_term)
267       loc.strip_hints!
268       coll_match = readable_by(*readers).where(portable_data_hash: loc.to_s).limit(1).first
269       if coll_match
270         # Check if the Collection contains exactly one file whose name
271         # looks like a saved Docker image.
272         manifest = Keep::Manifest.new(coll_match.manifest_text)
273         if manifest.exact_file_count?(1) and
274             (manifest.files[0][1] =~ /^[0-9A-Fa-f]{64}\.tar$/)
275           return [coll_match]
276         end
277       end
278     end
279
280     if search_tag.nil? and (n = search_term.index(":"))
281       search_tag = search_term[n+1..-1]
282       search_term = search_term[0..n-1]
283     end
284
285     # Find Collections with matching Docker image repository+tag pairs.
286     matches = base_search.
287       where(link_class: "docker_image_repo+tag",
288             name: "#{search_term}:#{search_tag || 'latest'}")
289
290     # If that didn't work, find Collections with matching Docker image hashes.
291     if matches.empty?
292       matches = base_search.
293         where("link_class = ? and links.name LIKE ?",
294               "docker_image_hash", "#{search_term}%")
295     end
296
297     # Generate an order key for each result.  We want to order the results
298     # so that anything with an image timestamp is considered more recent than
299     # anything without; then we use the link's created_at as a tiebreaker.
300     uuid_timestamps = {}
301     matches.all.map do |link|
302       uuid_timestamps[link.head_uuid] = [(-link.properties["image_timestamp"].to_datetime.to_i rescue 0),
303        -link.created_at.to_i]
304     end
305     Collection.where('uuid in (?)', uuid_timestamps.keys).sort_by { |c| uuid_timestamps[c.uuid] }
306   end
307
308   def self.for_latest_docker_image(search_term, search_tag=nil, readers=nil)
309     find_all_for_docker_image(search_term, search_tag, readers).first
310   end
311
312   def self.searchable_columns operator
313     super - ["manifest_text"]
314   end
315
316   protected
317   def portable_manifest_text
318     portable_manifest = self[:manifest_text].dup
319     self.class.munge_manifest_locators!(portable_manifest) do |loc|
320       loc.hash + '+' + loc.size.to_s
321     end
322     portable_manifest
323   end
324
325   def compute_pdh
326     portable_manifest = portable_manifest_text
327     (Digest::MD5.hexdigest(portable_manifest) +
328      '+' +
329      portable_manifest.bytesize.to_s)
330   end
331 end