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/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/v2" ) 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 a semantic version determined by the 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. Publish your new release 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.DryFlag, internal.MajorFlag, internal.MinorFlag, internal.PatchFlag, internal.PublishFlag, }, BashComplete: autocomplete.RecipeNameComplete, Action: func(c *cli.Context) error { recipe := internal.ValidateRecipeWithPrompt(c) 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) 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: return services, fmt.Errorf("%s service is missing image tag?", path) } services[path] = 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); 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 { return fmt.Errorf("no changes discovered in %s, nothing to publish?", recipe.Dir()) } if internal.Publish { msg := fmt.Sprintf("chore: publish %s release", tag) repoPath := path.Join(config.RECIPES_DIR, recipe.Name) if err := gitPkg.Commit(repoPath, "compose.**yml", 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) 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/tags", config.REPOS_BASE_URL, recipe.Name) logrus.Infof("new release published: %s", url) 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 if tagString == "" { if err := internal.PromptBumpType(tagString); err != nil { return err } } 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 internal.Major || internal.Minor || internal.Patch { newTag.Metadata = mainAppVersion tagString = newTag.String() } 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.Fatal(err) } if err := tagRelease(tagString, repo); err != nil { logrus.Fatal(err) } if err := pushRelease(recipe, tagString); err != nil { logrus.Fatal(err) } 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 }