package recipe import ( "bufio" "fmt" "os" "path" "sort" "strings" "coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/formatter" recipePkg "coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/tagcmp" "github.com/AlecAivazis/survey/v2" "github.com/docker/distribution/reference" "github.com/sirupsen/logrus" "github.com/urfave/cli" ) type imgPin struct { image string version tagcmp.Tag } var recipeUpgradeCommand = cli.Command{ Name: "upgrade", Aliases: []string{"u"}, Usage: "Upgrade recipe image tags", Description: ` This command reads and attempts to parse all image tags within the given configuration and prompt with more recent tags to upgrade to. It will update the relevant compose file tags on the local file system. Some image tags cannot be parsed because they do not follow some sort of semver-like convention. In this case, all possible tags will be listed and it is up to the end-user to decide. The command is interactive and will show a select input which allows you to make a seclection. Use the "?" key to see more help on navigating this interface. You may invoke this command in "wizard" mode and be prompted for input: abra recipe upgrade `, BashComplete: autocomplete.RecipeNameComplete, ArgsUsage: "", Flags: []cli.Flag{ internal.DebugFlag, internal.NoInputFlag, internal.PatchFlag, internal.MinorFlag, internal.MajorFlag, internal.AllTagsFlag, }, Before: internal.SubCommandBefore, Action: func(c *cli.Context) error { recipe := internal.ValidateRecipeWithPrompt(c) bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.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.") } } // check for versions file and load pinned versions versionsPresent := false recipeDir := path.Join(config.RECIPES_DIR, recipe.Name) versionsPath := path.Join(recipeDir, "versions") var servicePins = make(map[string]imgPin) if _, err := os.Stat(versionsPath); err == nil { logrus.Debugf("found versions file for %s", recipe.Name) file, err := os.Open(versionsPath) if err != nil { logrus.Fatal(err) } scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() splitLine := strings.Split(line, " ") if splitLine[0] != "pin" || len(splitLine) != 3 { logrus.Fatalf("malformed version pin specification: %s", line) } pinSlice := strings.Split(splitLine[2], ":") pinTag, err := tagcmp.Parse(pinSlice[1]) if err != nil { logrus.Fatal(err) } pin := imgPin{ image: pinSlice[0], version: pinTag, } servicePins[splitLine[1]] = pin } if err := scanner.Err(); err != nil { logrus.Error(err) } versionsPresent = true } else { logrus.Debugf("did not find versions file for %s", recipe.Name) } for _, service := range recipe.Config.Services { img, err := reference.ParseNormalizedNamed(service.Image) if err != nil { logrus.Fatal(err) } image := reference.Path(img) regVersions, err := client.GetRegistryTags(image) if err != nil { logrus.Fatal(err) } logrus.Debugf("retrieved %s from remote registry for %s", regVersions, image) image = formatter.StripTagMeta(image) switch img.(type) { case reference.NamedTagged: if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) { logrus.Debugf("%s not considered semver-like", img.(reference.NamedTagged).Tag()) } default: logrus.Warnf("unable to read tag for image %s, is it missing? skipping upgrade for %s", image, service.Name) continue } tag, err := tagcmp.Parse(img.(reference.NamedTagged).Tag()) if err != nil { logrus.Warnf("unable to parse %s, error was: %s, skipping upgrade for %s", image, err.Error(), service.Name) continue } logrus.Debugf("parsed %s for %s", tag, service.Name) var compatible []tagcmp.Tag for _, regVersion := range regVersions { other, err := tagcmp.Parse(regVersion.Name) if err != nil { continue // skip tags that cannot be parsed } if tag.IsCompatible(other) && tag.IsLessThan(other) && !tag.Equals(other) { compatible = append(compatible, other) } } logrus.Debugf("detected potential upgradable tags %s for %s", compatible, service.Name) sort.Sort(tagcmp.ByTagDesc(compatible)) if len(compatible) == 0 && !internal.AllTags { logrus.Info(fmt.Sprintf("no new versions available for %s, %s is the latest", image, tag)) continue // skip on to the next tag and don't update any compose files } catlVersions, err := recipePkg.VersionsOfService(recipe.Name, service.Name) if err != nil { logrus.Fatal(err) } compatibleStrings := []string{"skip"} for _, compat := range compatible { skip := false for _, catlVersion := range catlVersions { if compat.String() == catlVersion { skip = true } } if !skip { compatibleStrings = append(compatibleStrings, compat.String()) } } logrus.Debugf("detected compatible upgradable tags %s for %s", compatibleStrings, service.Name) var upgradeTag string _, ok := servicePins[service.Name] if versionsPresent && ok { pinnedTag := servicePins[service.Name].version if tag.IsLessThan(pinnedTag) { pinnedTagString := pinnedTag.String() contains := false for _, v := range compatible { if pinnedTag.IsUpgradeCompatible(v) { contains = true upgradeTag = v.String() break } } if contains { logrus.Infof("upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString) } else { logrus.Infof("service %s, image %s pinned to %s, no compatible upgrade found", service.Name, servicePins[service.Name].image, pinnedTagString) continue } } else { logrus.Fatalf("service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String()) continue } } else { if bumpType != 0 { for _, upTag := range compatible { upElement, err := tag.UpgradeDelta(upTag) if err != nil { return err } delta := upElement.UpgradeType() if delta <= bumpType { upgradeTag = upTag.String() break } } if upgradeTag == "" { logrus.Warnf("not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants", tag.String(), compatible[0].String(), image) continue } } else { msg := fmt.Sprintf("upgrade to which tag? (service: %s, image: %s, tag: %s)", service.Name, image, tag) if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) || internal.AllTags { tag := img.(reference.NamedTagged).Tag() if !internal.AllTags { logrus.Warning(fmt.Sprintf("unable to determine versioning semantics of %s, listing all tags", tag)) } msg = fmt.Sprintf("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag) compatibleStrings = []string{"skip"} for _, regVersion := range regVersions { compatibleStrings = append(compatibleStrings, regVersion.Name) } } prompt := &survey.Select{ Message: msg, Help: "enter / return to confirm, choose 'skip' to not upgrade this tag, vim mode is enabled", VimMode: true, Options: compatibleStrings, } if err := survey.AskOne(prompt, &upgradeTag); err != nil { logrus.Fatal(err) } } } if upgradeTag != "skip" { ok, err := recipe.UpdateTag(image, upgradeTag) if err != nil { logrus.Fatal(err) } if ok { logrus.Infof("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image) } } else { logrus.Warnf("not upgrading %s, skipping as requested", image) } } return nil }, }