sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/hack/prowimagebuilder/main.go (about)

     1  /*
     2  Copyright 2022 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  	"bufio"
    21  	"context"
    22  	"flag"
    23  	"fmt"
    24  	"io"
    25  	"os"
    26  	"os/exec"
    27  	"path"
    28  	"strconv"
    29  	"strings"
    30  	"sync"
    31  	"time"
    32  
    33  	"github.com/sirupsen/logrus"
    34  	"sigs.k8s.io/prow/pkg/flagutil"
    35  	"sigs.k8s.io/yaml"
    36  )
    37  
    38  const (
    39  	defaultArch = "linux/amd64"
    40  	allArch     = "all"
    41  
    42  	gatherStaicScriptName = "gather-static.sh"
    43  
    44  	// Relative to root of the repo
    45  	defaultProwImageListFile = ".prow-images.yaml"
    46  
    47  	defaultWorkersCount = 10
    48  	defaultRetry        = 3
    49  
    50  	// noOpKoDocerRepo is used when images are not pushed
    51  	noOpKoDocerRepo = "ko.local"
    52  )
    53  
    54  var (
    55  	rootDir     string
    56  	otherArches = []string{
    57  		"linux/arm64",
    58  		"linux/s390x",
    59  		"linux/ppc64le",
    60  	}
    61  	defaultTags = []string{
    62  		"latest",
    63  		"latest-root",
    64  	}
    65  )
    66  
    67  func init() {
    68  	out, err := runCmd(nil, "git", "rev-parse", "--show-toplevel")
    69  	if err != nil {
    70  		logrus.WithError(err).Error("Failed getting git root dir")
    71  		os.Exit(1)
    72  	}
    73  	rootDir = out
    74  
    75  	if _, err := runCmdInDirFunc(path.Join(rootDir, "hack/tools"), nil, "go", "build", "-o", path.Join(rootDir, "_bin/ko"), "github.com/google/ko"); err != nil {
    76  		logrus.WithError(err).Error("Failed ensure ko")
    77  		os.Exit(1)
    78  	}
    79  }
    80  
    81  type options struct {
    82  	dockerRepo        string
    83  	prowImageListFile string
    84  	images            flagutil.Strings
    85  	workers           int
    86  	push              bool
    87  	maxRetry          int
    88  }
    89  
    90  // Mock for unit testing purpose
    91  var runCmdInDirFunc = runCmdInDir
    92  
    93  func runCmdInDir(dir string, additionalEnv []string, cmd string, args ...string) (string, error) {
    94  	log := logrus.WithFields(logrus.Fields{"cmd": cmd, "args": args})
    95  	command := exec.Command(cmd, args...)
    96  	if dir != "" {
    97  		command.Dir = dir
    98  	}
    99  	command.Env = append(os.Environ(), additionalEnv...)
   100  	stdOut, err := command.StdoutPipe()
   101  	if err != nil {
   102  		return "", err
   103  	}
   104  	stdErr, err := command.StderrPipe()
   105  	if err != nil {
   106  		return "", err
   107  	}
   108  	if err := command.Start(); err != nil {
   109  		return "", err
   110  	}
   111  	scanner := bufio.NewScanner(stdOut)
   112  	var allOut string
   113  	for scanner.Scan() {
   114  		out := scanner.Text()
   115  		allOut = allOut + out
   116  		logrus.WithField("cmd", command.Args).Info(out)
   117  	}
   118  	allErr, _ := io.ReadAll(stdErr)
   119  	err = command.Wait()
   120  	if len(allErr) > 0 {
   121  		if err != nil {
   122  			log.Error(string(allErr))
   123  		} else {
   124  			log.Warn(string(allErr))
   125  		}
   126  	}
   127  	return strings.TrimSpace(allOut), err
   128  }
   129  
   130  func runCmd(additionalEnv []string, cmd string, args ...string) (string, error) {
   131  	return runCmdInDirFunc(rootDir, additionalEnv, cmd, args...)
   132  }
   133  
   134  type imageDef struct {
   135  	Dir            string `json:"dir"`
   136  	Arch           string `json:"arch"`
   137  	remainingRetry int
   138  }
   139  
   140  type imageDefs struct {
   141  	Defs []imageDef `json:"images"`
   142  }
   143  
   144  func loadImageDefs(p string) ([]imageDef, error) {
   145  	b, err := os.ReadFile(p)
   146  	if err != nil {
   147  		return nil, err
   148  	}
   149  	var res imageDefs
   150  	if err := yaml.Unmarshal(b, &res); err != nil {
   151  		return nil, err
   152  	}
   153  	return res.Defs, nil
   154  }
   155  
   156  func allBaseTags() ([]string, error) {
   157  	gitTag, err := gitTag()
   158  	if err != nil {
   159  		return nil, err
   160  	}
   161  	// Add a `ko-<GIT_TAG>` tag so that it's easy to identify images built from
   162  	// ko vs. images built from bazel, in case there is a revert needed.
   163  	// TODO(chaodaiG): remove `ko-` tag once the images produced by ko proved to
   164  	// be working
   165  	return append(defaultTags, gitTag, "ko-"+gitTag), nil
   166  }
   167  
   168  func allTags(arch string) ([]string, error) {
   169  	baseTags, err := allBaseTags()
   170  	if err != nil {
   171  		return nil, err
   172  	}
   173  
   174  	var allTags = baseTags
   175  	for _, otherArch := range otherArches {
   176  		if arch != allArch && arch != otherArch {
   177  			continue
   178  		}
   179  		for _, base := range baseTags {
   180  			// So far only platform supported is linux, trimming off the linux/
   181  			// prefix so that there is no slash in tag. Also for consistency reasons.
   182  			platform := strings.Replace(otherArch, "linux/", "", 1)
   183  			allTags = append(allTags, fmt.Sprintf("%s-%s", base, platform))
   184  		}
   185  	}
   186  	return allTags, nil
   187  }
   188  
   189  var datePrefix string
   190  
   191  // gitTag returns YYYYMMDD-<GIT_TAG>
   192  // In order to ensure a consistent date value across the runtime of this process
   193  // when run near midnight UTC, we cache the value of date.
   194  // We don't cache the git SHA since a change in that would be meaningful.
   195  func gitTag() (string, error) {
   196  	var err error
   197  	if datePrefix == "" {
   198  		if datePrefix, err = runCmd(nil, "date", "+v%Y%m%d"); err != nil {
   199  			return "", err
   200  		}
   201  	}
   202  	postfix, err := runCmd(nil, "git", "describe", "--always", "--dirty")
   203  	if err != nil {
   204  		return "", err
   205  	}
   206  	return fmt.Sprintf("%s-%s", datePrefix, postfix), nil
   207  }
   208  
   209  func runGatherStaticScript(id *imageDef, args ...string) error {
   210  	script := path.Join(rootDir, id.Dir, gatherStaicScriptName)
   211  	if _, err := os.Lstat(script); err != nil {
   212  		if !os.IsNotExist(err) {
   213  			return err
   214  		}
   215  		return nil
   216  	}
   217  	if _, err := runCmd(nil, script, args...); err != nil {
   218  		return err
   219  	}
   220  	return nil
   221  }
   222  
   223  func setup(id *imageDef) error {
   224  	return runGatherStaticScript(id)
   225  }
   226  
   227  func teardown(id *imageDef) error {
   228  	return runGatherStaticScript(id, "--cleanup")
   229  }
   230  
   231  func buildAndPush(id *imageDef, dockerRepos []string, push bool) error {
   232  	logger := logrus.WithField("image", id.Dir)
   233  	logger.Info("Build and push")
   234  	start := time.Now()
   235  	defer func(logger *logrus.Entry, start time.Time) {
   236  		logger.WithField("duration", time.Since(start).String()).Info("Duration of image building.")
   237  	}(logger, start)
   238  	// So far only supports certain arch
   239  	isSupportedArch := (id.Arch == defaultArch || id.Arch == allArch)
   240  	for _, otherArch := range otherArches {
   241  		if id.Arch == otherArch {
   242  			isSupportedArch = true
   243  		}
   244  	}
   245  	if !isSupportedArch {
   246  		return fmt.Errorf("Arch '%s' not supported, only support %v", id.Arch, append([]string{defaultArch, allArch}, otherArches...))
   247  	}
   248  	publishArgs := []string{"publish", fmt.Sprintf("--tarball=_bin/%s.tar", path.Base(id.Dir)), "--push=false"}
   249  	if push {
   250  		publishArgs = []string{"publish", "--push=true"}
   251  	}
   252  	tags, err := allTags(id.Arch)
   253  	if err != nil {
   254  		return fmt.Errorf("collecting tags: %w", err)
   255  	}
   256  	for _, tag := range tags {
   257  		publishArgs = append(publishArgs, fmt.Sprintf("--tags=%s", tag))
   258  	}
   259  	publishArgs = append(publishArgs, "--base-import-paths", "--platform="+id.Arch, "./"+id.Dir)
   260  
   261  	defer teardown(id)
   262  	if err := setup(id); err != nil {
   263  		return fmt.Errorf("setup: %w", err)
   264  	}
   265  	// ko only supports a single docker repo at a time; we run ko repeatedly
   266  	// against different docker repos to support pushing to multiple docker
   267  	// repos.  This process utilizes the built-in cache of ko, so that pushing
   268  	// to subsequent identical docker repo(s) is relatively cheap.
   269  	for _, dockerRepo := range dockerRepos {
   270  		logger.WithField("args", publishArgs).Info("Running ko.")
   271  		if _, err = runCmd([]string{"KO_DOCKER_REPO=" + dockerRepo}, "_bin/ko", publishArgs...); err != nil {
   272  			return fmt.Errorf("running ko: %w", err)
   273  		}
   274  	}
   275  	return nil
   276  }
   277  
   278  func (o *options) imageAllowed(image string) bool {
   279  	return len(o.images.Strings()) == 0 || o.images.StringSet().Has(image)
   280  }
   281  
   282  func main() {
   283  	var o options
   284  	flag.StringVar(&o.prowImageListFile, "prow-images-file", path.Join(rootDir, defaultProwImageListFile), "Yaml file contains list of prow images")
   285  	flag.Var(&o.images, "image", "Images to be built, must be part of --prow-images-file, can be passed in repeatedly")
   286  	flag.StringVar(&o.dockerRepo, "ko-docker-repo", os.Getenv("KO_DOCKER_REPO"), "Dockers repos, separated by comma")
   287  	flag.IntVar(&o.workers, "workers", defaultWorkersCount, "Number of workers in parallel")
   288  	flag.BoolVar(&o.push, "push", false, "whether push or not")
   289  	flag.IntVar(&o.maxRetry, "retry", defaultRetry, "Number of times retrying for each image")
   290  	flag.Parse()
   291  
   292  	if !o.push && o.dockerRepo == "" {
   293  		o.dockerRepo = noOpKoDocerRepo
   294  	}
   295  	// By default ensures timestamp of images, ref:
   296  	// https://github.com/google/ko#why-are-my-images-all-created-in-1970
   297  	if err := os.Setenv("SOURCE_DATE_EPOCH", strconv.Itoa(int(time.Now().Unix()))); err != nil {
   298  		logrus.WithError(err).Error("Failed setting SOURCE_DATE_EPOCH")
   299  		os.Exit(1)
   300  	}
   301  
   302  	// Set VERSION for embedding versions with go build
   303  	gitTag, err := gitTag()
   304  	if err != nil {
   305  		logrus.WithError(err).Error("Failed get git tag")
   306  		os.Exit(1)
   307  	}
   308  	if err := os.Setenv("VERSION", gitTag); err != nil {
   309  		logrus.WithError(err).Error("Failed setting VERSION")
   310  		os.Exit(1)
   311  	}
   312  
   313  	ids, err := loadImageDefs(o.prowImageListFile)
   314  	if err != nil {
   315  		logrus.WithError(err).WithField("prow-image-file", o.prowImageListFile).Error("Failed loading")
   316  		os.Exit(1)
   317  	}
   318  
   319  	var wg sync.WaitGroup
   320  	imageChan := make(chan imageDef, 10)
   321  	errChan := make(chan error, len(ids))
   322  	doneChan := make(chan imageDef, len(ids))
   323  	// Start workers
   324  	ctx, cancel := context.WithCancel(context.Background())
   325  	defer cancel()
   326  	for i := 0; i < o.workers; i++ {
   327  		go func(ctx context.Context, imageChan chan imageDef, errChan chan error, doneChan chan imageDef) {
   328  			for {
   329  				select {
   330  				case id := <-imageChan:
   331  					err := buildAndPush(&id, strings.Split(o.dockerRepo, ","), o.push)
   332  					if err != nil {
   333  						if id.remainingRetry > 0 {
   334  							// Let another routine handle this, better luck maybe?
   335  							id.remainingRetry--
   336  							imageChan <- id
   337  							// Don't call wg.Done() as we are not done yet
   338  							continue
   339  						}
   340  						errChan <- fmt.Errorf("building image for %s failed: %w", id.Dir, err)
   341  					}
   342  					doneChan <- id
   343  				case <-ctx.Done():
   344  					return
   345  				}
   346  			}
   347  		}(ctx, imageChan, errChan, doneChan)
   348  	}
   349  
   350  	var targetImagesCount int
   351  	for _, id := range ids {
   352  		id := id
   353  		if !o.imageAllowed(id.Dir) {
   354  			logrus.WithFields(logrus.Fields{"allowed-images": o.images, "image": id.Dir}).Info("Skipped.")
   355  			continue
   356  		}
   357  		id.remainingRetry = o.maxRetry
   358  		if id.Arch == "" {
   359  			id.Arch = defaultArch
   360  		}
   361  		// Feed into channel instead
   362  		wg.Add(1)
   363  		imageChan <- id
   364  		targetImagesCount++
   365  	}
   366  
   367  	// This is used for testing images building, let's make sure it does something.
   368  	if targetImagesCount == 0 {
   369  		logrus.Error("There is no image to build.")
   370  		os.Exit(1)
   371  	}
   372  
   373  	go func(ctx context.Context, wg *sync.WaitGroup, doneChan chan imageDef) {
   374  		var done int
   375  		for {
   376  			select {
   377  			case id := <-doneChan:
   378  				done++
   379  				logrus.WithFields(logrus.Fields{"image": id.Dir, "done": done, "total": targetImagesCount}).Info("Done with image.")
   380  				wg.Done()
   381  			case <-ctx.Done():
   382  				return
   383  			}
   384  		}
   385  	}(ctx, &wg, doneChan)
   386  
   387  	wg.Wait()
   388  	for {
   389  		select {
   390  		case err := <-errChan:
   391  			logrus.WithError(err).Error("Failed.")
   392  			os.Exit(1)
   393  		default:
   394  			return
   395  		}
   396  	}
   397  }