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