package recipe import ( "bufio" "context" "encoding/json" "fmt" "os" "path" "sort" "strings" "coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/formatter" gitPkg "coopcloud.tech/abra/pkg/git" "coopcloud.tech/abra/pkg/log" recipePkg "coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/tagcmp" "github.com/AlecAivazis/survey/v2" "github.com/distribution/reference" "github.com/urfave/cli/v3" ) type imgPin struct { image string version tagcmp.Tag } // anUpgrade represents a single service upgrade (as within a recipe), and the // list of tags that it can be upgraded to, for serialization purposes. type anUpgrade struct { Service string `json:"service"` Image string `json:"image"` Tag string `json:"tag"` UpgradeTags []string `json:"upgrades"` } var recipeUpgradeCommand = cli.Command{ Name: "upgrade", Aliases: []string{"u"}, Usage: "Upgrade recipe image tags", UsageText: "abra recipe upgrade [] [options]", Description: `Upgrade a given configuration. 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.`, Flags: []cli.Flag{ internal.PatchFlag, internal.MinorFlag, internal.MajorFlag, internal.MachineReadableFlag, internal.AllTagsFlag, }, Before: internal.SubCommandBefore, ShellComplete: autocomplete.RecipeNameComplete, HideHelp: true, Action: func(ctx context.Context, cmd *cli.Command) error { recipe := internal.ValidateRecipe(cmd) if err := recipe.Ensure(internal.Chaos, internal.Offline); err != nil { log.Fatal(err) } 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 { log.Fatal("you can only use one of: --major, --minor, --patch.") } } if internal.MachineReadable { // -m implies -n in this case internal.NoInput = true } upgradeList := make(map[string]anUpgrade) // check for versions file and load pinned versions versionsPresent := false versionsPath := path.Join(recipe.Dir, "versions") servicePins := make(map[string]imgPin) if _, err := os.Stat(versionsPath); err == nil { log.Debugf("found versions file for %s", recipe.Name) file, err := os.Open(versionsPath) if err != nil { log.Fatal(err) } scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() splitLine := strings.Split(line, " ") if splitLine[0] != "pin" || len(splitLine) != 3 { log.Fatalf("malformed version pin specification: %s", line) } pinSlice := strings.Split(splitLine[2], ":") pinTag, err := tagcmp.Parse(pinSlice[1]) if err != nil { log.Fatal(err) } pin := imgPin{ image: pinSlice[0], version: pinTag, } servicePins[splitLine[1]] = pin } if err := scanner.Err(); err != nil { log.Error(err) } versionsPresent = true } else { log.Debugf("did not find versions file for %s", recipe.Name) } config, err := recipe.GetComposeConfig(nil) if err != nil { log.Fatal(err) } for _, service := range config.Services { img, err := reference.ParseNormalizedNamed(service.Image) if err != nil { log.Fatal(err) } regVersions, err := client.GetRegistryTags(img) if err != nil { log.Fatal(err) } image := reference.Path(img) log.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()) { log.Debugf("%s not considered semver-like", img.(reference.NamedTagged).Tag()) } default: log.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 { log.Warnf("unable to parse %s, error was: %s, skipping upgrade for %s", image, err.Error(), service.Name) continue } log.Debugf("parsed %s for %s", tag, service.Name) var compatible []tagcmp.Tag for _, regVersion := range regVersions { other, err := tagcmp.Parse(regVersion) if err != nil { continue // skip tags that cannot be parsed } if tag.IsCompatible(other) && tag.IsLessThan(other) && !tag.Equals(other) { compatible = append(compatible, other) } } log.Debugf("detected potential upgradable tags %s for %s", compatible, service.Name) sort.Sort(tagcmp.ByTagDesc(compatible)) if len(compatible) == 0 && !internal.AllTags { log.Info(fmt.Sprintf("no new versions available for %s, assuming %s is the latest (use -a/--all-tags to see all anyway)", image, tag)) continue // skip on to the next tag and don't update any compose files } catlVersions, err := recipePkg.VersionsOfService(recipe.Name, service.Name, internal.Offline) if err != nil { log.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()) } } log.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 { log.Infof("upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString) } else { log.Infof("service %s, image %s pinned to %s, no compatible upgrade found", service.Name, servicePins[service.Name].image, pinnedTagString) continue } } else { log.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 == "" { log.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 { log.Warn(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) } } // there is always at least the item "skip" in compatibleStrings (a list of // possible upgradable tags) and at least one other tag. upgradableTags := compatibleStrings[1:] upgrade := anUpgrade{ Service: service.Name, Image: image, Tag: tag.String(), UpgradeTags: make([]string, len(upgradableTags)), } for n, s := range upgradableTags { var sb strings.Builder if _, err := sb.WriteString(s); err != nil { } upgrade.UpgradeTags[n] = sb.String() } upgradeList[upgrade.Service] = upgrade if internal.NoInput { upgradeTag = "skip" } else { 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 { log.Fatal(err) } } } } if upgradeTag != "skip" { ok, err := recipe.UpdateTag(image, upgradeTag) if err != nil { log.Fatal(err) } if ok { log.Infof("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image) } } else { if !internal.NoInput { log.Warnf("not upgrading %s, skipping as requested", image) } } } if internal.NoInput { if internal.MachineReadable { jsonstring, err := json.Marshal(upgradeList) if err != nil { log.Fatal(err) } fmt.Println(string(jsonstring)) return nil } for _, upgrade := range upgradeList { log.Infof("can upgrade service: %s, image: %s, tag: %s ::", upgrade.Service, upgrade.Image, upgrade.Tag) for _, utag := range upgrade.UpgradeTags { log.Infof(" %s", utag) } } } isClean, err := gitPkg.IsClean(recipe.Dir) if err != nil { log.Fatal(err) } if !isClean { log.Infof("%s currently has these unstaged changes 👇", recipe.Name) if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil { log.Fatal(err) } } return nil }, }