forked from toolshed/abra
		
	
		
			
				
	
	
		
			461 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			461 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
package recipe
 | 
						|
 | 
						|
import (
 | 
						|
	"fmt"
 | 
						|
	"path"
 | 
						|
	"strconv"
 | 
						|
	"strings"
 | 
						|
 | 
						|
	"coopcloud.tech/abra/cli/internal"
 | 
						|
	"coopcloud.tech/abra/pkg/autocomplete"
 | 
						|
	"coopcloud.tech/abra/pkg/config"
 | 
						|
	"coopcloud.tech/abra/pkg/formatter"
 | 
						|
	gitPkg "coopcloud.tech/abra/pkg/git"
 | 
						|
	"coopcloud.tech/abra/pkg/recipe"
 | 
						|
	recipePkg "coopcloud.tech/abra/pkg/recipe"
 | 
						|
	"coopcloud.tech/abra/pkg/runtime"
 | 
						|
	"coopcloud.tech/tagcmp"
 | 
						|
	"github.com/AlecAivazis/survey/v2"
 | 
						|
	"github.com/docker/distribution/reference"
 | 
						|
	"github.com/go-git/go-git/v5"
 | 
						|
	"github.com/sirupsen/logrus"
 | 
						|
	"github.com/urfave/cli"
 | 
						|
)
 | 
						|
 | 
						|
var recipeReleaseCommand = cli.Command{
 | 
						|
	Name:      "release",
 | 
						|
	Aliases:   []string{"rl"},
 | 
						|
	Usage:     "Release a new recipe version",
 | 
						|
	ArgsUsage: "<recipe> [<version>]",
 | 
						|
	Description: `
 | 
						|
Create a new version of a recipe. These versions are then published on the
 | 
						|
Co-op Cloud recipe catalogue. These versions take the following form:
 | 
						|
 | 
						|
    a.b.c+x.y.z
 | 
						|
 | 
						|
Where the "a.b.c" part is a semantic version determined by the maintainer. The
 | 
						|
"x.y.z" part is the image tag of the recipe "app" service (the main container
 | 
						|
which contains the software to be used, by naming convention).
 | 
						|
 | 
						|
We maintain a semantic versioning scheme ("a.b.c") alongside the recipe
 | 
						|
versioning scheme ("x.y.z") in order to maximise the chances that the nature of
 | 
						|
recipe updates are properly communicated. I.e. developers of an app might
 | 
						|
publish a minor version but that might lead to changes in the recipe which are
 | 
						|
major and therefore require intervention while doing the upgrade work.
 | 
						|
 | 
						|
Publish your new release to git.coopcloud.tech with "-p/--publish". This
 | 
						|
requires that you have permission to git push to these repositories and have
 | 
						|
your SSH keys configured on your account.
 | 
						|
`,
 | 
						|
	Flags: []cli.Flag{
 | 
						|
		internal.DebugFlag,
 | 
						|
		internal.NoInputFlag,
 | 
						|
		internal.DryFlag,
 | 
						|
		internal.MajorFlag,
 | 
						|
		internal.MinorFlag,
 | 
						|
		internal.PatchFlag,
 | 
						|
		internal.PublishFlag,
 | 
						|
		internal.OfflineFlag,
 | 
						|
	},
 | 
						|
	Before:       internal.SubCommandBefore,
 | 
						|
	BashComplete: autocomplete.RecipeNameComplete,
 | 
						|
	Action: func(c *cli.Context) error {
 | 
						|
		conf := runtime.New(
 | 
						|
			runtime.WithOffline(internal.Offline),
 | 
						|
			runtime.WithEnsureRecipeUpToDate(false),
 | 
						|
		)
 | 
						|
 | 
						|
		recipe := internal.ValidateRecipeWithPrompt(c, conf)
 | 
						|
 | 
						|
		imagesTmp, err := getImageVersions(recipe)
 | 
						|
		if err != nil {
 | 
						|
			logrus.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		mainApp, err := internal.GetMainAppImage(recipe)
 | 
						|
		if err != nil {
 | 
						|
			logrus.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		mainAppVersion := imagesTmp[mainApp]
 | 
						|
		if mainAppVersion == "" {
 | 
						|
			logrus.Fatalf("main app service version for %s is empty?", recipe.Name)
 | 
						|
		}
 | 
						|
 | 
						|
		tagString := c.Args().Get(1)
 | 
						|
		if tagString != "" {
 | 
						|
			if _, err := tagcmp.Parse(tagString); err != nil {
 | 
						|
				logrus.Fatalf("cannot parse %s, invalid tag specified?", tagString)
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		if (internal.Major || internal.Minor || internal.Patch) && tagString != "" {
 | 
						|
			logrus.Fatal("cannot specify tag and bump type at the same time")
 | 
						|
		}
 | 
						|
 | 
						|
		if tagString != "" {
 | 
						|
			if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
 | 
						|
				logrus.Fatal(err)
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		tags, err := recipe.Tags()
 | 
						|
		if err != nil {
 | 
						|
			logrus.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		if tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
 | 
						|
			var err error
 | 
						|
			tagString, err = getLabelVersion(recipe, false)
 | 
						|
			if err != nil {
 | 
						|
				logrus.Fatal(err)
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		if len(tags) > 0 {
 | 
						|
			logrus.Warnf("previous git tags detected, assuming this is a new semver release")
 | 
						|
			if err := createReleaseFromPreviousTag(tagString, mainAppVersion, recipe, tags); err != nil {
 | 
						|
				logrus.Fatal(err)
 | 
						|
			}
 | 
						|
		} else {
 | 
						|
			logrus.Warnf("no tag specified and no previous tag available for %s, assuming this is the initial release", recipe.Name)
 | 
						|
 | 
						|
			if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
 | 
						|
				if cleanUpErr := cleanUpTag(tagString, recipe.Name); err != nil {
 | 
						|
					logrus.Fatal(cleanUpErr)
 | 
						|
				}
 | 
						|
				logrus.Fatal(err)
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		return nil
 | 
						|
	},
 | 
						|
}
 | 
						|
 | 
						|
// getImageVersions retrieves image versions for a recipe
 | 
						|
func getImageVersions(recipe recipe.Recipe) (map[string]string, error) {
 | 
						|
	var services = make(map[string]string)
 | 
						|
 | 
						|
	missingTag := false
 | 
						|
	for _, service := range recipe.Config.Services {
 | 
						|
		if service.Image == "" {
 | 
						|
			continue
 | 
						|
		}
 | 
						|
 | 
						|
		img, err := reference.ParseNormalizedNamed(service.Image)
 | 
						|
		if err != nil {
 | 
						|
			return services, 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:
 | 
						|
			if service.Name == "app" {
 | 
						|
				missingTag = true
 | 
						|
			}
 | 
						|
			continue
 | 
						|
		}
 | 
						|
 | 
						|
		services[path] = tag
 | 
						|
	}
 | 
						|
 | 
						|
	if missingTag {
 | 
						|
		return services, fmt.Errorf("app service is missing image tag?")
 | 
						|
	}
 | 
						|
 | 
						|
	return services, nil
 | 
						|
}
 | 
						|
 | 
						|
// createReleaseFromTag creates a new release based on a supplied recipe version string
 | 
						|
func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string) error {
 | 
						|
	var err error
 | 
						|
 | 
						|
	directory := path.Join(config.RECIPES_DIR, recipe.Name)
 | 
						|
	repo, err := git.PlainOpen(directory)
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	tag, err := tagcmp.Parse(tagString)
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	if tag.MissingMinor {
 | 
						|
		tag.Minor = "0"
 | 
						|
		tag.MissingMinor = false
 | 
						|
	}
 | 
						|
 | 
						|
	if tag.MissingPatch {
 | 
						|
		tag.Patch = "0"
 | 
						|
		tag.MissingPatch = false
 | 
						|
	}
 | 
						|
 | 
						|
	if tagString == "" {
 | 
						|
		tagString = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion)
 | 
						|
	}
 | 
						|
 | 
						|
	if err := commitRelease(recipe, tagString); err != nil {
 | 
						|
		logrus.Fatal(err)
 | 
						|
	}
 | 
						|
 | 
						|
	if err := tagRelease(tagString, repo); err != nil {
 | 
						|
		logrus.Fatal(err)
 | 
						|
	}
 | 
						|
 | 
						|
	if err := pushRelease(recipe, tagString); err != nil {
 | 
						|
		logrus.Fatal(err)
 | 
						|
	}
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
// btoi converts a boolean value into an integer
 | 
						|
func btoi(b bool) int {
 | 
						|
	if b {
 | 
						|
		return 1
 | 
						|
	}
 | 
						|
 | 
						|
	return 0
 | 
						|
}
 | 
						|
 | 
						|
// getTagCreateOptions constructs git tag create options
 | 
						|
func getTagCreateOptions(tag string) (git.CreateTagOptions, error) {
 | 
						|
	msg := fmt.Sprintf("chore: publish %s release", tag)
 | 
						|
	return git.CreateTagOptions{Message: msg}, nil
 | 
						|
}
 | 
						|
 | 
						|
func commitRelease(recipe recipe.Recipe, tag string) error {
 | 
						|
	if internal.Dry {
 | 
						|
		logrus.Debugf("dry run: no changes committed")
 | 
						|
		return nil
 | 
						|
	}
 | 
						|
 | 
						|
	isClean, err := gitPkg.IsClean(recipe.Dir())
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	if isClean {
 | 
						|
		if !internal.Dry {
 | 
						|
			return fmt.Errorf("no changes discovered in %s, nothing to publish?", recipe.Dir())
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	msg := fmt.Sprintf("chore: publish %s release", tag)
 | 
						|
	repoPath := path.Join(config.RECIPES_DIR, recipe.Name)
 | 
						|
	if err := gitPkg.Commit(repoPath, ".", msg, internal.Dry); err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func tagRelease(tagString string, repo *git.Repository) error {
 | 
						|
	if internal.Dry {
 | 
						|
		logrus.Debugf("dry run: no git tag created (%s)", tagString)
 | 
						|
		return nil
 | 
						|
	}
 | 
						|
 | 
						|
	head, err := repo.Head()
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	createTagOptions, err := getTagCreateOptions(tagString)
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	_, err = repo.CreateTag(tagString, head.Hash(), &createTagOptions)
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	hash := formatter.SmallSHA(head.Hash().String())
 | 
						|
	logrus.Debugf(fmt.Sprintf("created tag %s at %s", tagString, hash))
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func pushRelease(recipe recipe.Recipe, tagString string) error {
 | 
						|
	if internal.Dry {
 | 
						|
		logrus.Info("dry run: no changes published")
 | 
						|
		return nil
 | 
						|
	}
 | 
						|
 | 
						|
	if !internal.Publish && !internal.NoInput {
 | 
						|
		prompt := &survey.Confirm{
 | 
						|
			Message: "publish new release?",
 | 
						|
		}
 | 
						|
 | 
						|
		if err := survey.AskOne(prompt, &internal.Publish); err != nil {
 | 
						|
			return err
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	if internal.Publish {
 | 
						|
		if err := recipe.Push(internal.Dry); err != nil {
 | 
						|
			return err
 | 
						|
		}
 | 
						|
		url := fmt.Sprintf("%s/%s/src/tag/%s", config.REPOS_BASE_URL, recipe.Name, tagString)
 | 
						|
		logrus.Infof("new release published: %s", url)
 | 
						|
	} else {
 | 
						|
		logrus.Info("no -p/--publish passed, not publishing")
 | 
						|
	}
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recipe.Recipe, tags []string) error {
 | 
						|
	directory := path.Join(config.RECIPES_DIR, recipe.Name)
 | 
						|
	repo, err := git.PlainOpen(directory)
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
 | 
						|
	if bumpType != 0 {
 | 
						|
		if (bumpType & (bumpType - 1)) != 0 {
 | 
						|
			return fmt.Errorf("you can only use one of: --major, --minor, --patch")
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	var lastGitTag tagcmp.Tag
 | 
						|
	for _, tag := range tags {
 | 
						|
		parsed, err := tagcmp.Parse(tag)
 | 
						|
		if err != nil {
 | 
						|
			return err
 | 
						|
		}
 | 
						|
 | 
						|
		if (lastGitTag == tagcmp.Tag{}) {
 | 
						|
			lastGitTag = parsed
 | 
						|
		} else if parsed.IsGreaterThan(lastGitTag) {
 | 
						|
			lastGitTag = parsed
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	newTag := lastGitTag
 | 
						|
	if internal.Patch {
 | 
						|
		now, err := strconv.Atoi(newTag.Patch)
 | 
						|
		if err != nil {
 | 
						|
			return err
 | 
						|
		}
 | 
						|
 | 
						|
		newTag.Patch = strconv.Itoa(now + 1)
 | 
						|
	} else if internal.Minor {
 | 
						|
		now, err := strconv.Atoi(newTag.Minor)
 | 
						|
		if err != nil {
 | 
						|
			return err
 | 
						|
		}
 | 
						|
 | 
						|
		newTag.Patch = "0"
 | 
						|
		newTag.Minor = strconv.Itoa(now + 1)
 | 
						|
	} else if internal.Major {
 | 
						|
		now, err := strconv.Atoi(newTag.Major)
 | 
						|
		if err != nil {
 | 
						|
			return err
 | 
						|
		}
 | 
						|
 | 
						|
		newTag.Patch = "0"
 | 
						|
		newTag.Minor = "0"
 | 
						|
		newTag.Major = strconv.Itoa(now + 1)
 | 
						|
	}
 | 
						|
 | 
						|
	if tagString == "" {
 | 
						|
		if err := internal.PromptBumpType(tagString, lastGitTag.String()); err != nil {
 | 
						|
			return err
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	if internal.Major || internal.Minor || internal.Patch {
 | 
						|
		newTag.Metadata = mainAppVersion
 | 
						|
		tagString = newTag.String()
 | 
						|
	}
 | 
						|
 | 
						|
	if lastGitTag.String() == tagString {
 | 
						|
		logrus.Fatalf("latest git tag (%s) and synced label (%s) are the same?", lastGitTag, tagString)
 | 
						|
	}
 | 
						|
 | 
						|
	if !internal.NoInput {
 | 
						|
		prompt := &survey.Confirm{
 | 
						|
			Message: fmt.Sprintf("current: %s, new: %s, correct?", lastGitTag, tagString),
 | 
						|
		}
 | 
						|
 | 
						|
		var ok bool
 | 
						|
		if err := survey.AskOne(prompt, &ok); err != nil {
 | 
						|
			logrus.Fatal(err)
 | 
						|
		}
 | 
						|
 | 
						|
		if !ok {
 | 
						|
			logrus.Fatal("exiting as requested")
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	if err := commitRelease(recipe, tagString); err != nil {
 | 
						|
		logrus.Fatalf("failed to commit changes: %s", err.Error())
 | 
						|
	}
 | 
						|
 | 
						|
	if err := tagRelease(tagString, repo); err != nil {
 | 
						|
		logrus.Fatalf("failed to tag release: %s", err.Error())
 | 
						|
	}
 | 
						|
 | 
						|
	if err := pushRelease(recipe, tagString); err != nil {
 | 
						|
		logrus.Fatalf("failed to publish new release: %s", err.Error())
 | 
						|
	}
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
// cleanUpTag removes a freshly created tag
 | 
						|
func cleanUpTag(tag, recipeName string) error {
 | 
						|
	directory := path.Join(config.RECIPES_DIR, recipeName)
 | 
						|
	repo, err := git.PlainOpen(directory)
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	if err := repo.DeleteTag(tag); err != nil {
 | 
						|
		if !strings.Contains(err.Error(), "not found") {
 | 
						|
			return err
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	logrus.Debugf("removed freshly created tag %s", tag)
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func getLabelVersion(recipe recipe.Recipe, prompt bool) (string, error) {
 | 
						|
	initTag, err := recipePkg.GetVersionLabelLocal(recipe)
 | 
						|
	if err != nil {
 | 
						|
		return "", err
 | 
						|
	}
 | 
						|
 | 
						|
	if initTag == "" {
 | 
						|
		logrus.Fatalf("unable to read version for %s from synced label. Did you try running \"abra recipe sync %s\" already?", recipe.Name, recipe.Name)
 | 
						|
	}
 | 
						|
 | 
						|
	logrus.Warnf("discovered %s as currently synced recipe label", initTag)
 | 
						|
 | 
						|
	if prompt && !internal.NoInput {
 | 
						|
		var response bool
 | 
						|
		prompt := &survey.Confirm{Message: fmt.Sprintf("use %s as the new version?", initTag)}
 | 
						|
		if err := survey.AskOne(prompt, &response); err != nil {
 | 
						|
			return "", err
 | 
						|
		}
 | 
						|
 | 
						|
		if !response {
 | 
						|
			return "", fmt.Errorf("please fix your synced label for %s and re-run this command", recipe.Name)
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return initTag, nil
 | 
						|
}
 |