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 }