-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathcore.go
298 lines (235 loc) · 6.87 KB
/
core.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
package githosts
import (
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"reflect"
"slices"
"strings"
"time"
"github.com/hashicorp/go-retryablehttp"
"gitlab.com/tozd/go/errors"
)
const (
envVarGitBackupDir = "GIT_BACKUP_DIR"
envVarGitHostsLog = "GITHOSTS_LOG"
refsMethod = "refs"
cloneMethod = "clone"
defaultRemoteMethod = cloneMethod
logEntryPrefix = "githosts-utils: "
statusOk = "ok"
statusFailed = "failed"
)
type repository struct {
Name string
Owner string
PathWithNameSpace string
Domain string
HTTPSUrl string
SSHUrl string
URLWithToken string
URLWithBasicAuth string
}
type describeReposOutput struct {
Repos []repository
}
type RepoBackupResults struct {
Repo string `json:"repo,omitempty"`
Status string `json:"status,omitempty"` // ok, failed
Error errors.E `json:"error,omitempty"`
}
// type ProviderBackupResult []RepoBackupResults
type ProviderBackupResult struct {
BackupResults []RepoBackupResults
Error errors.E
}
type gitProvider interface {
getAPIURL() string
describeRepos() (describeReposOutput, errors.E)
Backup() ProviderBackupResult
diffRemoteMethod() string
}
// gitRefs is a mapping of references to SHAs.
type gitRefs map[string]string
func remoteRefsMatchLocalRefs(cloneURL, backupPath string) bool {
// if there's no backup path then return false
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
return false
}
// if there are no backups
if !dirHasBundles(backupPath) {
return false
}
var rHeads, lHeads gitRefs
var err error
lHeads, err = getLatestBundleRefs(backupPath)
if err != nil {
logger.Printf("failed to get latest bundle refs for %s", backupPath)
return false
}
rHeads, err = getRemoteRefs(cloneURL)
if err != nil {
logger.Printf("failed to get remote refs")
return false
}
if reflect.DeepEqual(lHeads, rHeads) {
return true
}
return false
}
func cutBySpaceAndTrimOutput(in string) (before, after string, found bool) {
// remove leading and trailing space
in = strings.TrimSpace(in)
// try cutting by tab
b, a, f := strings.Cut(in, "\t")
if f {
b = strings.TrimSpace(b)
a = strings.TrimSpace(a)
if len(a) > 0 && len(b) > 0 {
return b, a, true
}
}
// try cutting by tab
b, a, f = strings.Cut(in, " ")
if f {
b = strings.TrimSpace(b)
a = strings.TrimSpace(a)
if len(a) > 0 && len(b) > 0 {
return b, a, true
}
}
return
}
func generateMapFromRefsCmdOutput(in []byte) (refs gitRefs, err error) {
refs = make(map[string]string)
lines := strings.Split(string(in), "\n")
for x := range lines {
// if empty (final line perhaps) then skip
if len(strings.TrimSpace(lines[x])) == 0 {
continue
}
// try cutting ref by both space and tab as its possible for both to be used
sha, ref, found := cutBySpaceAndTrimOutput(lines[x])
// expect only a sha and a ref
if !found {
logger.Printf("skipping invalid ref: %s", strings.TrimSpace(lines[x]))
continue
}
// git bundle list-heads returns pseudo-refs but not peeled tags
// this is required for comparison with remote references
if slices.Contains([]string{"HEAD", "FETCH_HEAD", "ORIG_HEAD", "MERGE_HEAD", "CHERRY_PICK_HEAD"}, ref) {
continue
}
refs[ref] = sha
}
return
}
func getRemoteRefs(cloneURL string) (refs gitRefs, err error) {
// --refs ignores pseudo-refs like HEAD and FETCH_HEAD, and also peeled tags that reference other objects
// this enables comparison with refs from existing bundles
remoteHeadsCmd := exec.Command("git", "ls-remote", "--refs", cloneURL)
out, err := remoteHeadsCmd.CombinedOutput()
if err != nil {
return refs, errors.Wrap(err, "failed to retrieve remote heads")
}
refs, err = generateMapFromRefsCmdOutput(out)
return
}
func processBackup(logLevel int, repo repository, backupDIR string, backupsToKeep int, diffRemoteMethod string) errors.E {
// create backup path
workingPath := filepath.Join(backupDIR, workingDIRName, repo.Domain, repo.PathWithNameSpace)
backupPath := filepath.Join(backupDIR, repo.Domain, repo.PathWithNameSpace)
// clean existing working directory
delErr := os.RemoveAll(workingPath)
if delErr != nil {
return errors.Errorf("failed to remove working directory: %s: %s", workingPath, delErr)
}
var cloneURL string
if repo.URLWithToken != "" {
cloneURL = repo.URLWithToken
} else if repo.URLWithBasicAuth != "" {
cloneURL = repo.URLWithBasicAuth
}
// Check if existing, latest bundle refs, already match the remote
if diffRemoteMethod == refsMethod {
// check backup path exists before attempting to compare remote and local heads
if remoteRefsMatchLocalRefs(cloneURL, backupPath) {
logger.Printf("skipping clone of %s repo '%s' as refs match existing bundle", repo.Domain, repo.PathWithNameSpace)
return nil
}
}
// clone repo
logger.Printf("cloning: %s to: %s", repo.HTTPSUrl, workingPath)
cloneCmd := exec.Command("git", "clone", "-v", "--mirror", cloneURL, workingPath)
cloneCmd.Dir = backupDIR
cloneOut, cloneErr := cloneCmd.CombinedOutput()
if cloneErr != nil {
fmt.Printf("cloning failed for repository: %s - %s\n", repo.Name, cloneErr)
}
cloneOutLines := strings.Split(string(cloneOut), "\n")
if cloneErr != nil {
if os.Getenv(envVarGitHostsLog) == "debug" {
fmt.Printf("debug: cloning failed for repository: %s - %s\n", repo.Name, strings.Join(cloneOutLines, ", "))
return errors.Errorf("cloning failed: %s: %s", strings.Join(cloneOutLines, ", "), cloneErr)
}
return errors.Errorf("cloning failed for repository: %s - %s", repo.Name, cloneErr)
}
// create bundle
if err := createBundle(logLevel, workingPath, backupPath, repo); err != nil {
if strings.HasSuffix(err.Error(), "is empty") {
logger.Printf("skipping empty %s repository %s", repo.Domain, repo.PathWithNameSpace)
return nil
}
return err
}
removeBundleIfDuplicate(backupPath)
if backupsToKeep > 0 {
if err := pruneBackups(backupPath, backupsToKeep); err != nil {
return err
}
}
return nil
}
func getHTTPClient() *retryablehttp.Client {
tr := &http.Transport{
DisableKeepAlives: false,
DisableCompression: true,
MaxIdleConns: maxIdleConns,
IdleConnTimeout: idleConnTimeout,
ForceAttemptHTTP2: false,
}
rc := retryablehttp.NewClient()
rc.HTTPClient = &http.Client{
Transport: tr,
Timeout: 120 * time.Second,
}
rc.Logger = nil
rc.RetryWaitMax = 120 * time.Second
rc.RetryWaitMin = 60 * time.Second
rc.RetryMax = 2
return rc
}
func validDiffRemoteMethod(method string) error {
if !slices.Contains([]string{cloneMethod, refsMethod}, method) {
return fmt.Errorf("invalid diff remote method: %s", method)
}
return nil
}
func setLoggerPrefix(prefix string) {
if prefix != "" {
logger.SetPrefix(fmt.Sprintf("%s: ", prefix))
}
}
func allTrue(in ...bool) bool {
for _, v := range in {
if !v {
return false
}
}
return true
}
func ToPtr[T any](v T) *T {
return &v
}