13138: Gather arvados API errors under "RequestError" class.
[arvados.git] / services / api / app / models / commit.rb
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: AGPL-3.0
4
5 require 'request_error'
6
7 class Commit < ActiveRecord::Base
8   extend CurrentApiClient
9
10   class GitError < RequestError
11     def http_status
12       422
13     end
14   end
15
16   def self.git_check_ref_format(e)
17     if !e or e.empty? or e[0] == '-' or e[0] == '$'
18       # definitely not valid
19       false
20     else
21       `git check-ref-format --allow-onelevel #{e.shellescape}`
22       $?.success?
23     end
24   end
25
26   # Return an array of commits (each a 40-char sha1) satisfying the
27   # given criteria.
28   #
29   # Return [] if the revisions given in minimum/maximum are invalid or
30   # don't exist in the given repository.
31   #
32   # Raise ArgumentError if the given repository is invalid, does not
33   # exist, or cannot be read for any reason. (Any transient error that
34   # prevents commit ranges from resolving must raise rather than
35   # returning an empty array.)
36   #
37   # repository can be the name of a locally hosted repository or a git
38   # URL (see git-fetch(1)). Currently http, https, and git schemes are
39   # supported.
40   def self.find_commit_range repository, minimum, maximum, exclude
41     if minimum and minimum.empty?
42       minimum = nil
43     end
44
45     if minimum and !git_check_ref_format(minimum)
46       logger.warn "find_commit_range called with invalid minimum revision: '#{minimum}'"
47       return []
48     end
49
50     if maximum and !git_check_ref_format(maximum)
51       logger.warn "find_commit_range called with invalid maximum revision: '#{maximum}'"
52       return []
53     end
54
55     if !maximum
56       maximum = "HEAD"
57     end
58
59     gitdir, is_remote = git_dir_for repository
60     fetch_remote_repository gitdir, repository if is_remote
61     ENV['GIT_DIR'] = gitdir
62
63     commits = []
64
65     # Get the commit hash for the upper bound
66     max_hash = nil
67     git_max_hash_cmd = "git rev-list --max-count=1 #{maximum.shellescape} --"
68     IO.foreach("|#{git_max_hash_cmd}") do |line|
69       max_hash = line.strip
70     end
71
72     # If not found, nothing else to do
73     if !max_hash
74       logger.warn "no refs found looking for max_hash: `GIT_DIR=#{gitdir} #{git_max_hash_cmd}` returned no output"
75       return []
76     end
77
78     # If string is invalid, nothing else to do
79     if !git_check_ref_format(max_hash)
80       logger.warn "ref returned by `GIT_DIR=#{gitdir} #{git_max_hash_cmd}` was invalid for max_hash: #{max_hash}"
81       return []
82     end
83
84     resolved_exclude = nil
85     if exclude
86       resolved_exclude = []
87       exclude.each do |e|
88         if git_check_ref_format(e)
89           IO.foreach("|git rev-list --max-count=1 #{e.shellescape} --") do |line|
90             resolved_exclude.push(line.strip)
91           end
92         else
93           logger.warn "find_commit_range called with invalid exclude invalid characters: '#{exclude}'"
94           return []
95         end
96       end
97     end
98
99     if minimum
100       # Get the commit hash for the lower bound
101       min_hash = nil
102       git_min_hash_cmd = "git rev-list --max-count=1 #{minimum.shellescape} --"
103       IO.foreach("|#{git_min_hash_cmd}") do |line|
104         min_hash = line.strip
105       end
106
107       # If not found, nothing else to do
108       if !min_hash
109         logger.warn "no refs found looking for min_hash: `GIT_DIR=#{gitdir} #{git_min_hash_cmd}` returned no output"
110         return []
111       end
112
113       # If string is invalid, nothing else to do
114       if !git_check_ref_format(min_hash)
115         logger.warn "ref returned by `GIT_DIR=#{gitdir} #{git_min_hash_cmd}` was invalid for min_hash: #{min_hash}"
116         return []
117       end
118
119       # Now find all commits between them
120       IO.foreach("|git rev-list #{min_hash.shellescape}..#{max_hash.shellescape} --") do |line|
121         hash = line.strip
122         commits.push(hash) if !resolved_exclude or !resolved_exclude.include? hash
123       end
124
125       commits.push(min_hash) if !resolved_exclude or !resolved_exclude.include? min_hash
126     else
127       commits.push(max_hash) if !resolved_exclude or !resolved_exclude.include? max_hash
128     end
129
130     commits
131   end
132
133   # Given a repository (url, or name of hosted repo) and commit sha1,
134   # copy the commit into the internal git repo (if necessary), and tag
135   # it with the given tag (typically a job UUID).
136   #
137   # The repo can be a remote url, but in this case sha1 must already
138   # be present in our local cache for that repo: e.g., sha1 was just
139   # returned by find_commit_range.
140   def self.tag_in_internal_repository repo_name, sha1, tag
141     unless git_check_ref_format tag
142       raise ArgumentError.new "invalid tag #{tag}"
143     end
144     unless /^[0-9a-f]{40}$/ =~ sha1
145       raise ArgumentError.new "invalid sha1 #{sha1}"
146     end
147     src_gitdir, _ = git_dir_for repo_name
148     unless src_gitdir
149       raise ArgumentError.new "no local repository for #{repo_name}"
150     end
151     dst_gitdir = Rails.configuration.git_internal_dir
152
153     begin
154       commit_in_dst = must_git(dst_gitdir, "log -n1 --format=%H #{sha1.shellescape}^{commit}").strip
155     rescue GitError
156       commit_in_dst = false
157     end
158
159     tag_cmd = "tag --force #{tag.shellescape} #{sha1.shellescape}^{commit}"
160     if commit_in_dst == sha1
161       must_git(dst_gitdir, tag_cmd)
162     else
163       # git-fetch is faster than pack-objects|unpack-objects, but
164       # git-fetch can't fetch by sha1. So we first try to fetch a
165       # branch that has the desired commit, and if that fails (there
166       # is no such branch, or the branch we choose changes under us in
167       # race), we fall back to pack|unpack.
168       begin
169         branches = must_git(src_gitdir,
170                             "branch --contains #{sha1.shellescape}")
171         m = branches.match(/^. (\w+)\n/)
172         if !m
173           raise GitError.new "commit is not on any branch"
174         end
175         branch = m[1]
176         must_git(dst_gitdir,
177                  "fetch file://#{src_gitdir.shellescape} #{branch.shellescape}")
178         # Even if all of the above steps succeeded, we might still not
179         # have the right commit due to a race, in which case tag_cmd
180         # will fail, and we'll need to fall back to pack|unpack. So
181         # don't be tempted to condense this tag_cmd and the one in the
182         # rescue block into a single attempt.
183         must_git(dst_gitdir, tag_cmd)
184       rescue GitError
185         must_pipe("echo #{sha1.shellescape}",
186                   "git --git-dir #{src_gitdir.shellescape} pack-objects -q --revs --stdout",
187                   "git --git-dir #{dst_gitdir.shellescape} unpack-objects -q")
188         must_git(dst_gitdir, tag_cmd)
189       end
190     end
191   end
192
193   protected
194
195   def self.remote_url? repo_name
196     /^(https?|git):\/\// =~ repo_name
197   end
198
199   # Return [local_git_dir, is_remote]. If is_remote, caller must use
200   # fetch_remote_repository to ensure content is up-to-date.
201   #
202   # Raises an exception if the latest content could not be fetched for
203   # any reason.
204   def self.git_dir_for repo_name
205     if remote_url? repo_name
206       return [cache_dir_for(repo_name), true]
207     end
208     repos = Repository.readable_by(current_user).where(name: repo_name)
209     if repos.count == 0
210       raise ArgumentError.new "Repository not found: '#{repo_name}'"
211     elsif repos.count > 1
212       logger.error "Multiple repositories with name=='#{repo_name}'!"
213       raise ArgumentError.new "Name conflict"
214     else
215       return [repos.first.server_path, false]
216     end
217   end
218
219   def self.cache_dir_for git_url
220     File.join(cache_dir_base, Digest::SHA1.hexdigest(git_url) + ".git").to_s
221   end
222
223   def self.cache_dir_base
224     Rails.root.join 'tmp', 'git-cache'
225   end
226
227   def self.fetch_remote_repository gitdir, git_url
228     # Caller decides which protocols are worth using. This is just a
229     # safety check to ensure we never use urls like "--flag" or wander
230     # into git's hardlink features by using bare "/path/foo" instead
231     # of "file:///path/foo".
232     unless /^[a-z]+:\/\// =~ git_url
233       raise ArgumentError.new "invalid git url #{git_url}"
234     end
235     begin
236       must_git gitdir, "branch"
237     rescue GitError => e
238       raise unless /Not a git repository/ =~ e.to_s
239       # OK, this just means we need to create a blank cache repository
240       # before fetching.
241       FileUtils.mkdir_p gitdir
242       must_git gitdir, "init"
243     end
244     must_git(gitdir,
245              "fetch --no-progress --tags --prune --force --update-head-ok #{git_url.shellescape} 'refs/heads/*:refs/heads/*'")
246   end
247
248   def self.must_git gitdir, *cmds
249     # Clear token in case a git helper tries to use it as a password.
250     orig_token = ENV['ARVADOS_API_TOKEN']
251     ENV['ARVADOS_API_TOKEN'] = ''
252     last_output = ''
253     begin
254       git = "git --git-dir #{gitdir.shellescape}"
255       cmds.each do |cmd|
256         last_output = must_pipe git+" "+cmd
257       end
258     ensure
259       ENV['ARVADOS_API_TOKEN'] = orig_token
260     end
261     return last_output
262   end
263
264   def self.must_pipe *cmds
265     cmd = cmds.join(" 2>&1 |") + " 2>&1"
266     out = IO.read("| </dev/null #{cmd}")
267     if not $?.success?
268       raise GitError.new "#{cmd}: #{$?}: #{out}"
269     end
270     return out
271   end
272 end