Commit a066d5e8 authored by Kirill Smelkov's avatar Kirill Smelkov

pull, restore: Read refs of a saved repository only once; Use that reading to...

pull, restore: Read refs of a saved repository only once; Use that reading to recreate refs on restore

Since the beginning `git-backup pull` was working by saving discovered
files as blobs and only making special care about *.git/objects/ via
fetching data as git packs from there. This means that besides
*.git/objects/ other files and directories inside *.git are saved as just
regular files. For example *.git/HEAD , *.git/description and
*.git/heads/refs/master are all saved as regular files. Which works ok
when we know that there is no simultaneous modifications to saved
repository, but could result in inconsistency in refs part in the presence of
concurrent updates.

For example Alain and Thomas report that restore side of lab.nexedi.com
PBS complains sometimes as e.g.

main.cmd_restore: E: extracted /srv/slapgrid/slappart43/srv/runner/instance/slappart0/var/repositories.1737918101/nexedi/erp5.git refs corrupt:

    want:
    9e7ca95f7104af0c3acf7e69cdd80306f10c8567 refs/heads/DateTime.equalTo_fix
    c11e08bac6fb8d41a2ce6c3c4f3321b4f3cd295c refs/heads/TMP-2to3
    48b8c69f291026f6cb08fb118978dcf537b0e12a refs/heads/UpdateValidationStateFromConsistency
    335648bdb91c75da22f9a4a627127933de5a6cd3 refs/heads/UserPropertySheet_backward_compatibility
    d5cef37ec4bba46168b04400978edf4a64ab781f refs/heads/addToDate_implicit_localtime
    78e033ac7f1f0e80b51194fd732a495b003c1947 refs/heads/add_boolean_type
    6ee39f470be53eb306e65b52e3ae6770f5487cf6 refs/heads/allow_login_change
    5f5d2daada9502b7b4db8b3bc275571c40e39b54 refs/heads/allow_login_change_wip
    8b6848d8bf8e60f491eb34b102f85eaf466f751c refs/heads/arnau
    33c86fbbdf2f847b32bd5eabf7c34fbf5eddc8fa refs/heads/arnau-RD-Components-CacheTool
    9f1ce1bab9489307be29b3c8439c78158e57a48e refs/heads/arnau-RD-Components-ERP5Form-ERP5Report
    03a1712fedeabe643cdb1833b26b6d6370133a74 refs/heads/arnau-RD-Components-ERP5Form-SelectionTool-MemcachedTool
    ...

    have:
    9e7ca95f7104af0c3acf7e69cdd80306f10c8567 refs/heads/DateTime.equalTo_fix
    c11e08bac6fb8d41a2ce6c3c4f3321b4f3cd295c refs/heads/TMP-2to3
    48b8c69f291026f6cb08fb118978dcf537b0e12a refs/heads/UpdateValidationStateFromConsistency
    335648bdb91c75da22f9a4a627127933de5a6cd3 refs/heads/UserPropertySheet_backward_compatibility
    d5cef37ec4bba46168b04400978edf4a64ab781f refs/heads/addToDate_implicit_localtime
    78e033ac7f1f0e80b51194fd732a495b003c1947 refs/heads/add_boolean_type
    6ee39f470be53eb306e65b52e3ae6770f5487cf6 refs/heads/allow_login_change
    5f5d2daada9502b7b4db8b3bc275571c40e39b54 refs/heads/allow_login_change_wip
    8b6848d8bf8e60f491eb34b102f85eaf466f751c refs/heads/arnau
    33c86fbbdf2f847b32bd5eabf7c34fbf5eddc8fa refs/heads/arnau-RD-Components-CacheTool
    9f1ce1bab9489307be29b3c8439c78158e57a48e refs/heads/arnau-RD-Components-ERP5Form-ERP5Report
    03a1712fedeabe643cdb1833b26b6d6370133a74 refs/heads/arnau-RD-Components-ERP5Form-SelectionTool-MemcachedTool
    ...

which was found to result in the following difference:

    diff --git a/a b/b
    index fb51541cace8d..98efbacb8ed6f 100644
    --- a/a
    +++ b/b
    @@ -18997,13 +18997,13 @@ ce76cb544752122200c72b1fe0e8d22de3409e89 refs/merge-requests/1127/head
     726b07bccde0ed7f88b8ffcf9e4adc9c64624a75 refs/merge-requests/113/head
     dca6727ddb752a803764f6f2248c41b5ad98f653 refs/merge-requests/1130/head
     0ecb76f1d433f6b04bf9562313f84375769377e4 refs/merge-requests/1131/head
    -ebf766512111f25e9cadd7f00d7e730e980ecde8 refs/merge-requests/1131/merge
    +cb636a38c13e00de2400f90f17d50f6a15ddb584 refs/merge-requests/1131/merge
     f8a87dfd1a0af405bd162b5c6884081f72fb29e1 refs/merge-requests/1132/head
     da16b24621c7fd8940906a718acf2aa3ad325ba6 refs/merge-requests/1132/merge
     2d34bbeac38908a08546e8df0c912242dfbe0077 refs/merge-requests/1133/head
     9f2fa83567bd0c1491d45d5b6809998276581354 refs/merge-requests/1134/head
     850dcdc6c174487b51098e833aa4a8e868e3d3e6 refs/merge-requests/1135/head
    -959cfc12c6b3d61fe324813070803c92efea8373 refs/merge-requests/1135/merge
    +ff7515979f75b83adecaa18a72b114ce5742b906 refs/merge-requests/1135/merge
     64ec4b8e7abf11f3f1877285dc4ec04e936b75b8 refs/merge-requests/1136/head
     1f4e93c0c458739f0a97834802682a90642ddb10 refs/merge-requests/1137/head
     41fa1d0c5e95ad51bb9d1d3d5bc4b404e9f7b44b refs/merge-requests/1138/head

If we check about e.g. 1131 merge request, we can see that
ebf766512111f25e9cadd7f00d7e730e980ecde8 is a recent commit, done a few days
before the time of backup, and that cb636a38c13e00de2400f90f17d50f6a15ddb584 is
not there in the backup repository at all:

    backup-gitlab.git$ git log -1 HEAD
    commit 296fe74285068fcd1bbc7333244883b49612bef8 (HEAD -> master)
    Merge: b4cd493bd50ae 0000025c4085f 00007c41785ae ...
    Date:   Sun Jan 26 20:35:43 2025 +0060

    Git-backup 20250126-2005

    backup-gitlab.git$ git log -1 ebf766512111f25e9cadd7f00d7e730e980ecde8
    commit ebf766512111f25e9cadd7f00d7e730e980ecde8
    Merge: 569f78098281a 0ecb76f1d433f
    Author: Łukasz Nowak <luke@nexedi.com>
    Date:   Wed Jan 22 19:46:15 2025 +0100

        WIP: Feature/rjs gadget pagehelp

        See merge request nexedi/erp5!1131

    backup-gitlab.git$ git log -1 cb636a38c13e00de2400f90f17d50f6a15ddb584
    fatal: bad object cb636a38c13e00de2400f90f17d50f6a15ddb584

So what happenned here is that GitLab, it seems, automatically creates
refs/merge-requests/X/merge from time to time, and from time to updates
it with a fresh merge commit, ready to be used as the merge commit,
if/when the merge-request in question becomes merged. Then the time of
updating such merge commit aligned with the time when `git-backup pull`
was running, git-backup first pulled when the state of saved repository
was not yet modifed, then GitLab updated refs/merge-requests/1131/merge
reference, and then `git-backup pull` continued and saved content of
repo.git/refs/merge-requests/1131/merge file in its updated state.
However the backup.refs in the backup repository is built from the state
of saved references observed at fetch time, and so this results in the
inconsistency in between information saved into backup.refs and
information saved about those refs via just files from under *.git/ .

-> Fix that by reading refs state only once - when doing fetch, and
restoring refs state from that read state without relying on per-file
level save/restore.

For the reference Git itself implements fetching with always providing
some consistent state of fetched repository. So even though in the
presence of simultaneous changes git-backup cannot guarantee overall
atomicity of saved backup, what it now should be able to guarantee is
that saved state is some set of per-individual-repository consistent
snapshots. As before, if one wants to make a fully atomic snapshot of
the data, GitLab service should be stopped before running `git-backup pull`.

Added test fails as follows without the fix:

    git-backup_test.go:291: pull: third run was not noop: δ:
        diff --git a/b1/dir/hello.git/refs/test/ref-to-blob b/b1/dir/hello.git/refs/test/ref-to-blob
        new file mode 100644
        index 0000000..379708e
        --- /dev/null
        +++ b/b1/dir/hello.git/refs/test/ref-to-blob
        @@ -0,0 +1 @@
        +cb8d6bb5e54b1c7159698442057416473a3b5385

and even if we disable δ23 check in the test via

    @@ -286,7 +287,7 @@ func TestPullRestore(t *testing.T) {
                    t.Fatal("pull: third run did not adjusted HEAD")
            }
            δ23 := xgit(ctx, "diff", h2, h3)
    -       if δ23 != "" {
    +       if false && δ23 != "" {
                    t.Fatalf("pull: third run was not noop: δ:\n%s", δ23)
            }

then it becomes to fail in the restore part of the test confirming that
the test is effective to cover hit problem:

    E: Problem while checking connectivity of extracted repo:
        git-backup_test.go:109: git-backup_test.go:296: lab.nexedi.com/kirr/git-backup.cmd_restore: E: extracted /tmp/t-git-backup10753034/1/dir/hello.git refs corrupt:

            want:
            647e137fd3b31939b36889eba854a298ef97b6ff refs/heads/branch2
            feeed96ca75fcf8dcf183008f61dbf72e91ab4de refs/heads/master
            11e67095628aa17b03436850e690faea3006c25d refs/tags/tag-to-blob
            f735011c9fcece41219729a33f7876cd8791f659 refs/tags/tag-to-commit
            7124713e403925bc772cd252b0dec099f3ced9c5 refs/tags/tag-to-tag
            ba899e5639273a6fa4d50d684af8db1ae070351e refs/tags/tag-to-tree
            7a3343f584218e973165d943d7c0af47a52ca477 refs/test/ref-to-blob	<-- NOTE
            61882eb85774ed4401681d800bb9c638031375e2 refs/test/ref-to-tree

            have:
            647e137fd3b31939b36889eba854a298ef97b6ff refs/heads/branch2
            feeed96ca75fcf8dcf183008f61dbf72e91ab4de refs/heads/master
            11e67095628aa17b03436850e690faea3006c25d refs/tags/tag-to-blob
            f735011c9fcece41219729a33f7876cd8791f659 refs/tags/tag-to-commit
            7124713e403925bc772cd252b0dec099f3ced9c5 refs/tags/tag-to-tag
            ba899e5639273a6fa4d50d684af8db1ae070351e refs/tags/tag-to-tree
            cb8d6bb5e54b1c7159698442057416473a3b5385 refs/test/ref-to-blob	<-- NOTE
            61882eb85774ed4401681d800bb9c638031375e2 refs/test/ref-to-tree

/reported-by @alain.takoudjou, @tomo
/reported-on https://www.erp5.com/group_section/forum/Gitlab-backup-zDVMZqaMAK
/reviewed-by @jerome, @alain.takoudjou, @tomo
/reviewed-on !11
/cc @rafael
parent e6c4baa9
// Copyright (C) 2015-2021 Nexedi SA and Contributors.
// Copyright (C) 2015-2025 Nexedi SA and Contributors.
// Kirill Smelkov <kirr@nexedi.com>
//
// This program is free software: you can Use, Study, Modify and Redistribute
......@@ -476,6 +476,12 @@ func cmd_pull_(ctx context.Context, gb *git.Repository, pullspecv []PullSpec) {
// files -> blobs + queue info for adding blobs to index
if !info.IsDir() {
// everything related to *.git/refs is ignored
// (see below comment about .git/refs for details)
if strings.HasSuffix(path, ".git/packed-refs") {
return nil
}
infof("# file %s\t<- %s", prefix, path)
blob, mode := file_to_blob(gb, path)
blobbedv = append(blobbedv,
......@@ -490,6 +496,11 @@ func cmd_pull_(ctx context.Context, gb *git.Repository, pullspecv []PullSpec) {
return filepath.SkipDir
}
// neither we do not recurse into *.git/refs & co - we'll save refs via backup.refs blob
if strings.HasSuffix(path, ".git/refs") || strings.HasSuffix(path, ".git/reftable") {
return filepath.SkipDir
}
// else we recurse, but handle *.git specially - via fetching objects from it
if !strings.HasSuffix(path, ".git") {
return nil
......@@ -692,8 +703,14 @@ func cmd_pull_(ctx context.Context, gb *git.Repository, pullspecv []PullSpec) {
//
// Note: fetch does not create any local references - the references returned
// only describe state of references in fetched source repository.
var tfetchPostHook func(repo string)
func fetch(ctx context.Context, repo string, alreadyHave Sha1Set) (refv, fetchedv []Ref, err error) {
defer xerr.Contextf(&err, "fetch %s", repo)
defer func() {
if tfetchPostHook != nil {
tfetchPostHook(repo)
}
}()
// first check which references are advertised
refv, err = lsremote(ctx, repo)
......@@ -1008,6 +1025,27 @@ func cmd_restore_(ctx context.Context, gb *git.Repository, HEAD_ string, restore
exc.Raiseif(ctx.Err())
// skip *.git/refs/... & co on restore
//
// pre-2025 git-backup used to save both backup.refs and .git/refs/* as regular files
// which was resulting in inconsistent backup in the presence of concurrent
// modifications of the saved repository because refs values were read and saved twice -
// once at the fetch time, and then also when saving .git/refs/* as just files. Nowadays
// pull saves refs observed only at the fetch time and does not pull .git/refs/* as
// regular files. However to be able to restore backups made before corresponding pull
// fix, restore needs to ignore refs-related files and recreate refs using backup.refs
// blob as the only source.
dotgit := strings.LastIndex(filename, ".git/")
if dotgit != -1 {
ingit := filename[dotgit:]
if strings.HasPrefix(ingit, ".git/refs/") ||
strings.HasPrefix(ingit, ".git/reftable/") ||
ingit == ".git/packed-refs" {
infof("# file %s\t-> %s\t(skip)", prefix, filename)
continue
}
}
filename = reprefix(prefix, dir, filename)
infof("# file %s\t-> %s", prefix, filename)
blob_to_file(gb, sha1, mode, filename)
......@@ -1098,11 +1136,19 @@ func cmd_restore_(ctx context.Context, gb *git.Repository, HEAD_ string, restore
xgit2(ctx, pack_argv, RunWith{stdin: p.refs.Sha1HeadsStr(), stderr: gitprogress()})
// extract refs for that repo from backup.refs entries
repo_refs := p.refs.Values()
sort.Sort(ByRefname(repo_refs))
repo_ref_createv := make([]string, 0, len(repo_refs))
for _, ref := range repo_refs {
repo_ref_createv = append(repo_ref_createv, fmt.Sprintf("create refs/%s\x00%s", ref.name, ref.sha1))
}
repo_ref_create := strings.Join(repo_ref_createv, "\x00")
xgit(ctx, "--git-dir="+p.repopath, "update-ref", "--no-deref", "--stdin", "-z", RunWith{stdin: repo_ref_create})
// verify that extracted repo refs match backup.refs index after extraction
x_ref_list := xgit(ctx, "--git-dir="+p.repopath,
"for-each-ref", "--format=%(objectname) %(refname)")
repo_refs := p.refs.Values()
sort.Sort(ByRefname(repo_refs))
repo_ref_listv := make([]string, 0, len(repo_refs))
for _, ref := range repo_refs {
repo_ref_listv = append(repo_ref_listv, fmt.Sprintf("%s refs/%s", ref.sha1, ref.name))
......
// Copyright (C) 2015-2024 Nexedi SA and Contributors.
// Copyright (C) 2015-2025 Nexedi SA and Contributors.
// Kirill Smelkov <kirr@nexedi.com>
//
// This program is free software: you can Use, Study, Modify and Redistribute
......@@ -24,6 +24,7 @@ import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
......@@ -74,6 +75,15 @@ func xnoref(ref string) {
xgit(context.Background(), "update-ref", "--stdin", RunWith{stdin: fmt.Sprintf("verify refs/%s %s\n", ref, Sha1{})})
}
// xcopy recursively copies directory src to dst.
func xcopy(t *testing.T, src, dst string) {
cmd := exec.Command("cp", "-a", src, dst)
err := cmd.Run()
if err != nil {
t.Fatal(err)
}
}
// verify end-to-end pull-restore
func TestPullRestore(t *testing.T) {
......@@ -226,6 +236,61 @@ func TestPullRestore(t *testing.T) {
t.Fatalf("pull: second run was not noop: δ:\n%s", δ12)
}
// pull again with modifying one of the repositories simultaneously
//
// This verifies that pulling results in consistent refs and objects
// even if backed'up repo is modified concurrently. Previously in such
// case pull used to create backup.refs and repo.git/refs inconsistent
// to each other leading to an error on restore.
//
// We test this case via modifying a ref right after fetch but before
// pulling of plain files from inside repo.git . In order to create
// consisten backup pull should ignore content of refs in files and
// read refs only once - when doing the fetch.
mod1 := workdir + "/1mod"
xcopy(t, my1, mod1)
mod1RepoUpdated := false
defer func() { tfetchPostHook = nil }()
tfetchPostHook = func(repo string) {
if repo != mod1 + "/dir/hello.git" {
return
}
if mod1RepoUpdated {
t.Fatal("hello.git post fetch called twice")
}
hold := xgitSha1(ctx, "--git-dir="+repo, "rev-parse", "--verify", "refs/test/ref-to-blob")
hnew := xgitSha1(ctx, "--git-dir="+repo, "hash-object", "-w", "--stdin", RunWith{stdin: "new blob"})
if hnewOk := XSha1("cb8d6bb5e54b1c7159698442057416473a3b5385"); hnew != hnewOk {
t.Fatalf("object for new blob did not hash correctly: have: %s ; want: %s", hnew, hnewOk)
}
xgit(ctx, "--git-dir="+repo, "update-ref", "refs/test/ref-to-blob", hnew, hold)
hnew_ := xgitSha1(ctx, "--git-dir="+repo, "rev-parse", "--verify", "refs/test/ref-to-blob")
if hnew_ != hnew {
t.Fatalf("ref-to-blob did not update correctly: have %s ; want %s", hnew_, hnew)
}
mod1RepoUpdated = true
}
cmd_pull(ctx, gb, []string{mod1 + ":b1"})
afterPull()
tfetchPostHook = nil
if !mod1RepoUpdated {
t.Fatal("mod1/hello.git: hook to update it right after fetch was not ran")
}
h3 := xgitSha1(ctx, "rev-parse", "HEAD")
if h3 == h2 {
t.Fatal("pull: third run did not adjusted HEAD")
}
δ23 := xgit(ctx, "diff", h2, h3)
if δ23 != "" {
t.Fatalf("pull: third run was not noop: δ:\n%s", δ23)
}
// restore backup
work1 := workdir + "/1"
......@@ -237,21 +302,21 @@ func TestPullRestore(t *testing.T) {
if gerr != nil && gerr.Sys().(syscall.WaitStatus).ExitStatus() > 1 {
t.Fatal(gerr)
}
gitObjectsRe := regexp.MustCompile(`\.git/objects/`)
gitObjectsAndRefsRe := regexp.MustCompile(`\.git/(objects/|refs/|packed-refs|reftable/)`)
for _, diffline := range strings.Split(diff, "\n") {
// :srcmode dstmode srcsha1 dstsha1 status\tpath
_, path, err := xstrings.HeadTail(diffline, "\t")
if err != nil {
t.Fatalf("restorecheck: cannot parse diff line %q", diffline)
}
// git objects can be represented differently (we check them later)
if gitObjectsRe.FindString(path) != "" {
// git objects and refscan be represented differently (we check them later)
if gitObjectsAndRefsRe.FindString(path) != "" {
continue
}
t.Fatal("restorecheck: unexpected diff:", diffline)
}
// verify git objects restored to the same as original
// verify git objects and refs restored to the same as original
err = filepath.Walk(my1, func(path string, info os.FileInfo, err error) error {
// any error -> stop
if err != nil {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment