382 lines
10 KiB
Go
382 lines
10 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/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/spf13/cobra"
|
|
)
|
|
|
|
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 = &cobra.Command{
|
|
Use: "upgrade <recipe> [flags]",
|
|
Aliases: []string{"u"},
|
|
Short: "Upgrade recipe image tags",
|
|
Long: `Upgrade a given <recipe> 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.`,
|
|
Args: cobra.RangeArgs(0, 1),
|
|
ValidArgsFunction: func(
|
|
cmd *cobra.Command,
|
|
args []string,
|
|
toComplete string) ([]string, cobra.ShellCompDirective) {
|
|
return autocomplete.RecipeNameComplete()
|
|
},
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
recipe := internal.ValidateRecipe(args, cmd.Name())
|
|
|
|
if err := recipe.Ensure(internal.GetEnsureContext()); 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 && !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
|
|
}
|
|
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()) || allTags {
|
|
tag := img.(reference.NamedTagged).Tag()
|
|
if !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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
},
|
|
}
|
|
|
|
var (
|
|
allTags bool
|
|
)
|
|
|
|
func init() {
|
|
RecipeUpgradeCommand.Flags().BoolVarP(
|
|
&internal.Major,
|
|
"major",
|
|
"x",
|
|
false,
|
|
"increase the major part of the version",
|
|
)
|
|
|
|
RecipeUpgradeCommand.Flags().BoolVarP(
|
|
&internal.Minor,
|
|
"minor",
|
|
"y",
|
|
false,
|
|
"increase the minor part of the version",
|
|
)
|
|
|
|
RecipeUpgradeCommand.Flags().BoolVarP(
|
|
&internal.Patch,
|
|
"patch",
|
|
"z",
|
|
false,
|
|
"increase the patch part of the version",
|
|
)
|
|
|
|
RecipeUpgradeCommand.Flags().BoolVarP(
|
|
&internal.MachineReadable,
|
|
"machine",
|
|
"m",
|
|
false,
|
|
"print machine-readable output",
|
|
)
|
|
|
|
RecipeUpgradeCommand.Flags().BoolVarP(
|
|
&allTags,
|
|
"all-tags",
|
|
"a",
|
|
false,
|
|
"list all tags, not just upgrades",
|
|
)
|
|
}
|