main
rb 112 lines 3.23 KB
Raw
1 # frozen_string_literal: true
2
3 require "open3"
4 require "tempfile"
5 require "timeout"
6 require "base64"
7
8 # Re-syncs a mirror repository from its upstream: force-fetches all heads and
9 # tags into the (read-only) bare repo so it tracks the upstream exactly. Because
10 # mirrors reject local pushes, force-updating refs here can never clobber unique
11 # local work — the upstream is the single source of truth by design.
12 #
13 # Shares the isolation posture of RepositoryImportService: hard timeout with
14 # process-group kill, no interactive prompts, credentials passed as an HTTP
15 # header (never the URL or stored config) and scrubbed from captured output.
16 class MirrorSyncService
17 class SyncError < StandardError; end
18 class SyncTimeout < SyncError; end
19
20 DEFAULT_TIMEOUT = Integer(ENV.fetch("MIRROR_SYNC_TIMEOUT", 900)) # 15 min
21
22 def self.sync(repository, timeout: DEFAULT_TIMEOUT)
23 new(repository, timeout:).call
24 end
25
26 def initialize(repository, timeout: DEFAULT_TIMEOUT)
27 @repository = repository
28 @timeout = timeout
29 end
30
31 # Fetches upstream into the repo. Raises SyncError on failure. On success the
32 # caller stamps mirror_synced_at / mirror_status.
33 def call
34 raise SyncError, "Not a mirror." unless @repository.mirror?
35 raise SyncError, "Mirror has no upstream URL." if @repository.upstream_url.blank?
36
37 # Re-validate the upstream every sync: DNS could have been repointed at an
38 # internal address since the import, so this closes the rebinding window.
39 ImportUrlValidator.validate!(@repository.upstream_url)
40
41 args = [ "git" ]
42 args += credential_config
43 args += [ "-C", @repository.disk_path, "fetch", "--prune", @repository.upstream_url,
44 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*" ]
45
46 ok, output = run(args, timeout: @timeout)
47 raise SyncError, "Fetch failed: #{first_error_line(output)}" unless ok
48
49 true
50 end
51
52 private
53
54 def credential_config
55 token = @repository.upstream_token
56 return [] if token.blank?
57
58 basic = Base64.strict_encode64("x-access-token:#{token}")
59 [ "-c", "http.extraHeader=Authorization: Basic #{basic}" ]
60 end
61
62 def run(cmd, timeout:)
63 out = Tempfile.new("sigit-sync-out")
64 begin
65 pid = Process.spawn(git_env, *cmd, out: out.path, err: [ :child, :out ], pgroup: true)
66 begin
67 Timeout.timeout(timeout) { Process.wait(pid) }
68 rescue Timeout::Error
69 kill_group(pid)
70 raise SyncTimeout, "mirror sync exceeded #{timeout}s and was terminated."
71 end
72 [ $?.success?, scrub(File.read(out.path)) ]
73 ensure
74 out.close!
75 end
76 end
77
78 def kill_group(pid)
79 begin
80 Process.kill("TERM", -pid)
81 sleep 2
82 Process.kill("KILL", -pid)
83 rescue Errno::ESRCH, Errno::EPERM
84 nil
85 end
86 begin
87 Process.wait(pid)
88 rescue Errno::ECHILD
89 nil
90 end
91 end
92
93 def git_env
94 {
95 "GIT_TERMINAL_PROMPT" => "0",
96 "GIT_ASKPASS" => "/bin/echo",
97 "GCM_INTERACTIVE" => "never",
98 "GIT_LFS_SKIP_SMUDGE" => "1"
99 }
100 end
101
102 def scrub(text)
103 token = @repository.upstream_token
104 return text if token.blank?
105
106 text.to_s.gsub(token, "***")
107 end
108
109 def first_error_line(output)
110 output.to_s.each_line.map(&:strip).reject(&:empty?).last.presence || "unknown error"
111 end
112 end