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