package recipe import ( "fmt" "os" "path" "strings" "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/formatter" gitPkg "coopcloud.tech/abra/pkg/git" "coopcloud.tech/abra/pkg/log" "github.com/distribution/reference" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" ) // Ensure makes sure the recipe exists, is up to date and has the latest version checked out. func (r Recipe) Ensure(chaos bool, offline bool) error { if err := r.EnsureExists(); err != nil { return err } if !chaos { if err := r.EnsureIsClean(); err != nil { return err } if !offline { if err := r.EnsureUpToDate(); err != nil { log.Fatal(err) } } if err := r.EnsureLatest(); err != nil { return err } } return nil } // EnsureExists ensures that the recipe is locally cloned func (r Recipe) EnsureExists() error { if _, err := os.Stat(r.Dir); os.IsNotExist(err) { log.Debugf("%s does not exist, attemmpting to clone", r.Dir) if err := gitPkg.Clone(r.Dir, r.GitURL); err != nil { return err } } if err := gitPkg.EnsureGitRepo(r.Dir); err != nil { return err } return nil } // EnsureVersion checks whether a specific version exists for a recipe. func (r Recipe) EnsureVersion(version string) (bool, error) { isChaosCommit := false if err := gitPkg.EnsureGitRepo(r.Dir); err != nil { return isChaosCommit, err } repo, err := git.PlainOpen(r.Dir) if err != nil { return isChaosCommit, err } tags, err := repo.Tags() if err != nil { return isChaosCommit, err } var parsedTags []string var tagRef plumbing.ReferenceName if err := tags.ForEach(func(ref *plumbing.Reference) (err error) { parsedTags = append(parsedTags, ref.Name().Short()) if ref.Name().Short() == version { tagRef = ref.Name() } return nil }); err != nil { return isChaosCommit, err } joinedTags := strings.Join(parsedTags, ", ") if joinedTags != "" { log.Debugf("read %s as tags for recipe %s", joinedTags, r.Name) } var opts *git.CheckoutOptions if tagRef.String() == "" { log.Debugf("attempting to checkout '%s' as chaos commit", version) hash, err := repo.ResolveRevision(plumbing.Revision(version)) if err != nil { log.Fatalf("unable to resolve '%s': %s", version, err) } opts = &git.CheckoutOptions{Hash: *hash, Create: false, Force: true} isChaosCommit = true } else { opts = &git.CheckoutOptions{Branch: tagRef, Create: false, Force: true} } worktree, err := repo.Worktree() if err != nil { return isChaosCommit, nil } if err := worktree.Checkout(opts); err != nil { return isChaosCommit, nil } log.Debugf("successfully checked %s out to %s in %s", r.Name, tagRef.Short(), r.Dir) return isChaosCommit, nil } // EnsureIsClean makes sure that the recipe repository has no unstaged changes. func (r Recipe) EnsureIsClean() error { isClean, err := gitPkg.IsClean(r.Dir) if err != nil { return fmt.Errorf("unable to check git clean status in %s: %s", r.Dir, err) } if !isClean { msg := "%s (%s) has locally unstaged changes? please commit/remove your changes before proceeding" return fmt.Errorf(msg, r.Name, r.Dir) } return nil } // EnsureLatest makes sure the latest commit is checked out for the local recipe repository func (r Recipe) EnsureLatest() error { recipeDir := path.Join(config.RECIPES_DIR, r.Name) if err := gitPkg.EnsureGitRepo(recipeDir); err != nil { return err } repo, err := git.PlainOpen(recipeDir) if err != nil { return err } worktree, err := repo.Worktree() if err != nil { return err } branch, err := gitPkg.GetDefaultBranch(repo, recipeDir) if err != nil { return err } checkOutOpts := &git.CheckoutOptions{ Create: false, Force: true, Branch: plumbing.ReferenceName(branch), } if err := worktree.Checkout(checkOutOpts); err != nil { log.Debugf("failed to check out %s in %s", branch, recipeDir) return err } return nil } // EnsureUpToDate ensures that the local repo is synced to the remote func (r Recipe) EnsureUpToDate() error { recipeDir := path.Join(config.RECIPES_DIR, r.Name) repo, err := git.PlainOpen(recipeDir) if err != nil { return fmt.Errorf("unable to open %s: %s", recipeDir, err) } remotes, err := repo.Remotes() if err != nil { return fmt.Errorf("unable to read remotes in %s: %s", recipeDir, err) } if len(remotes) == 0 { log.Debugf("cannot ensure %s is up-to-date, no git remotes configured", r.Name) return nil } worktree, err := repo.Worktree() if err != nil { return fmt.Errorf("unable to open git work tree in %s: %s", recipeDir, err) } branch, err := gitPkg.CheckoutDefaultBranch(repo, recipeDir) if err != nil { return fmt.Errorf("unable to check out default branch in %s: %s", recipeDir, err) } fetchOpts := &git.FetchOptions{Tags: git.AllTags} if err := repo.Fetch(fetchOpts); err != nil { if !strings.Contains(err.Error(), "already up-to-date") { return fmt.Errorf("unable to fetch tags in %s: %s", recipeDir, err) } } opts := &git.PullOptions{ Force: true, ReferenceName: branch, SingleBranch: true, } if err := worktree.Pull(opts); err != nil { if !strings.Contains(err.Error(), "already up-to-date") { return fmt.Errorf("unable to git pull in %s: %s", recipeDir, err) } } log.Debugf("fetched latest git changes for %s", r.Name) return nil } // ChaosVersion constructs a chaos mode recipe version. func (r Recipe) ChaosVersion() (string, error) { var version string head, err := gitPkg.GetRecipeHead(r.Name) if err != nil { return version, err } version = formatter.SmallSHA(head.String()) recipeDir := path.Join(config.RECIPES_DIR, r.Name) isClean, err := gitPkg.IsClean(recipeDir) if err != nil { return version, err } if !isClean { version = fmt.Sprintf("%s + unstaged changes", version) } return version, nil } // Push pushes the latest changes to a SSH URL remote. You need to have your // local SSH configuration for git.coopcloud.tech working for this to work func (r Recipe) Push(dryRun bool) error { repo, err := git.PlainOpen(r.Dir) if err != nil { return err } if err := gitPkg.CreateRemote(repo, "origin-ssh", r.SSHURL, dryRun); err != nil { return err } if err := gitPkg.Push(r.Dir, "origin-ssh", true, dryRun); err != nil { return err } return nil } // Tags list the recipe tags func (r Recipe) Tags() ([]string, error) { var tags []string repo, err := git.PlainOpen(r.Dir) if err != nil { return tags, err } gitTags, err := repo.Tags() if err != nil { return tags, err } if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) { tags = append(tags, strings.TrimPrefix(string(ref.Name()), "refs/tags/")) return nil }); err != nil { return tags, err } log.Debugf("detected %s as tags for recipe %s", strings.Join(tags, ", "), r.Name) return tags, nil } // GetRecipeVersions retrieves all recipe versions. func (r Recipe) GetRecipeVersions() (RecipeVersions, error) { versions := RecipeVersions{} log.Debugf("attempting to open git repository in %s", r.Dir) repo, err := git.PlainOpen(r.Dir) if err != nil { return versions, err } worktree, err := repo.Worktree() if err != nil { return versions, err } gitTags, err := repo.Tags() if err != nil { return versions, err } if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) { tag := strings.TrimPrefix(string(ref.Name()), "refs/tags/") log.Debugf("processing %s for %s", tag, r.Name) checkOutOpts := &git.CheckoutOptions{ Create: false, Force: true, Branch: plumbing.ReferenceName(ref.Name()), } if err := worktree.Checkout(checkOutOpts); err != nil { log.Debugf("failed to check out %s in %s", tag, r.Dir) return err } log.Debugf("successfully checked out %s in %s", ref.Name(), r.Dir) config, err := r.GetComposeConfig(nil) if err != nil { return err } versionMeta := make(map[string]ServiceMeta) for _, service := range config.Services { img, err := reference.ParseNormalizedNamed(service.Image) if err != nil { return err } path := reference.Path(img) path = formatter.StripTagMeta(path) var tag string switch img.(type) { case reference.NamedTagged: tag = img.(reference.NamedTagged).Tag() case reference.Named: log.Warnf("%s service is missing image tag?", path) continue } versionMeta[service.Name] = ServiceMeta{ Image: path, Tag: tag, } } versions = append(versions, map[string]map[string]ServiceMeta{tag: versionMeta}) return nil }); err != nil { return versions, err } _, err = gitPkg.CheckoutDefaultBranch(repo, r.Dir) if err != nil { return versions, err } sortRecipeVersions(versions) log.Debugf("collected %s for %s", versions, r.Dir) return versions, nil }