package main import ( "errors" "fmt" "io" "os" "os/user" "path" "path/filepath" "reflect" "strconv" "strings" "time" "github.com/alecthomas/kong" "github.com/cooperspencer/gickup/azureblob" "github.com/cooperspencer/gickup/bitbucket" "github.com/cooperspencer/gickup/gitea" "github.com/cooperspencer/gickup/github" "github.com/cooperspencer/gickup/gitlab" "github.com/cooperspencer/gickup/gogs" "github.com/cooperspencer/gickup/httpclient" "github.com/cooperspencer/gickup/local" "github.com/cooperspencer/gickup/logger" "github.com/cooperspencer/gickup/metrics/apprise" "github.com/cooperspencer/gickup/metrics/gotify" "github.com/cooperspencer/gickup/metrics/heartbeat" "github.com/cooperspencer/gickup/metrics/ntfy" "github.com/cooperspencer/gickup/metrics/prometheus" "github.com/cooperspencer/gickup/onedev" "github.com/cooperspencer/gickup/opengist" "github.com/cooperspencer/gickup/radicle" "github.com/cooperspencer/gickup/s3" "github.com/cooperspencer/gickup/sourcehut" "github.com/cooperspencer/gickup/types" "github.com/cooperspencer/gickup/webdav" "github.com/cooperspencer/gickup/whatever" "github.com/cooperspencer/gickup/zip" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/goccy/go-yaml" "github.com/google/go-cmp/cmp" "github.com/minio/minio-go/v7" "github.com/robfig/cron/v3" "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) var cli struct { Configfiles []string `arg name:"conf" help:"Path to the configfile." default:"conf.yml"` Version bool `flag name:"version" help:"Show version."` Dry bool `flag name:"dryrun" help:"Make a dry-run."` Debug bool `flag name:"debug" help:"Output debug messages"` Quiet bool `flag name:"quiet" help:"Output only warnings, errors, and fatal messages to stderr log output"` Silent bool `flag name:"silent" help:"Suppress all stderr log output"` } var version = "unknown" func readConfigFile(configfile string) []*types.Conf { conf := []*types.Conf{} cfgdata, err := os.Open(filepath.Clean(configfile)) if err != nil { log.Fatal(). Str("stage", "readconfig"). Str("file", configfile). Msgf("Cannot open config file from %s", types.Red(configfile)) } defer cfgdata.Close() dec := yaml.NewDecoder(cfgdata) i := 0 for { var c types.Conf err = dec.Decode(&c) if errors.Is(err, io.EOF) { break } else if err != nil { if len(conf) > 0 { log.Fatal(). Str("stage", "readconfig"). Str("file", configfile). Msgf("an error occurred in the %d place of %s", i, configfile) } else { log.Fatal(). Str("stage", "readconfig"). Str("file", configfile). Msg(err.Error()) } } if reflect.ValueOf(c).IsZero() { continue } expandConfigPaths(&c) if !reflect.ValueOf(c).IsZero() { if len(conf) > 0 { if len(c.Metrics.PushConfigs.Gotify) == 0 && len(c.Metrics.PushConfigs.Ntfy) == 0 && len(c.Metrics.PushConfigs.Apprise) == 0 { c.Metrics.PushConfigs = conf[0].Metrics.PushConfigs } } conf = append(conf, &c) i++ } } return conf } func expandConfigPaths(c *types.Conf) { c.Log.FileLogging.Dir = substituteHomeForTildeInPath(c.Log.FileLogging.Dir) expandGenRepoPaths(c.Source.Gogs) expandGenRepoPaths(c.Source.Gitlab) expandGenRepoPaths(c.Source.Github) expandGenRepoPaths(c.Source.Gitea) expandGenRepoPaths(c.Source.BitBucket) expandGenRepoPaths(c.Source.OneDev) expandGenRepoPaths(c.Source.Sourcehut) expandGenRepoPaths(c.Source.Opengist) expandGenRepoPaths(c.Source.Any) expandGenRepoPaths(c.Destination.Gitlab) expandGenRepoPaths(c.Destination.Github) expandGenRepoPaths(c.Destination.Gitea) expandGenRepoPaths(c.Destination.Gogs) expandGenRepoPaths(c.Destination.OneDev) expandGenRepoPaths(c.Destination.Sourcehut) for i, local := range c.Destination.Local { c.Destination.Local[i].Path = substituteHomeForTildeInPath(local.Path) } } func expandGenRepoPaths(repos []types.GenRepo) { for i := range repos { repos[i].TokenFile = substituteHomeForTildeInPath(repos[i].TokenFile) repos[i].SSHKey = substituteHomeForTildeInPath(repos[i].SSHKey) repos[i].AppPrivateKeyFile = substituteHomeForTildeInPath(repos[i].AppPrivateKeyFile) } } func getUserHome() (string, error) { usr, err := user.Current() if err != nil { return "", err } return usr.HomeDir, nil } func substituteHomeForTildeInPath(path string) string { if !strings.HasPrefix(path, "~") { return path } if path == "~" { userHome, err := getUserHome() if err != nil { log.Fatal(). Str("stage", "local ~ substitution"). Str("path", path). Msg(err.Error()) } else { return userHome } } if strings.HasPrefix(path, "~/") { userHome, err := getUserHome() if err != nil { log.Fatal(). Str("stage", "local ~/ substitution"). Str("path", path). Msg(err.Error()) } else { return filepath.Join(userHome, path[2:]) } } // in whatever other strange case return path } func backup(repos []types.Repo, conf *types.Conf) { checkedpath := false currentDateDir := time.Now().Format("2006-01-02") + "/" for _, r := range repos { log.Info(). Str("stage", "backup"). Msgf("starting backup for %s", r.URL) if conf.Destination.Count() == 0 { log.Warn().Str("stage", "backup").Msg("No destinations configured!") } for _, d := range conf.Destination.Local { if !checkedpath { _, err := filepath.Abs(d.Path) if err != nil { log.Fatal(). Str("stage", "locally"). Str("path", d.Path). Msg(err.Error()) } checkedpath = true } repotime := time.Now() status := 0 if local.Locally(r, d, cli.Dry) { prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "local", d.Path).Set(time.Since(repotime).Seconds()) status = 1 } prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "local", d.Path).Set(float64(status)) prometheus.DestinationBackupsComplete.WithLabelValues("local").Inc() } for _, d := range conf.Destination.S3 { repotime := time.Now() status := 0 logOp := "pushing" if d.Zip { logOp = "zipping and pushing" } log.Info(). Str("stage", "s3"). Str("url", d.Endpoint). Msgf("%s %s to %s", logOp, types.Blue(r.Name), d.Bucket) if !cli.Dry { tempname := fmt.Sprintf("s3-%x", repotime) tempdir, err := os.MkdirTemp(os.TempDir(), tempname) if err != nil { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Msg(err.Error()) continue } if d.Structured { r.Name = path.Join(r.Hoster, r.Owner, r.Name) } if d.DateCreateDir { r.Name = currentDateDir + r.Name } defer os.RemoveAll(tempdir) r.Origin.LFS = d.LFS tempClonePath := path.Join(tempdir, r.Name) _, err = local.TempCloneBare(r, tempClonePath) if err != nil { switch { case errors.Is(err, git.NoErrAlreadyUpToDate): log.Info(). Str("stage", "s3"). Str("url", r.URL). Msg(err.Error()) case errors.Is(err, transport.ErrEmptyRemoteRepository): log.Warn(). Str("repo", r.Name). Msgf("%s - Skipping backup", err.Error()) continue default: log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Str("git", "clone"). Msg(err.Error()) os.RemoveAll(tempdir) continue } } if d.UseStaticCreds { // Check if environment variables are used for accesskey and secretkey d.AccessKey, err = d.GetKey(d.AccessKey) if err != nil { log.Error().Str("stage", "s3").Str("endpoint", d.Endpoint).Str("bucket", d.Bucket).Msg(err.Error()) } d.SecretKey, err = d.GetKey(d.SecretKey) if err != nil { log.Error().Str("stage", "s3").Str("endpoint", d.Endpoint).Str("bucket", d.Bucket).Msg(err.Error()) } if d.Token != "" { d.Token, err = d.GetKey(d.Token) if err != nil { log.Error().Str("stage", "s3").Str("endpoint", d.Endpoint).Str("bucket", d.Bucket).Msg(err.Error()) } } } if d.Zip { log.Info(). Msgf("zipping %s", types.Green(r.Name)) err := zip.Zip(tempClonePath, []string{tempClonePath}) if err != nil { log.Error(). Str("stage", "zip"). Str("url", r.URL). Str("repo", r.Name). Msg(err.Error()) log.Error().Msgf("Skipping backup of %s due to error while zipping", r.Name) continue } } s3opts := &minio.PutObjectOptions{ StorageClass: d.StorageClass, } if d.SrcRepoUrlTagKey != nil { s3opts.UserTags = map[string]string{ *d.SrcRepoUrlTagKey: r.URL, } } err = s3.UploadDirToS3(tempdir, d, s3opts) if err != nil { log.Error().Str("stage", "s3").Str("endpoint", d.Endpoint).Str("bucket", d.Bucket).Msg(err.Error()) } err = s3.DeleteObjectsNotInRepo(tempdir, r.Name, d) if err != nil { log.Error().Str("stage", "s3").Str("endpoint", d.Endpoint).Str("bucket", d.Bucket).Msg(err.Error()) } prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "s3", d.Endpoint).Set(time.Since(repotime).Seconds()) status = 1 prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "s3", d.Endpoint).Set(float64(status)) prometheus.DestinationBackupsComplete.WithLabelValues("s3").Inc() } } for _, d := range conf.Destination.AzureBlob { repotime := time.Now() status := 0 azureblobclient, err := azureblob.NewAzureBlobClient(d) if err != nil { log.Error(). Str("stage", "init"). Msg(err.Error()) continue } logOp := "uploading" if d.Zip { logOp = "zipping and uploading" } log.Info(). Str("stage", "azureblob"). Msgf("%s %s to blob container %s", logOp, types.Blue(r.Name), d.Container) if !cli.Dry { tempname := fmt.Sprintf("azureblob-%x", repotime) tempdir, err := os.MkdirTemp(os.TempDir(), tempname) if err != nil { log.Error(). Str("stage", "tempclone"). Msg(err.Error()) continue } if d.Structured { r.Name = path.Join(r.Hoster, r.Owner, r.Name) } if d.DateCreateDir { r.Name = currentDateDir + r.Name } defer os.RemoveAll(tempdir) tempClonePath := path.Join(tempdir, r.Name) _, err = local.TempCloneBare(r, tempClonePath) if err != nil { switch { case errors.Is(err, git.NoErrAlreadyUpToDate): log.Info(). Str("stage", "azureblob"). Msg(err.Error()) case errors.Is(err, transport.ErrEmptyRemoteRepository): log.Warn(). Str("repo", r.Name). Msgf("%s - Skipping backup", err.Error()) continue default: log.Error(). Str("stage", "tempclone"). Str("git", "clone"). Msg(err.Error()) os.RemoveAll(tempdir) continue } } if d.Zip { log.Info(). Msgf("zipping %s", types.Green(r.Name)) err := zip.Zip(tempClonePath, []string{tempClonePath}) if err != nil { log.Error(). Str("stage", "zip"). Str("repo", r.Name). Msg(err.Error()) log.Error().Msgf("Skipping backup of %s due to error while zipping", r.Name) continue } } err = azureblob.UploadDirToBlobStorage(tempdir, d, azureblobclient) if err != nil { log.Error().Str("stage", "azureblob").Str("container", d.Container).Msg(err.Error()) } err = azureblob.DeleteObjectsNotInRepo(tempdir, r.Name, d, azureblobclient) if err != nil { log.Error().Str("stage", "azureblob").Str("container", d.Container).Msg(err.Error()) } prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "azureblob", d.Container).Set(time.Since(repotime).Seconds()) status = 1 prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "azureblob", d.Container).Set(float64(status)) prometheus.DestinationBackupsComplete.WithLabelValues("azureblob").Inc() } } for _, d := range conf.Destination.WebDAV { repotime := time.Now() status := 0 logOp := "uploading" if d.Zip { logOp = "zipping and uploading" } log.Info(). Str("stage", "webdav"). Str("url", d.Url). Msgf("%s %s to %s", logOp, types.Blue(r.Name), d.Url) if !cli.Dry { tempname := fmt.Sprintf("webdav-%x", repotime) tempdir, err := os.MkdirTemp(os.TempDir(), tempname) if err != nil { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Msg(err.Error()) continue } if d.Structured { r.Name = path.Join(r.Hoster, r.Owner, r.Name) } if d.DateCreateDir { r.Name = currentDateDir + r.Name } defer os.RemoveAll(tempdir) tempClonePath := path.Join(tempdir, r.Name) _, err = local.TempCloneBare(r, tempClonePath) if err != nil { switch { case errors.Is(err, git.NoErrAlreadyUpToDate): log.Info(). Str("stage", "webdav"). Msg(err.Error()) case errors.Is(err, transport.ErrEmptyRemoteRepository): log.Warn(). Str("repo", r.Name). Msgf("%s - Skipping backup", err.Error()) continue default: log.Error(). Str("stage", "tempclone"). Str("git", "clone"). Msg(err.Error()) os.RemoveAll(tempdir) continue } } if d.Zip { log.Info(). Msgf("zipping %s", types.Green(r.Name)) err := zip.Zip(tempClonePath, []string{tempClonePath}) if err != nil { log.Error(). Str("stage", "zip"). Str("repo", r.Name). Msg(err.Error()) log.Error().Msgf("Skipping backup of %s due to error while zipping", r.Name) continue } } err = webdav.UploadDirToWebDAV(tempdir, d) if err != nil { log.Error().Str("stage", "webdav").Str("url", d.Url).Msg(err.Error()) } err = webdav.DeleteObjectsNotInRepo(tempdir, r.Name, d) if err != nil { log.Error().Str("stage", "webdav").Str("url", d.Url).Msg(err.Error()) } prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "webdav", d.Url).Set(time.Since(repotime).Seconds()) status = 1 prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "webdav", d.Url).Set(float64(status)) prometheus.DestinationBackupsComplete.WithLabelValues("webdav").Inc() } } for _, d := range conf.Destination.Gitea { if d.MirrorInterval != "" { log.Warn(). Str("stage", "gitea"). Str("url", d.URL). Msg("mirrorinterval is deprecated and will be removed in one of the next releases. please move it under the mirror parameter.") } if !strings.HasSuffix(r.Name, ".wiki") { repotime := time.Now() status := 0 if d.Mirror.Enabled { log.Info(). Str("stage", "gitea"). Str("url", d.URL). Msgf("mirroring %s to %s", types.Blue(r.Name), d.URL) if !cli.Dry { tempdir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("gitea-%x", repotime)) if err != nil { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Msg(err.Error()) continue } defer os.RemoveAll(tempdir) temprepo, err := local.TempClone(r, tempdir) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). Str("stage", "gitea"). Str("url", r.URL). Msg(err.Error()) } else { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Str("git", "clone"). Msg(err.Error()) os.RemoveAll(tempdir) continue } } cloneurl, err := gitea.GetOrCreate(d, r) if err != nil { log.Error(). Str("stage", "gitea"). Str("url", r.URL). Msg(err.Error()) os.RemoveAll(tempdir) continue } err = local.CreateRemotePush(temprepo, d, cloneurl, d.GetToken(), r.Origin.LFS) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). Str("stage", "gitea"). Str("url", r.URL). Msg(err.Error()) } else { log.Error(). Str("stage", "gitea"). Str("url", r.URL). Str("git", "push"). Msg(err.Error()) os.RemoveAll(tempdir) continue } } prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "gitea", d.URL).Set(time.Since(repotime).Seconds()) status = 1 prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "gitea", d.URL).Set(float64(status)) } } else if gitea.Backup(r, d, cli.Dry) { prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "gitea", d.URL).Set(time.Since(repotime).Seconds()) status = 1 } prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "gitea", d.URL).Set(float64(status)) prometheus.DestinationBackupsComplete.WithLabelValues("gitea").Inc() } } for _, d := range conf.Destination.Gogs { if !strings.HasSuffix(r.Name, ".wiki") { repotime := time.Now() status := 0 if d.Mirror.Enabled { log.Info(). Str("stage", "gogs"). Str("url", d.URL). Msgf("mirroring %s to %s", types.Blue(r.Name), d.URL) if !cli.Dry { tempdir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("gogs-%x", repotime)) if err != nil { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Msg(err.Error()) continue } defer os.RemoveAll(tempdir) temprepo, err := local.TempClone(r, tempdir) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). Str("stage", "gogs"). Str("url", r.URL). Msg(err.Error()) } else { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Str("git", "clone"). Msg(err.Error()) os.RemoveAll(tempdir) continue } } cloneurl, err := gogs.GetOrCreate(d, r) if err != nil { log.Error(). Str("stage", "gogs"). Str("url", r.URL). Msg(err.Error()) os.RemoveAll(tempdir) continue } err = local.CreateRemotePush(temprepo, d, cloneurl, d.GetToken(), r.Origin.LFS) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). Str("stage", "gogs"). Str("url", r.URL). Msg(err.Error()) } else { log.Error(). Str("stage", "gogs"). Str("url", r.URL). Str("git", "push"). Msg(err.Error()) os.RemoveAll(tempdir) continue } } prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "gogs", d.URL).Set(time.Since(repotime).Seconds()) status = 1 prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "gogs", d.URL).Set(float64(status)) } } else if gogs.Backup(r, d, cli.Dry) { prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "gogs", d.URL).Set(time.Since(repotime).Seconds()) status = 1 } prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "gogs", d.URL).Set(float64(status)) prometheus.DestinationBackupsComplete.WithLabelValues("gogs").Inc() } } for _, d := range conf.Destination.Gitlab { if !strings.HasSuffix(r.Name, ".wiki") { if d.URL == "" { d.URL = "https://gitlab.com" } repotime := time.Now() status := 0 if d.Mirror.Enabled { log.Info(). Str("stage", "gitlab"). Str("url", d.URL). Msgf("mirroring %s to %s", types.Blue(r.Name), d.URL) if !cli.Dry { tempdir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("gitlab-%x", repotime)) if err != nil { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Msg(err.Error()) continue } defer os.RemoveAll(tempdir) temprepo, err := local.TempClone(r, tempdir) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). Str("stage", "gitlab"). Str("url", r.URL). Msg(err.Error()) } else { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Str("git", "clone"). Msg(err.Error()) os.RemoveAll(tempdir) continue } } cloneurl, err := gitlab.GetOrCreate(d, r) if err != nil { log.Error(). Str("stage", "gitlab"). Str("url", r.URL). Msg(err.Error()) os.RemoveAll(tempdir) continue } err = local.CreateRemotePush(temprepo, d, cloneurl, d.GetToken(), r.Origin.LFS) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). Str("stage", "gitlab"). Str("url", r.URL). Msg(err.Error()) } else { log.Error(). Str("stage", "gitlab"). Str("url", r.URL). Str("git", "push"). Msg(err.Error()) os.RemoveAll(tempdir) continue } } prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "gitlab", d.URL).Set(time.Since(repotime).Seconds()) status = 1 prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "gitlab", d.URL).Set(float64(status)) } } else if gitlab.Backup(r, d, cli.Dry) { prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "gitlab", d.URL).Set(time.Since(repotime).Seconds()) status = 1 } prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "gitlab", d.URL).Set(float64(status)) prometheus.DestinationBackupsComplete.WithLabelValues("gitlab").Inc() } } for _, d := range conf.Destination.Github { if !strings.HasSuffix(r.Name, ".wiki") { repotime := time.Now() status := 0 log.Info(). Str("stage", "github"). Str("url", "https://github.com"). Msgf("mirroring %s to %s", types.Blue(r.Name), "https://github.com") if !cli.Dry { tempdir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("github-%x", repotime)) if err != nil { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Msg(err.Error()) continue } defer os.RemoveAll(tempdir) temprepo, err := local.TempClone(r, tempdir) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). Str("stage", "github"). Str("url", r.URL). Msg(err.Error()) } else { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Str("git", "clone"). Msg(err.Error()) os.RemoveAll(tempdir) continue } } cloneurl, destinationtoken, err := github.GetOrCreate(d, r) if err != nil { log.Error(). Str("stage", "github"). Str("url", r.URL). Msg(err.Error()) os.RemoveAll(tempdir) continue } err = local.CreateRemotePush(temprepo, d, cloneurl, destinationtoken, r.Origin.LFS) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). Str("stage", "github"). Str("url", r.URL). Msg(err.Error()) } else { log.Error(). Str("stage", "github"). Str("url", r.URL). Str("git", "push"). Msg(err.Error()) os.RemoveAll(tempdir) continue } } prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "github", "https://github.com").Set(time.Since(repotime).Seconds()) status = 1 prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "github", "https://github.com").Set(float64(status)) prometheus.DestinationBackupsComplete.WithLabelValues("github").Inc() } } } for _, d := range conf.Destination.OneDev { if !strings.HasSuffix(r.Name, ".wiki") { repotime := time.Now() status := 0 if d.URL == "" { d.URL = "https://code.onedev.io/" } log.Info(). Str("stage", "onedev"). Str("url", d.URL). Msgf("mirroring %s to %s", types.Blue(r.Name), d.URL) if !cli.Dry { tempdir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("onedev-%x", repotime)) if err != nil { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Msg(err.Error()) continue } defer os.RemoveAll(tempdir) temprepo, err := local.TempClone(r, tempdir) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). Str("stage", "onedev"). Str("url", r.URL). Msg(err.Error()) } else { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Msg(err.Error()) os.RemoveAll(tempdir) continue } } cloneurl, err := onedev.GetOrCreate(d, r) if err != nil { log.Error(). Str("stage", "onedev"). Str("url", r.URL). Msg(err.Error()) os.RemoveAll(tempdir) continue } err = local.CreateRemotePush(temprepo, d, cloneurl, d.GetToken(), r.Origin.LFS) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). Str("stage", "onedev"). Str("url", r.URL). Msg(err.Error()) } else { log.Error(). Str("stage", "onedev"). Str("url", r.URL). Msg(err.Error()) os.RemoveAll(tempdir) continue } } prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "onedev", d.URL).Set(time.Since(repotime).Seconds()) status = 1 prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "onedev", d.URL).Set(float64(status)) prometheus.DestinationBackupsComplete.WithLabelValues("onedev").Inc() os.RemoveAll(tempdir) } } } for _, d := range conf.Destination.Sourcehut { if !strings.HasSuffix(r.Name, "-docs") { repotime := time.Now() status := 0 d.SSH = true if d.URL == "" { d.URL = "https://git.sr.ht" } log.Info(). Str("stage", "sourcehut"). Str("url", d.URL). Msgf("mirroring %s to %s", types.Blue(r.Name), d.URL) if !cli.Dry { tempdir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("sourcehut-%x", repotime)) if err != nil { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Msg(err.Error()) continue } defer os.RemoveAll(tempdir) temprepo, err := local.TempClone(r, tempdir) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). Str("stage", "sourcehut"). Str("url", r.URL). Msg(err.Error()) } else { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Msg(err.Error()) os.RemoveAll(tempdir) continue } } cloneurl, err := sourcehut.GetOrCreate(d, r) if err != nil { log.Error(). Str("stage", "sourcehut"). Str("url", r.URL). Msg(err.Error()) os.RemoveAll(tempdir) continue } err = local.CreateRemotePush(temprepo, d, cloneurl, d.GetToken(), r.Origin.LFS) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). Str("stage", "sourcehut"). Str("url", r.URL). Msg(err.Error()) } else { log.Error(). Str("stage", "sourcehut"). Str("url", r.URL). Msg(err.Error()) os.RemoveAll(tempdir) continue } } prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "sourcehut", d.URL).Set(time.Since(repotime).Seconds()) status = 1 prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "sourcehut", d.URL).Set(float64(status)) prometheus.DestinationBackupsComplete.WithLabelValues("sourcehut").Inc() os.RemoveAll(tempdir) } } } for _, d := range conf.Destination.Radicle { repotime := time.Now() status := 0 radhome, _ := radicle.Home() log.Info(). Str("stage", "radicle"). Str("home", radhome). Msgf("mirroring %s to %s", types.Blue(r.Name), radhome) if !cli.Dry { func() { tempdir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("radicle-%x", repotime)) if err != nil { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Msg(err.Error()) return } defer os.RemoveAll(tempdir) _, err = local.TempClone(r, tempdir) if err != nil { if errors.Is(err, git.NoErrAlreadyUpToDate) { log.Info(). Str("stage", "radicle"). Str("url", r.URL). Msg(err.Error()) } else { log.Error(). Str("stage", "tempclone"). Str("url", r.URL). Str("git", "clone"). Msg(err.Error()) return } } rid, err := radicle.Mirror(r, d, tempdir) if err != nil { log.Error(). Str("stage", "radicle"). Str("url", r.URL). Msg(err.Error()) return } log.Info(). Str("stage", "radicle"). Str("rid", fmt.Sprintf("rad:%s", rid)). Msgf("mirrored %s", types.Green(r.Name)) prometheus.RepoTime.WithLabelValues(r.Hoster, r.Name, r.Owner, "radicle", radhome).Set(time.Since(repotime).Seconds()) status = 1 }() prometheus.RepoSuccess.WithLabelValues(r.Hoster, r.Name, r.Owner, "radicle", radhome).Set(float64(status)) prometheus.DestinationBackupsComplete.WithLabelValues("radicle").Inc() } } prometheus.SourceBackupsComplete.WithLabelValues(r.Name).Inc() } } func runBackup(conf *types.Conf, num int) { log.Info().Msg("Backup run starting") numstring := strconv.Itoa(num) startTime := time.Now() prometheus.JobsStarted.Inc() httpclient.ApplyGickupUserAgent() // Github repos, ran := github.Get(conf) if ran { prometheus.CountReposDiscovered.WithLabelValues("github", numstring).Set(float64(len(repos))) } backup(repos, conf) // Gitea repos, ran = gitea.Get(conf) if ran { prometheus.CountReposDiscovered.WithLabelValues("gitea", numstring).Set(float64(len(repos))) } backup(repos, conf) // Gogs repos, ran = gogs.Get(conf) if ran { prometheus.CountReposDiscovered.WithLabelValues("gogs", numstring).Set(float64(len(repos))) } backup(repos, conf) // Gitlab repos, ran = gitlab.Get(conf) if ran { prometheus.CountReposDiscovered.WithLabelValues("gitlab", numstring).Set(float64(len(repos))) } backup(repos, conf) repos, ran = bitbucket.Get(conf) if ran { prometheus.CountReposDiscovered.WithLabelValues("bitbucket", numstring).Set(float64(len(repos))) } backup(repos, conf) repos, ran = whatever.Get(conf) if ran { prometheus.CountReposDiscovered.WithLabelValues("whatever", numstring).Set(float64(len(repos))) } backup(repos, conf) repos, ran = onedev.Get(conf) if ran { prometheus.CountReposDiscovered.WithLabelValues("onedev", numstring).Set(float64(len(repos))) } backup(repos, conf) repos, ran = sourcehut.Get(conf) if ran { prometheus.CountReposDiscovered.WithLabelValues("sourcehut", numstring).Set(float64(len(repos))) } backup(repos, conf) repos, ran = opengist.Get(conf) if ran { prometheus.CountReposDiscovered.WithLabelValues("opengist", numstring).Set(float64(len(repos))) } backup(repos, conf) endTime := time.Now() duration := endTime.Sub(startTime) prometheus.JobsComplete.Inc() prometheus.JobDuration.Observe(duration.Seconds()) exitCode := logger.GetExitCode() success := exitCode == 0 title := "Backup done" msg := fmt.Sprintf("backup took %v", duration) if !success { title = "Backup failed" msg = fmt.Sprintf("backup took %v, check the logs for errors", duration) } if len(conf.Metrics.Heartbeat.URLs) > 0 || len(conf.Metrics.Heartbeat.FailureURLs) > 0 { heartbeat.Send(conf.Metrics.Heartbeat, success) } if len(conf.Metrics.PushConfigs.Ntfy) > 0 { for _, pusher := range conf.Metrics.PushConfigs.Ntfy { pusher.ResolveToken() err := ntfy.Notify(title, msg, *pusher) if err != nil { log.Warn().Str("push", "ntfy").Err(err).Msg("couldn't send message") } } } if len(conf.Metrics.PushConfigs.Gotify) > 0 { for _, pusher := range conf.Metrics.PushConfigs.Gotify { pusher.ResolveToken() err := gotify.Notify(title, msg, *pusher) if err != nil { log.Warn().Str("push", "gotify").Err(err).Msg("couldn't send message") } } } if len(conf.Metrics.PushConfigs.Apprise) > 0 { for _, pusher := range conf.Metrics.PushConfigs.Apprise { err := apprise.Notify(title, msg, *pusher) if err != nil { log.Warn().Str("push", "apprise").Err(err).Msg("couldn't send message") } } } if !success { log.Warn().Msgf("Encountered at least one error during the run. Check the logs. Exiting with status=%d", exitCode) } log.Info(). Str("duration", duration.String()). Msg("Backup run complete") if conf.HasValidCronSpec() { logNextRun(conf) } } func playsForever(c *cron.Cron, conffiles []string, confs []*types.Conf) bool { for { checkconfigs := []*types.Conf{} for _, f := range conffiles { checkconfigs = append(checkconfigs, readConfigFile(f)...) } if checkconfigs[0].HasValidCronSpec() { for num, config := range checkconfigs { if !config.HasValidCronSpec() { checkconfigs[num].Cron = checkconfigs[0].Cron } } } if !cmp.Equal(confs, checkconfigs) { log.Info().Msg("config changed") log.Debug().Msg(cmp.Diff(confs, checkconfigs)) for _, entry := range c.Entries() { c.Remove(entry.ID) } return true } time.Sleep(5 * time.Second) } } func main() { timeformat := "2006-01-02T15:04:05Z07:00" if len(os.Getenv("GICKUP_TIME_FORMAT")) > 0 { timeformat = os.Getenv("GICKUP_TIME_FORMAT") } log.Logger = log.Output(zerolog.ConsoleWriter{ Out: os.Stderr, TimeFormat: timeformat, }) kong.Parse(&cli, kong.Name("gickup"), kong.Description("a tool to backup all your favorite repos")) if cli.Version { fmt.Println(version) return } zerolog.SetGlobalLevel(zerolog.InfoLevel) if cli.Quiet { zerolog.SetGlobalLevel(zerolog.WarnLevel) } if cli.Debug { zerolog.SetGlobalLevel(zerolog.DebugLevel) } if cli.Silent { zerolog.SetGlobalLevel(zerolog.Disabled) } if cli.Dry { log.Info(). Str("dry", "true"). Msgf("this is a %s", types.Blue("dry run")) } init := true for { reload := false confs := []*types.Conf{} for i, f := range cli.Configfiles { log.Info().Str("file", f). Msgf("Reading %s", types.Green(f)) absf, err := filepath.Abs(f) if err != nil { log.Panic().Err(err).Msgf("there is an issue with %s", f) } cli.Configfiles[i] = absf confs = append(confs, readConfigFile(absf)...) } logConf := confs[0].Log if logConf.Timeformat == "" { logConf.Timeformat = timeformat } log.Logger = logger.CreateLogger(logConf) validcron := confs[0].HasValidCronSpec() var c *cron.Cron if validcron { c = cron.New() c.Start() } sourcecount := 0 destinationcount := 0 // one pair per source-destination for num, conf := range confs { pairs := conf.Source.Count() * conf.Destination.Count() sourcecount += conf.Source.Count() destinationcount += conf.Destination.Count() log.Info(). Int("sources", conf.Source.Count()). Int("destinations", conf.Destination.Count()). Int("pairs", pairs). Msg("Configuration loaded") if !conf.HasValidCronSpec() { conf.Cron = confs[0].Cron } if conf.HasValidCronSpec() && validcron { conf := conf // https://stackoverflow.com/questions/57095167/how-do-i-create-multiple-cron-function-by-looping-through-a-list num := num logNextRun(conf) _, err := c.AddFunc(conf.Cron, func() { runBackup(conf, num) }) if err != nil { log.Fatal(). Int("sources", conf.Source.Count()). Int("destinations", conf.Destination.Count()). Int("pairs", pairs). Msg(err.Error()) } } else { runBackup(conf, num) } } if validcron { if confs[0].HasAllPrometheusConf() { prometheus.CountSourcesConfigured.Add(float64(sourcecount)) prometheus.CountDestinationsConfigured.Add(float64(destinationcount)) if init { go prometheus.Serve(confs[0].Metrics.Prometheus) init = false } } reload = playsForever(c, cli.Configfiles, confs) log.Info().Msg("reloading config...") } if !reload { break } } os.Exit(int(logger.GetExitCode())) } func logNextRun(conf *types.Conf) { nextRun, err := conf.GetNextRun() if err == nil { log.Info(). Str("next", nextRun.String()). Str("cron", conf.Cron). Msg("Next cron run") } }