| 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 |