forked from toolshed/abra
		
	
		
			
				
	
	
		
			484 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			484 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package recipe
 | |
| 
 | |
| import (
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"os"
 | |
| 	"slices"
 | |
| 	"sort"
 | |
| 	"strings"
 | |
| 
 | |
| 	"coopcloud.tech/abra/pkg/config"
 | |
| 	"coopcloud.tech/abra/pkg/formatter"
 | |
| 	gitPkg "coopcloud.tech/abra/pkg/git"
 | |
| 	"coopcloud.tech/abra/pkg/i18n"
 | |
| 	"coopcloud.tech/abra/pkg/log"
 | |
| 	"coopcloud.tech/tagcmp"
 | |
| 	"github.com/distribution/reference"
 | |
| 	"github.com/go-git/go-git/v5"
 | |
| 	"github.com/go-git/go-git/v5/plumbing"
 | |
| )
 | |
| 
 | |
| type EnsureContext struct {
 | |
| 	Chaos            bool
 | |
| 	Offline          bool
 | |
| 	IgnoreEnvVersion bool
 | |
| }
 | |
| 
 | |
| // Ensure makes sure the recipe exists, is up to date and has the specific
 | |
| // version checked out.
 | |
| func (r Recipe) Ensure(ctx EnsureContext) error {
 | |
| 	if err := r.EnsureExists(); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	if ctx.Chaos {
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	if err := r.EnsureIsClean(); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	if !ctx.Offline {
 | |
| 		if err := r.EnsureUpToDate(); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if r.EnvVersion != "" && !ctx.IgnoreEnvVersion {
 | |
| 		log.Debug(i18n.G("ensuring env version %s", r.EnvVersion))
 | |
| 		if strings.Contains(r.EnvVersion, "+U") {
 | |
| 			return errors.New(i18n.G(`cannot redeploy previous chaos version (%s), did you mean to use "--chaos"?`))
 | |
| 		}
 | |
| 
 | |
| 		if _, err := r.EnsureVersion(r.EnvVersion); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	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) {
 | |
| 		if err := gitPkg.Clone(r.Dir, r.GitURL); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // IsChaosCommit determines if a version sttring is a chaos commit or not.
 | |
| func (r Recipe) IsChaosCommit(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 tagRef plumbing.ReferenceName
 | |
| 	if err := tags.ForEach(func(ref *plumbing.Reference) (err error) {
 | |
| 		if ref.Name().Short() == version {
 | |
| 			tagRef = ref.Name()
 | |
| 		}
 | |
| 		return nil
 | |
| 	}); err != nil {
 | |
| 		return isChaosCommit, err
 | |
| 	}
 | |
| 
 | |
| 	if tagRef.String() == "" {
 | |
| 		isChaosCommit = true
 | |
| 	}
 | |
| 
 | |
| 	return isChaosCommit, 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.Debug(i18n.G("read %s as tags for recipe %s", joinedTags, r.Name))
 | |
| 	}
 | |
| 
 | |
| 	var opts *git.CheckoutOptions
 | |
| 	if tagRef.String() == "" {
 | |
| 		log.Debug(i18n.G("attempting to checkout '%s' as chaos commit", version))
 | |
| 
 | |
| 		hash, err := repo.ResolveRevision(plumbing.Revision(version))
 | |
| 		if err != nil {
 | |
| 			log.Fatal(i18n.G("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.Debug(i18n.G("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 errors.New(i18n.G("unable to check git clean status in %s: %s", r.Dir, err))
 | |
| 	}
 | |
| 
 | |
| 	if !isClean {
 | |
| 		return errors.New(i18n.G("%s (%s) has locally unstaged changes?", 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 {
 | |
| 	if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	repo, err := git.PlainOpen(r.Dir)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	worktree, err := repo.Worktree()
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	branch, err := gitPkg.GetDefaultBranch(repo, r.Dir)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	checkOutOpts := &git.CheckoutOptions{
 | |
| 		Create: false,
 | |
| 		Force:  true,
 | |
| 		Branch: plumbing.ReferenceName(branch),
 | |
| 	}
 | |
| 
 | |
| 	if err := worktree.Checkout(checkOutOpts); err != nil {
 | |
| 		log.Debug(i18n.G("failed to check out %s in %s", branch, r.Dir))
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // EnsureUpToDate ensures that the local repo is synced to the remote
 | |
| func (r Recipe) EnsureUpToDate() error {
 | |
| 	repo, err := git.PlainOpen(r.Dir)
 | |
| 	if err != nil {
 | |
| 		return errors.New(i18n.G("unable to open %s: %s", r.Dir, err))
 | |
| 	}
 | |
| 
 | |
| 	remotes, err := repo.Remotes()
 | |
| 	if err != nil {
 | |
| 		return errors.New(i18n.G("unable to read remotes in %s: %s", r.Dir, err))
 | |
| 	}
 | |
| 
 | |
| 	if len(remotes) == 0 {
 | |
| 		log.Debug(i18n.G("cannot ensure %s is up-to-date, no git remotes configured", r.Name))
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	worktree, err := repo.Worktree()
 | |
| 	if err != nil {
 | |
| 		return errors.New(i18n.G("unable to open git work tree in %s: %s", r.Dir, err))
 | |
| 	}
 | |
| 
 | |
| 	branch, err := gitPkg.CheckoutDefaultBranch(repo, r.Dir)
 | |
| 	if err != nil {
 | |
| 		return errors.New(i18n.G("unable to check out default branch in %s: %s", r.Dir, err))
 | |
| 	}
 | |
| 
 | |
| 	fetchOpts := &git.FetchOptions{Tags: git.AllTags}
 | |
| 	if err := repo.Fetch(fetchOpts); err != nil {
 | |
| 		if !strings.Contains(err.Error(), "already up-to-date") {
 | |
| 			return errors.New(i18n.G("unable to fetch tags in %s: %s", r.Dir, 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 errors.New(i18n.G("unable to git pull in %s: %s", r.Dir, err))
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	log.Debug(i18n.G("fetched latest git changes for %s", r.Name))
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // IsDirty checks whether a recipe is dirty or not.
 | |
| func (r *Recipe) IsDirty() (bool, error) {
 | |
| 	isClean, err := gitPkg.IsClean(r.Dir)
 | |
| 	if err != nil {
 | |
| 		return false, err
 | |
| 	}
 | |
| 
 | |
| 	return !isClean, nil
 | |
| }
 | |
| 
 | |
| // ChaosVersion constructs a chaos mode recipe version.
 | |
| func (r *Recipe) ChaosVersion() (string, error) {
 | |
| 	var version string
 | |
| 
 | |
| 	head, err := r.Head()
 | |
| 	if err != nil {
 | |
| 		return version, err
 | |
| 	}
 | |
| 
 | |
| 	version = formatter.SmallSHA(head.String())
 | |
| 
 | |
| 	dirty, err := r.IsDirty()
 | |
| 	if err != nil {
 | |
| 		return "", err
 | |
| 	}
 | |
| 	if dirty {
 | |
| 		return fmt.Sprintf("%s%s", version, config.DIRTY_DEFAULT), nil
 | |
| 	}
 | |
| 
 | |
| 	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
 | |
| 	}
 | |
| 
 | |
| 	sort.Slice(tags, func(i, j int) bool {
 | |
| 		version1, err := tagcmp.Parse(tags[i])
 | |
| 		if err != nil {
 | |
| 			return false
 | |
| 		}
 | |
| 		version2, err := tagcmp.Parse(tags[j])
 | |
| 		if err != nil {
 | |
| 			return false
 | |
| 		}
 | |
| 		return version1.IsLessThan(version2)
 | |
| 	})
 | |
| 
 | |
| 	log.Debug(i18n.G("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, []string, error) {
 | |
| 	var warnMsg []string
 | |
| 
 | |
| 	versions := RecipeVersions{}
 | |
| 
 | |
| 	log.Debug(i18n.G("git: opening repository in %s", r.Dir))
 | |
| 
 | |
| 	repo, err := git.PlainOpen(r.Dir)
 | |
| 	if err != nil {
 | |
| 		return versions, warnMsg, nil
 | |
| 	}
 | |
| 
 | |
| 	worktree, err := repo.Worktree()
 | |
| 	if err != nil {
 | |
| 		return versions, warnMsg, nil
 | |
| 	}
 | |
| 
 | |
| 	gitTags, err := repo.Tags()
 | |
| 	if err != nil {
 | |
| 		return versions, warnMsg, nil
 | |
| 	}
 | |
| 
 | |
| 	if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) {
 | |
| 		tag := strings.TrimPrefix(string(ref.Name()), "refs/tags/")
 | |
| 
 | |
| 		log.Debug(i18n.G("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.Debug(i18n.G("failed to check out %s in %s", tag, r.Dir))
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		log.Debug(i18n.G("git checkout: %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:
 | |
| 				warnMsg = append(warnMsg, i18n.G("%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, warnMsg, nil
 | |
| 	}
 | |
| 
 | |
| 	_, err = gitPkg.CheckoutDefaultBranch(repo, r.Dir)
 | |
| 	if err != nil {
 | |
| 		return versions, warnMsg, nil
 | |
| 	}
 | |
| 
 | |
| 	sortRecipeVersions(versions)
 | |
| 
 | |
| 	log.Debug(i18n.G("collected %s for %s", versions, r.Dir))
 | |
| 
 | |
| 	var uniqueWarnings []string
 | |
| 	for _, w := range warnMsg {
 | |
| 		if !slices.Contains(uniqueWarnings, w) {
 | |
| 			uniqueWarnings = append(uniqueWarnings, w)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return versions, uniqueWarnings, nil
 | |
| }
 | |
| 
 | |
| // Head retrieves latest HEAD metadata.
 | |
| func (r Recipe) Head() (*plumbing.Reference, error) {
 | |
| 	repo, err := git.PlainOpen(r.Dir)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	head, err := repo.Head()
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	return head, nil
 | |
| }
 |