main
rb 240 lines 8.44 KB
Raw
1 # frozen_string_literal: true
2
3 require "open3"
4 require "fileutils"
5 require "tmpdir"
6 require "tempfile"
7 require "timeout"
8 require "base64"
9
10 # The git mechanics of an import: mirror-clone a source repo in a sandbox, then
11 # push every ref (all branches and tags, full history, no rewrites) into an
12 # existing bare siGit repo. LFS objects are fetched when git-lfs is available,
13 # and clearly reported as skipped otherwise.
14 #
15 # This class is deliberately isolation-focused — every guardrail that keeps a
16 # hung or malicious remote from wedging a worker lives here:
17 # - the clone runs in a fresh temp dir that is always removed,
18 # - every git invocation runs with a hard timeout and is killed (whole process
19 # group) if it overruns,
20 # - `GIT_TERMINAL_PROMPT=0` makes auth failures fail fast instead of hanging,
21 # - the cloned size is capped, so one repo can't fill the disk,
22 # - credentials are passed via an HTTP header (not the URL, not the stored
23 # remote config) and scrubbed from any captured output before it is logged.
24 #
25 # It does not touch the database or create the destination repo — the caller
26 # (RepositoryImportJob) owns lifecycle, status, and cleanup.
27 class RepositoryImportService
28 class ImportError < StandardError; end
29 class SizeExceeded < ImportError; end
30 class CloneTimeout < ImportError; end
31
32 # A single git call may not pin a worker forever. Overridable for very large
33 # legitimate repos via env.
34 DEFAULT_TIMEOUT = Integer(ENV.fetch("IMPORT_GIT_TIMEOUT", 1800)) # 30 min
35
36 # Per-repo on-disk cap. GitHub reports size in KB; we also re-check the actual
37 # clone so URL imports (no reported size) are bounded too.
38 DEFAULT_MAX_SIZE_KB = Integer(ENV.fetch("IMPORT_MAX_REPO_SIZE_KB", 2_000_000)) # ~2 GB
39
40 Result = Struct.new(:lfs_detected, :lfs_skipped, :size_kb, keyword_init: true)
41
42 # Clones +source_url+ (with optional +credential+ token for private sources)
43 # and pushes all refs into the existing bare repo at +dest_path+. Yields a
44 # status symbol (:cloning, :pushing, :fetching_lfs) to +progress+ as it moves
45 # through the phases. Returns a Result. Raises ImportError (or a subclass) on
46 # failure; the caller is responsible for cleaning up +dest_path+.
47 def self.clone_and_push(source_url:, dest_path:, credential: nil,
48 max_size_kb: DEFAULT_MAX_SIZE_KB, timeout: DEFAULT_TIMEOUT, progress: nil)
49 new(source_url:, dest_path:, credential:, max_size_kb:, timeout:, progress:).call
50 end
51
52 def initialize(source_url:, dest_path:, credential:, max_size_kb:, timeout:, progress:)
53 @source_url = source_url
54 @dest_path = dest_path
55 @credential = credential
56 @max_size_kb = max_size_kb
57 @timeout = timeout
58 @progress = progress
59 end
60
61 def call
62 Dir.mktmpdir("sigit-import-") do |workdir|
63 mirror = File.join(workdir, "source.git")
64
65 emit(:cloning)
66 clone_mirror(mirror)
67 enforce_size!(mirror)
68
69 emit(:pushing)
70 push_all_refs(mirror)
71
72 emit(:fetching_lfs)
73 lfs = handle_lfs(mirror)
74
75 Result.new(
76 lfs_detected: lfs[:detected],
77 lfs_skipped: lfs[:skipped],
78 size_kb: dir_size_kb(@dest_path)
79 )
80 end
81 end
82
83 private
84
85 def emit(phase)
86 @progress&.call(phase)
87 end
88
89 def clone_mirror(dest)
90 args = [ "git" ]
91 args += credential_config
92 args += [ "clone", "--mirror", @source_url, dest ]
93
94 ok, output = run(args, timeout: @timeout)
95 raise ImportError, "Clone failed: #{first_error_line(output)}" unless ok
96 end
97
98 # Push every ref verbatim (heads + tags), preserving history exactly. The
99 # refspec form (rather than `--mirror`) keeps siGit's internal refs, if any,
100 # from being pruned, and never rewrites commits.
101 def push_all_refs(mirror)
102 ok, output = run([ "git", "-C", mirror, "push", @dest_path, "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*" ],
103 timeout: @timeout)
104 raise ImportError, "Push failed: #{first_error_line(output)}" unless ok
105 end
106
107 # Best-effort LFS handling. Detection is cheap (does any ref carry a
108 # `.gitattributes` with `filter=lfs`); fetching requires the git-lfs binary.
109 # When it's missing we don't fail — the LFS *pointer* files are ordinary git
110 # blobs and are already imported with the history; only the large binaries are
111 # skipped, which we report so the UI can say so plainly.
112 def handle_lfs(mirror)
113 return { detected: false, skipped: false } unless lfs_used?(mirror)
114 return { detected: true, skipped: true } unless lfs_available?
115
116 ok, _ = run([ "git", "-C", mirror, "lfs", "fetch", "--all" ], timeout: @timeout)
117 return { detected: true, skipped: true } unless ok
118
119 # Copy the fetched object store into the destination so siGit holds the
120 # binaries too. Non-fatal if it doesn't land.
121 src_lfs = File.join(mirror, "lfs")
122 if Dir.exist?(src_lfs)
123 FileUtils.cp_r(src_lfs, File.join(@dest_path, "lfs"))
124 { detected: true, skipped: false }
125 else
126 { detected: true, skipped: true }
127 end
128 rescue StandardError => e
129 Rails.logger.warn("LFS import step failed (continuing): #{e.class}")
130 { detected: true, skipped: true }
131 end
132
133 def lfs_used?(mirror)
134 # Check the tip of every branch for a .gitattributes that enables the lfs
135 # filter. Cheap and covers the overwhelmingly common case. The blob read
136 # goes through GitRepositoryService (the single git-read layer) rather than
137 # shelling out again here.
138 ok, refs = run([ "git", "-C", mirror, "for-each-ref", "--format=%(refname)", "refs/heads/" ], timeout: 30)
139 return false unless ok
140
141 refs.each_line.map(&:strip).reject(&:empty?).any? do |ref|
142 content = GitRepositoryService.file_content(mirror, ref, ".gitattributes")
143 content&.include?("filter=lfs")
144 end
145 end
146
147 def lfs_available?
148 _o, _e, st = Open3.capture3("git", "lfs", "version")
149 st.success?
150 rescue StandardError
151 false
152 end
153
154 def enforce_size!(mirror)
155 size = dir_size_kb(mirror)
156 return if size.nil? || size <= @max_size_kb
157
158 raise SizeExceeded,
159 "Repository is #{(size / 1024.0).round} MB, over the #{(@max_size_kb / 1024.0).round} MB import limit."
160 end
161
162 # ── process execution ────────────────────────────────────────────────────
163
164 # Runs +cmd+ with a hard timeout. On overrun the whole process group is killed
165 # so a hung `git` (or a child it spawned) can't linger. Returns
166 # [success_bool, output_string]; output has any credential scrubbed out.
167 def run(cmd, timeout:)
168 out = Tempfile.new("sigit-git-out")
169 begin
170 pid = Process.spawn(git_env, *cmd, out: out.path, err: [ :child, :out ], pgroup: true)
171 begin
172 Timeout.timeout(timeout) { Process.wait(pid) }
173 rescue Timeout::Error
174 kill_group(pid)
175 raise CloneTimeout, "git operation exceeded #{timeout}s and was terminated."
176 end
177 success = $?.success?
178 [ success, scrub(File.read(out.path)) ]
179 ensure
180 out.close!
181 end
182 end
183
184 def kill_group(pid)
185 begin
186 Process.kill("TERM", -pid)
187 sleep 2
188 Process.kill("KILL", -pid)
189 rescue Errno::ESRCH, Errno::EPERM
190 # already gone
191 end
192
193 begin
194 Process.wait(pid)
195 rescue Errno::ECHILD
196 nil
197 end
198 end
199
200 def git_env
201 {
202 # Never block on an interactive auth/host prompt — fail fast instead.
203 "GIT_TERMINAL_PROMPT" => "0",
204 "GIT_ASKPASS" => "/bin/echo",
205 "GCM_INTERACTIVE" => "never",
206 # Don't try to download LFS blobs during the mirror clone; we handle LFS
207 # explicitly afterwards.
208 "GIT_LFS_SKIP_SMUDGE" => "1"
209 }
210 end
211
212 # Auth for a private source, injected as an HTTP header via `-c` so the token
213 # never lands in the URL, the stored remote config, or the clone on disk.
214 def credential_config
215 return [] if @credential.blank?
216
217 basic = Base64.strict_encode64("x-access-token:#{@credential}")
218 [ "-c", "http.extraHeader=Authorization: Basic #{basic}" ]
219 end
220
221 # Remove the credential from any captured text before it can be logged or
222 # persisted to the import's error_message.
223 def scrub(text)
224 return text if @credential.blank?
225
226 text.to_s.gsub(@credential, "***")
227 end
228
229 def first_error_line(output)
230 line = output.to_s.each_line.map(&:strip).reject(&:empty?).last
231 line.presence || "unknown error"
232 end
233
234 def dir_size_kb(path)
235 return nil unless Dir.exist?(path)
236
237 out, _e, st = Open3.capture3("du", "-sk", path)
238 st.success? ? out.split("\t").first.to_i : nil
239 end
240 end