Merge branch 'master' into 3036-collection-uuids
[arvados.git] / services / api / app / models / collection.rb
1 class Collection < ArvadosModel
2   include HasUuid
3   include KindAndEtag
4   include CommonApiTemplate
5
6   before_validation :set_portable_data_hash
7   validate :ensure_hash_matches_manifest_text
8
9   api_accessible :user, extend: :common do |t|
10     t.add :data_size
11     t.add :files
12     t.add :name
13     t.add :description
14     t.add :properties
15     t.add :portable_data_hash
16     t.add :manifest_text
17   end
18
19   def self.attributes_required_columns
20     super.merge({ "data_size" => ["manifest_text"],
21                   "files" => ["manifest_text"],
22                 })
23   end
24
25   def set_portable_data_hash
26     if (self.portable_data_hash.nil? or (self.portable_data_hash == "") or (manifest_text_changed? and !portable_data_hash_changed?))
27       self.portable_data_hash = "#{Digest::MD5.hexdigest(manifest_text)}+#{manifest_text.length}"
28     elsif portable_data_hash_changed?
29       begin
30         loc = Locator.parse!(self.portable_data_hash)
31         loc.strip_hints!
32         self.portable_data_hash = loc.to_s
33       rescue ArgumentError => e
34         errors.add(:portable_data_hash, "#{e}")
35         return false
36       end
37     end
38     true
39   end
40
41   def ensure_hash_matches_manifest_text
42     if manifest_text_changed? or portable_data_hash_changed?
43       computed_hash = "#{Digest::MD5.hexdigest(manifest_text)}+#{manifest_text.length}"
44       unless computed_hash == portable_data_hash
45         logger.debug "(computed) '#{computed_hash}' != '#{portable_data_hash}' (provided)"
46         errors.add(:portable_data_hash, "does not match hash of manifest_text")
47         return false
48       end
49     end
50     true
51   end
52
53   def redundancy_status
54     if redundancy_confirmed_as.nil?
55       'unconfirmed'
56     elsif redundancy_confirmed_as < redundancy
57       'degraded'
58     else
59       if redundancy_confirmed_at.nil?
60         'unconfirmed'
61       elsif Time.now - redundancy_confirmed_at < 7.days
62         'OK'
63       else
64         'stale'
65       end
66     end
67   end
68
69   def data_size
70     inspect_manifest_text if @data_size.nil? or manifest_text_changed?
71     @data_size
72   end
73
74   def files
75     inspect_manifest_text if @files.nil? or manifest_text_changed?
76     @files
77   end
78
79   def inspect_manifest_text
80     if !manifest_text
81       @data_size = false
82       @files = []
83       return
84     end
85
86     @data_size = 0
87     tmp = {}
88
89     manifest_text.split("\n").each do |stream|
90       toks = stream.split(" ")
91
92       stream = toks[0].gsub /\\(\\|[0-7]{3})/ do |escape_sequence|
93         case $1
94         when '\\' '\\'
95         else $1.to_i(8).chr
96         end
97       end
98
99       toks[1..-1].each do |tok|
100         if (re = tok.match /^[0-9a-f]{32}/)
101           blocksize = nil
102           tok.split('+')[1..-1].each do |hint|
103             if !blocksize and hint.match /^\d+$/
104               blocksize = hint.to_i
105             end
106             if (re = hint.match /^GS(\d+)$/)
107               blocksize = re[1].to_i
108             end
109           end
110           @data_size = false if !blocksize
111           @data_size += blocksize if @data_size
112         else
113           if (re = tok.match /^(\d+):(\d+):(\S+)$/)
114             filename = re[3].gsub /\\(\\|[0-7]{3})/ do |escape_sequence|
115               case $1
116               when '\\' '\\'
117               else $1.to_i(8).chr
118               end
119             end
120             fn = stream + '/' + filename
121             i = re[2].to_i
122             if tmp[fn]
123               tmp[fn] += i
124             else
125               tmp[fn] = i
126             end
127           end
128         end
129       end
130     end
131
132     @files = []
133     tmp.each do |k, v|
134       re = k.match(/^(.+)\/(.+)/)
135       @files << [re[1], re[2], v]
136     end
137   end
138
139   def self.normalize_uuid uuid
140     hash_part = nil
141     size_part = nil
142     uuid.split('+').each do |token|
143       if token.match /^[0-9a-f]{32,}$/
144         raise "uuid #{uuid} has multiple hash parts" if hash_part
145         hash_part = token
146       elsif token.match /^\d+$/
147         raise "uuid #{uuid} has multiple size parts" if size_part
148         size_part = token
149       end
150     end
151     raise "uuid #{uuid} has no hash part" if !hash_part
152     [hash_part, size_part].compact.join '+'
153   end
154
155   def self.uuids_for_docker_image(search_term, search_tag=nil, readers=nil)
156     readers ||= [Thread.current[:user]]
157     base_search = Link.
158       readable_by(*readers).
159       readable_by(*readers, table_name: "collections").
160       joins("JOIN collections ON links.head_uuid = collections.uuid").
161       order("links.created_at DESC")
162
163     # If the search term is a Collection locator that contains one file
164     # that looks like a Docker image, return it.
165     if loc = Locator.parse(search_term)
166       loc.strip_hints!
167       coll_match = readable_by(*readers).where(portable_data_hash: loc.to_s).limit(1).first
168       if coll_match and (coll_match.files.size == 1) and
169           (coll_match.files[0][1] =~ /^[0-9A-Fa-f]{64}\.tar$/)
170         return [coll_match.uuid]
171       end
172     end
173
174     if search_tag.nil? and (n = search_term.index(":"))
175       search_tag = search_term[n+1..-1]
176       search_term = search_term[0..n-1]
177     end
178
179     # Find Collections with matching Docker image repository+tag pairs.
180     matches = base_search.
181       where(link_class: "docker_image_repo+tag",
182             name: "#{search_term}:#{search_tag || 'latest'}")
183
184     # If that didn't work, find Collections with matching Docker image hashes.
185     if matches.empty?
186       matches = base_search.
187         where("link_class = ? and links.name LIKE ?",
188               "docker_image_hash", "#{search_term}%")
189     end
190
191     # Generate an order key for each result.  We want to order the results
192     # so that anything with an image timestamp is considered more recent than
193     # anything without; then we use the link's created_at as a tiebreaker.
194     uuid_timestamps = {}
195     matches.find_each do |link|
196       c = Collection.find_by_uuid(link.head_uuid)
197       uuid_timestamps[c.uuid] =
198         [(-link.properties["image_timestamp"].to_datetime.to_i rescue 0),
199          -link.created_at.to_i]
200     end
201     uuid_timestamps.keys.sort_by { |uuid| uuid_timestamps[uuid] }
202   end
203
204   def self.for_latest_docker_image(search_term, search_tag=nil, readers=nil)
205     image_uuid = uuids_for_docker_image(search_term, search_tag, readers).first
206     if image_uuid.nil?
207       nil
208     else
209       find_by_uuid(image_uuid)
210     end
211   end
212 end