k8s.io/test-infra@v0.0.0-20240520184403-27c6b4c223d8/experiment/cluster-upgrader/main.go (about)

     1  /*
     2  Copyright 2019 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package main
    18  
    19  import (
    20  	"errors"
    21  	"flag"
    22  	"fmt"
    23  	"os"
    24  	"os/exec"
    25  	"strings"
    26  
    27  	"github.com/blang/semver/v4"
    28  	"github.com/sirupsen/logrus"
    29  )
    30  
    31  type options struct {
    32  	project string
    33  	zone    string
    34  	cluster string
    35  	master  string
    36  	pools   string
    37  	ceiling string
    38  }
    39  
    40  func (o *options) parse(flags *flag.FlagSet, args []string) error {
    41  	flags.StringVar(&o.project, "project", "", "GCP project of cluster")
    42  	flags.StringVar(&o.zone, "zone", "", "GCP zone of cluster")
    43  	flags.StringVar(&o.cluster, "cluster", "", "GCP cluster name to upgrade")
    44  	flags.StringVar(&o.master, "master", "", "Force target master version instead of latest")
    45  	flags.StringVar(&o.pools, "pools", "", "Force target node pool version instead of latest")
    46  	flags.StringVar(&o.ceiling, "ceiling", "", "Limit to versions < this one when set, so --ceiling=1.15.0 match anything less than 1.15.0")
    47  	if err := flags.Parse(args); err != nil {
    48  		return fmt.Errorf("parse: %w", err)
    49  	}
    50  	if o.cluster == "" {
    51  		return errors.New("empty --cluster")
    52  	}
    53  	return nil
    54  }
    55  
    56  func parseOptions() options {
    57  	var o options
    58  	if err := o.parse(flag.CommandLine, os.Args[1:]); err != nil {
    59  		logrus.WithError(err).Fatal("Invalid flags")
    60  	}
    61  	return o
    62  }
    63  
    64  func main() {
    65  	opt := parseOptions()
    66  	log := logrus.WithFields(logrus.Fields{
    67  		"project": opt.project,
    68  		"zone":    opt.zone,
    69  		"cluster": opt.cluster,
    70  	})
    71  
    72  	var ceil *semver.Version
    73  	if opt.ceiling != "" {
    74  		var err error
    75  		if ceil, err = parse(opt.ceiling); err != nil {
    76  			logrus.WithError(err).Fatal("Bad --ceiling")
    77  		}
    78  	}
    79  	masterGoal, poolGoal, err := versions(opt.project, opt.zone, ceil)
    80  	if err != nil {
    81  		log.WithError(err).Fatal("Cannot find available versions")
    82  	}
    83  	if opt.master != "" {
    84  		if masterGoal, err = parse(opt.master); err != nil {
    85  			log.WithError(err).Fatal("Bad --master")
    86  		}
    87  	}
    88  	if opt.pools != "" {
    89  		if poolGoal, err = parse(opt.pools); err != nil {
    90  			log.WithError(err).Fatal("Bad --pool")
    91  		}
    92  	}
    93  
    94  	log = log.WithFields(logrus.Fields{
    95  		"masterGoal": masterGoal,
    96  		"poolGoal":   poolGoal,
    97  	})
    98  
    99  	if err := upgradeMaster(opt.project, opt.zone, opt.cluster, *masterGoal); err != nil {
   100  		log.WithError(err).Fatal("Could not upgrade master")
   101  	}
   102  
   103  	log.Info("Master at goal")
   104  
   105  	pools, err := pools(opt.project, opt.zone, opt.cluster)
   106  	if err != nil {
   107  		log.WithError(err).Fatal("Failed to list node pools")
   108  	}
   109  
   110  	baseLog := log
   111  	for _, pool := range pools {
   112  		log := log.WithField("pool", pool)
   113  		if err != nil {
   114  			log.WithError(err).Fatal("Could not determine current pool version")
   115  		}
   116  		if err := upgradePool(opt.project, opt.zone, opt.cluster, pool, *poolGoal); err != nil {
   117  			log.WithError(err).Fatal("Could not upgrade pool")
   118  		}
   119  		log.Info("Pool at goal")
   120  	}
   121  
   122  	baseLog.Info("Success")
   123  }
   124  
   125  // versions returns the available (master, node) versions for the zone
   126  func versions(project, zone string, ceiling *semver.Version) (*semver.Version, *semver.Version, error) {
   127  	out, err := output(
   128  		"gcloud", "container", "get-server-config",
   129  		"--project="+project, "--zone="+zone,
   130  		"--format=value(validMasterVersions,validNodeVersions)",
   131  	)
   132  	if err != nil {
   133  		return nil, nil, fmt.Errorf("get-server-config: %w", err)
   134  	}
   135  	parts := strings.Split(out, "\t")
   136  	master, err := selectVersion(ceiling, strings.Split(parts[0], ";")...)
   137  	if err != nil {
   138  		return nil, nil, fmt.Errorf("select master version: %w", err)
   139  	}
   140  	pool, err := selectVersion(ceiling, strings.Split(parts[0], ";")...)
   141  	if err != nil {
   142  		return nil, nil, fmt.Errorf("select pool version: %w", err)
   143  	}
   144  	return master, pool, nil
   145  }
   146  
   147  // selectVersion chooses the largest first value (less than ceiling if set)
   148  func selectVersion(ceiling *semver.Version, values ...string) (*semver.Version, error) {
   149  	for _, val := range values {
   150  		ver, err := parse(val)
   151  		if err != nil {
   152  			return nil, fmt.Errorf("bad version %s: %w", val, err)
   153  		}
   154  		if ceiling != nil && ver.GTE(*ceiling) {
   155  			continue
   156  		}
   157  		return ver, nil
   158  	}
   159  	return nil, errors.New("no matches found")
   160  }
   161  
   162  // upgradeMaster upgrades the master to the specified version, one minor version at a time.
   163  func upgradeMaster(project, zone, cluster string, want semver.Version) error {
   164  	doUpgrade := func(goal string) error {
   165  		return run(
   166  			"gcloud", "container", "clusters", "upgrade",
   167  			"--project="+project, "--zone="+zone, cluster, "--master",
   168  			"--cluster-version="+goal,
   169  		)
   170  	}
   171  
   172  	getVersion := func() (*semver.Version, error) {
   173  		return masterVersion(project, zone, cluster)
   174  	}
   175  	return upgrade(want, doUpgrade, getVersion)
   176  }
   177  
   178  // masterVersion returns the current master version.
   179  func masterVersion(project, zone, cluster string) (*semver.Version, error) {
   180  	out, err := output(
   181  		"gcloud", "container", "clusters", "describe",
   182  		"--project="+project, "--zone="+zone, cluster,
   183  		"--format=value(currentMasterVersion)",
   184  	)
   185  	if err != nil {
   186  		return nil, fmt.Errorf("clusters describe: %w", err)
   187  	}
   188  	return parse(out)
   189  }
   190  
   191  // pools returns the current set of pools in the cluster.
   192  func pools(project, zone, cluster string) ([]string, error) {
   193  	out, err := output(
   194  		"gcloud", "container", "node-pools", "list",
   195  		"--project="+project, "--zone="+zone, "--cluster="+cluster,
   196  		"--format=value(name)",
   197  	)
   198  	if err != nil {
   199  		return nil, fmt.Errorf("node-pools list: %w", err)
   200  	}
   201  	return strings.Split(out, "\n"), nil
   202  }
   203  
   204  // upgradePool upgrades the pool to the specified version, one minor version at a time.
   205  func upgradePool(project, zone, cluster, pool string, want semver.Version) error {
   206  	doUpgrade := func(goal string) error {
   207  		return run(
   208  			"gcloud", "container", "clusters", "upgrade",
   209  			"--project="+project, "--zone="+zone, cluster, "--node-pool="+pool,
   210  			"--cluster-version="+goal,
   211  		)
   212  	}
   213  
   214  	getVersion := func() (*semver.Version, error) {
   215  		return poolVersion(project, zone, cluster, pool)
   216  	}
   217  
   218  	return upgrade(want, doUpgrade, getVersion)
   219  }
   220  
   221  // poolVersion returns the current version of the pool.
   222  func poolVersion(project, zone, cluster, pool string) (*semver.Version, error) {
   223  	out, err := output(
   224  		"gcloud", "container", "node-pools", "describe",
   225  		"--project="+project, "--zone="+zone, "--cluster="+cluster, pool,
   226  		"--format=value(version)",
   227  	)
   228  	if err != nil {
   229  		return nil, fmt.Errorf("node-pools describe: %w", err)
   230  	}
   231  	return parse(out)
   232  }
   233  
   234  type upgrader func(string) error
   235  type versioner func() (*semver.Version, error)
   236  
   237  func upgrade(want semver.Version, doUpgrade upgrader, getVersion versioner) error {
   238  	for {
   239  		have, err := getVersion()
   240  		if err != nil {
   241  			return fmt.Errorf("get version: %w", err)
   242  		}
   243  		if have.Equals(want) {
   244  			return nil
   245  		}
   246  		if have.Major != want.Major {
   247  			return fmt.Errorf("cannot change major version %d to %d", have.Major, want.Major)
   248  		}
   249  		var goal string
   250  		switch {
   251  		case have.Minor == want.Minor:
   252  			goal = want.String()
   253  		case have.Minor > want.Minor:
   254  			goal = fmt.Sprintf("%d.%d", have.Major, have.Minor-1)
   255  		default:
   256  			goal = fmt.Sprintf("%d.%d", have.Major, have.Minor+1)
   257  		}
   258  		if err := doUpgrade(goal); err != nil {
   259  			return fmt.Errorf("upgrade to %s: %w", goal, err)
   260  		}
   261  	}
   262  }
   263  
   264  // output returns the output and prints Stderr to screen.
   265  func output(command string, args ...string) (string, error) {
   266  	logrus.WithFields(logrus.Fields{
   267  		"command": command,
   268  		"args":    args,
   269  	}).Debug("Grabbing output")
   270  	cmd := exec.Command(command, args...)
   271  	cmd.Stderr = os.Stderr
   272  	cmd.Stdin = os.Stdin
   273  	buf, err := cmd.Output()
   274  	return string(buf), err
   275  }
   276  
   277  // run the command, printing stdout, stderr to screen.
   278  func run(command string, args ...string) error {
   279  	logrus.WithFields(logrus.Fields{
   280  		"command": command,
   281  		"args":    args,
   282  	}).Info("Running command")
   283  	cmd := exec.Command(command, args...)
   284  	cmd.Stdout = os.Stdout
   285  	cmd.Stderr = os.Stderr
   286  	cmd.Stdin = os.Stdin
   287  	return cmd.Run()
   288  }
   289  
   290  // parse converts the string into a semver struct
   291  func parse(out string) (*semver.Version, error) {
   292  	ver, err := semver.Parse(strings.TrimSpace(out))
   293  	if err != nil {
   294  		return nil, fmt.Errorf("parse: %w", err)
   295  	}
   296  	return &ver, nil
   297  }