forked from toolshed/abra
		
	
		
			
				
	
	
		
			333 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			333 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
package recipe
 | 
						|
 | 
						|
import (
 | 
						|
	"bufio"
 | 
						|
	"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/config"
 | 
						|
	"coopcloud.tech/abra/pkg/formatter"
 | 
						|
	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/sirupsen/logrus"
 | 
						|
	"github.com/urfave/cli"
 | 
						|
)
 | 
						|
 | 
						|
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",
 | 
						|
	Description: `
 | 
						|
Parse all image tags within the given <recipe> 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:    "<recipe>",
 | 
						|
	Flags: []cli.Flag{
 | 
						|
		internal.DebugFlag,
 | 
						|
		internal.NoInputFlag,
 | 
						|
		internal.PatchFlag,
 | 
						|
		internal.MinorFlag,
 | 
						|
		internal.MajorFlag,
 | 
						|
		internal.MachineReadableFlag,
 | 
						|
		internal.AllTagsFlag,
 | 
						|
		internal.OfflineFlag,
 | 
						|
	},
 | 
						|
	Before: internal.SubCommandBefore,
 | 
						|
	Action: func(c *cli.Context) error {
 | 
						|
		conf := runtime.New(runtime.WithOffline(internal.Offline))
 | 
						|
		recipe := internal.ValidateRecipeWithPrompt(c, conf)
 | 
						|
 | 
						|
		if err := recipePkg.EnsureUpToDate(recipe.Name, conf); err != nil {
 | 
						|
			logrus.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 {
 | 
						|
				logrus.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
 | 
						|
		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)
 | 
						|
			}
 | 
						|
 | 
						|
			regVersions, err := client.GetRegistryTags(img)
 | 
						|
			if err != nil {
 | 
						|
				logrus.Fatal(err)
 | 
						|
			}
 | 
						|
 | 
						|
			image := reference.Path(img)
 | 
						|
			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)
 | 
						|
				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, 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, conf)
 | 
						|
			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)
 | 
						|
						}
 | 
						|
					}
 | 
						|
 | 
						|
					// 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 {
 | 
						|
							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 {
 | 
						|
				if !internal.NoInput {
 | 
						|
					logrus.Warnf("not upgrading %s, skipping as requested", image)
 | 
						|
				}
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		if internal.NoInput {
 | 
						|
			if internal.MachineReadable {
 | 
						|
				jsonstring, err := json.Marshal(upgradeList)
 | 
						|
				if err != nil {
 | 
						|
					logrus.Fatal(err)
 | 
						|
				}
 | 
						|
 | 
						|
				fmt.Println(string(jsonstring))
 | 
						|
				return nil
 | 
						|
			}
 | 
						|
 | 
						|
			for _, upgrade := range upgradeList {
 | 
						|
				logrus.Infof("can upgrade service: %s, image: %s, tag: %s ::\n", upgrade.Service, upgrade.Image, upgrade.Tag)
 | 
						|
				for _, utag := range upgrade.UpgradeTags {
 | 
						|
					logrus.Infof("    %s\n", utag)
 | 
						|
				}
 | 
						|
			}
 | 
						|
		}
 | 
						|
		return nil
 | 
						|
	},
 | 
						|
}
 |