package recipe import ( "fmt" "path" "strconv" "strings" abraFormatter "coopcloud.tech/abra/cli/formatter" "coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/recipe" recipePkg "coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/tagcmp" "github.com/AlecAivazis/survey/v2" "github.com/docker/distribution/reference" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" ) var Push bool var PushFlag = &cli.BoolFlag{ Name: "push", Usage: "Git push changes", Value: false, Aliases: []string{"P"}, Destination: &Push, } var Dry bool var DryFlag = &cli.BoolFlag{ Name: "dry-run", Usage: "No changes are made, only reports changes that would be made", Value: false, Aliases: []string{"d"}, Destination: &Dry, } var CommitMessage string var CommitMessageFlag = &cli.StringFlag{ Name: "commit-message", Usage: "Commit message (implies --commit)", Aliases: []string{"cm"}, Destination: &CommitMessage, } var Commit bool var CommitFlag = &cli.BoolFlag{ Name: "commit", Usage: "Commits compose.**yml file changes to recipe repository", Value: false, Aliases: []string{"c"}, Destination: &Commit, } var TagMessage string var TagMessageFlag = &cli.StringFlag{ Name: "tag-comment", Usage: "Description for release tag", Aliases: []string{"t", "tm"}, Destination: &TagMessage, } var recipeReleaseCommand = &cli.Command{ Name: "release", Usage: "Release a new recipe version", Aliases: []string{"rl"}, ArgsUsage: " []", Description: ` This command is used to specify a new tag for a recipe. These tags are used to identify different versions of the recipe and are published on the Co-op Cloud recipe catalogue. These tags take the following form: a.b.c+x.y.z Where the "a.b.c" part is maintained as a semantic version of the recipe by the recipe maintainer. And the "x.y.z" part is the image tag of the recipe "app" service (the main container which contains the software to be used). We maintain a semantic versioning scheme ("a.b.c") alongside the libre app versioning scheme in order to maximise the chances that the nature of recipe updates are properly communicated. Abra does its best to read the "a.b.c" version scheme and communicate what action needs to be taken when performing different operations such as an update or a rollback of an app. You may invoke this command in "wizard" mode and be prompted for input: abra recipe release gitea `, Flags: []cli.Flag{ DryFlag, MajorFlag, MinorFlag, PatchFlag, PushFlag, CommitFlag, CommitMessageFlag, TagMessageFlag, }, Action: func(c *cli.Context) error { recipe := internal.ValidateRecipeWithPrompt(c) directory := path.Join(config.APPS_DIR, recipe.Name) tagString := c.Args().Get(1) mainApp := getMainApp(recipe) imagesTmp, err := getImageVersions(recipe) if err != nil { logrus.Fatal(err) } mainAppVersion := imagesTmp[mainApp] if err := recipePkg.EnsureExists(recipe.Name); err != nil { logrus.Fatal(err) } if mainAppVersion == "" { logrus.Fatalf("main 'app' service version for %s is empty?", recipe.Name) } if tagString != "" { if _, err := tagcmp.Parse(tagString); err != nil { logrus.Fatal("invalid tag specified") } } if (!Major && !Minor && !Patch) && tagString != "" { logrus.Fatal("please specify or bump type (--major/--minor/--patch)") } if (Major || Minor || Patch) && tagString != "" { logrus.Fatal("cannot specify tag and bump type at the same time") } // bumpType is used to decide what part of the tag should be incremented bumpType := btoi(Major)*4 + btoi(Minor)*2 + btoi(Patch) if bumpType != 0 { // a bitwise check if the number is a power of 2 if (bumpType & (bumpType - 1)) != 0 { logrus.Fatal("you can only use one of: --major, --minor, --patch.") } } if (!Major && !Minor && !Patch) && tagString == "" { fmt.Printf(` semver cheat sheet (more via semver.org): major: new features/bug fixes, backwards incompatible minor: new features/bug fixes, backwards compatible patch: bug fixes, backwards compatible `) var chosenBumpType string prompt := &survey.Select{ Message: fmt.Sprintf("select recipe version increment type"), Options: []string{"major", "minor", "patch"}, } if err := survey.AskOne(prompt, &chosenBumpType); err != nil { logrus.Fatal(err) } setBumpType(chosenBumpType) } if TagMessage == "" { prompt := &survey.Input{ Message: "tag message", Default: fmt.Sprintf("chore: publish new %s version", getBumpType()), } if err := survey.AskOne(prompt, &TagMessage); err != nil { logrus.Fatal(err) } } var createTagOptions git.CreateTagOptions createTagOptions.Message = TagMessage if !Commit { prompt := &survey.Confirm{ Message: "git commit changes also?", } if err := survey.AskOne(prompt, &Commit); err != nil { return err } } if !Push { prompt := &survey.Confirm{ Message: "git push changes also?", } if err := survey.AskOne(prompt, &Push); err != nil { return err } } if Commit || CommitMessage != "" { commitRepo, err := git.PlainOpen(directory) if err != nil { logrus.Fatal(err) } commitWorktree, err := commitRepo.Worktree() if err != nil { logrus.Fatal(err) } if CommitMessage == "" { prompt := &survey.Input{ Message: "commit message", Default: fmt.Sprintf("chore: publish new %s version", getBumpType()), } if err := survey.AskOne(prompt, &CommitMessage); err != nil { logrus.Fatal(err) } } err = commitWorktree.AddGlob("compose.**yml") if err != nil { logrus.Fatal(err) } logrus.Debug("staged compose.**yml for commit") if !Dry { _, err = commitWorktree.Commit(CommitMessage, &git.CommitOptions{}) if err != nil { logrus.Fatal(err) } logrus.Info("changes commited") } else { logrus.Info("dry run only: NOT committing changes") } } repo, err := git.PlainOpen(directory) if err != nil { logrus.Fatal(err) } head, err := repo.Head() if err != nil { logrus.Fatal(err) } if tagString != "" { tag, err := tagcmp.Parse(tagString) if err != nil { logrus.Fatal(err) } if tag.MissingMinor { tag.Minor = "0" tag.MissingMinor = false } if tag.MissingPatch { tag.Patch = "0" tag.MissingPatch = false } tagString = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion) if Dry { hash := abraFormatter.SmallSHA(head.Hash().String()) logrus.Info(fmt.Sprintf("dry run only: NOT creating tag %s at %s", tagString, hash)) return nil } repo.CreateTag(tagString, head.Hash(), &createTagOptions) hash := abraFormatter.SmallSHA(head.Hash().String()) logrus.Info(fmt.Sprintf("created tag %s at %s", tagString, hash)) if Push && !Dry { if err := repo.Push(&git.PushOptions{}); err != nil { logrus.Fatal(err) } logrus.Info(fmt.Sprintf("pushed tag %s to remote", tagString)) } else { logrus.Info("dry run only: NOT pushing changes") } return nil } // get the latest tag with its hash, name etc var lastGitTag tagcmp.Tag iter, err := repo.Tags() if err != nil { logrus.Fatal(err) } if err := iter.ForEach(func(ref *plumbing.Reference) error { obj, err := repo.TagObject(ref.Hash()) if err != nil { return err } tagcmpTag, err := tagcmp.Parse(obj.Name) if err != nil { return err } if (lastGitTag == tagcmp.Tag{}) { lastGitTag = tagcmpTag } else if tagcmpTag.IsGreaterThan(lastGitTag) { lastGitTag = tagcmpTag } return nil }); err != nil { logrus.Fatal(err) } newTag := lastGitTag var newtagString string if bumpType > 0 { if Patch { now, err := strconv.Atoi(newTag.Patch) if err != nil { logrus.Fatal(err) } newTag.Patch = strconv.Itoa(now + 1) } else if Minor { now, err := strconv.Atoi(newTag.Minor) if err != nil { logrus.Fatal(err) } newTag.Patch = "0" newTag.Minor = strconv.Itoa(now + 1) } else if Major { now, err := strconv.Atoi(newTag.Major) if err != nil { logrus.Fatal(err) } newTag.Patch = "0" newTag.Minor = "0" newTag.Major = strconv.Itoa(now + 1) } } newTag.Metadata = mainAppVersion newtagString = newTag.String() if Dry { hash := abraFormatter.SmallSHA(head.Hash().String()) logrus.Info(fmt.Sprintf("dry run only: NOT creating tag %s at %s", newtagString, hash)) return nil } repo.CreateTag(newtagString, head.Hash(), &createTagOptions) hash := abraFormatter.SmallSHA(head.Hash().String()) logrus.Info(fmt.Sprintf("created tag %s at %s", newtagString, hash)) if Push && !Dry { if err := repo.Push(&git.PushOptions{}); err != nil { logrus.Fatal(err) } logrus.Info(fmt.Sprintf("pushed tag %s to remote", newtagString)) } else { logrus.Info("dry run only: NOT pushing changes") } return nil }, } // getImageVersions retrieves image versions for a recipe func getImageVersions(recipe recipe.Recipe) (map[string]string, error) { var services = make(map[string]string) 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) if strings.Contains(path, "library") { path = strings.Split(path, "/")[1] } var tag string switch img.(type) { case reference.NamedTagged: tag = img.(reference.NamedTagged).Tag() case reference.Named: logrus.Fatalf("%s service is missing image tag?", path) } services[path] = tag } return services, nil } // getMainApp retrieves the main 'app' image name func getMainApp(recipe recipe.Recipe) string { for _, service := range recipe.Config.Services { name := service.Name if name == "app" { return strings.Split(service.Image, ":")[0] } } return "" } // btoi converts a boolean value into an integer func btoi(b bool) int { if b { return 1 } return 0 } // getBumpType figures out which bump type is specified func getBumpType() string { var bumpType string if Major { bumpType = "major" } else if Minor { bumpType = "minor" } else if Patch { bumpType = "patch" } else { logrus.Fatal("no version bump type specififed?") } return bumpType } // setBumpType figures out which bump type is specified func setBumpType(bumpType string) { if bumpType == "major" { Major = true } else if bumpType == "minor" { Minor = true } else if bumpType == "patch" { Patch = true } else { logrus.Fatal("no version bump type specififed?") } }