k8s.io/test-infra@v0.0.0-20240520184403-27c6b4c223d8/images/builder/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  	"flag"
    21  	"fmt"
    22  	"log"
    23  	"os"
    24  	"os/exec"
    25  	"path"
    26  	"path/filepath"
    27  	"regexp"
    28  	"strings"
    29  	"sync"
    30  	"time"
    31  
    32  	"github.com/google/uuid"
    33  	"sigs.k8s.io/yaml"
    34  )
    35  
    36  const (
    37  	gcsSourceDir = "/source"
    38  	gcsLogsDir   = "/logs"
    39  )
    40  
    41  type Step struct {
    42  	Name string `yaml:"name"`
    43  	Args []string
    44  }
    45  
    46  // struct for images/<image>/cloudbuild.yaml
    47  // Example: images/alpine/cloudbuild.yaml
    48  type CloudBuildYAMLFile struct {
    49  	Steps         []Step `yaml:"steps"`
    50  	Substitutions map[string]string
    51  	Images        []string
    52  }
    53  
    54  func getProjectID() (string, error) {
    55  	cmd := exec.Command("gcloud", "config", "get-value", "project")
    56  	projectID, err := cmd.Output()
    57  	if err != nil {
    58  		return "", fmt.Errorf("failed to get project_id: %w", err)
    59  	}
    60  	return string(projectID), nil
    61  }
    62  
    63  func getImageName(o options, tag string, config string) (string, error) {
    64  	var cloudbuildyamlFile CloudBuildYAMLFile
    65  	buf, _ := os.ReadFile(o.cloudbuildFile)
    66  	if err := yaml.Unmarshal(buf, &cloudbuildyamlFile); err != nil {
    67  		return "", fmt.Errorf("failed to get image name: %w", err)
    68  	}
    69  	projectID := o.project
    70  	// if projectID wasn't set explicitly, discover it
    71  	if projectID == "" {
    72  		p, err := getProjectID()
    73  		if err != nil {
    74  			return "", err
    75  		}
    76  		projectID = p
    77  	}
    78  	var imageNames = cloudbuildyamlFile.Images
    79  	r := strings.NewReplacer("$PROJECT_ID", strings.TrimSpace(projectID), "$_GIT_TAG", tag, "$_CONFIG", config)
    80  	var result string
    81  	for _, name := range imageNames {
    82  		result = result + r.Replace(name) + " "
    83  	}
    84  	return result, nil
    85  }
    86  
    87  func runCmd(command string, args ...string) error {
    88  	cmd := exec.Command(command, args...)
    89  	cmd.Stderr = os.Stderr
    90  	cmd.Stdout = os.Stdout
    91  	return cmd.Run()
    92  }
    93  
    94  func getVersion(versionTagFilter string) (string, error) {
    95  	cmd := exec.Command("git", "describe", "--tags", "--always", "--dirty")
    96  	if versionTagFilter != "" {
    97  		cmd.Args = append(cmd.Args, "--match", versionTagFilter)
    98  	}
    99  	output, err := cmd.Output()
   100  	if err != nil {
   101  		return "", err
   102  	}
   103  	validTagRegexp, err := regexp.Compile("[^-_.a-zA-Z0-9]+")
   104  	if err != nil {
   105  		return "", err
   106  	}
   107  	sanitizedOutput := validTagRegexp.ReplaceAllString(string(output), "")
   108  	t := time.Now().Format("20060102")
   109  	return fmt.Sprintf("v%s-%s", t, sanitizedOutput), nil
   110  }
   111  
   112  func (o *options) validateConfigDir() error {
   113  	configDir := o.configDir
   114  	dirInfo, err := os.Stat(o.configDir)
   115  	if os.IsNotExist(err) {
   116  		log.Fatalf("Config directory (%s) does not exist", configDir)
   117  	}
   118  
   119  	if !dirInfo.IsDir() {
   120  		log.Fatalf("Config directory (%s) is not actually a directory", configDir)
   121  	}
   122  
   123  	_, err = os.Stat(o.cloudbuildFile)
   124  	if os.IsNotExist(err) {
   125  		log.Fatalf("%s does not exist", o.cloudbuildFile)
   126  	}
   127  
   128  	return nil
   129  }
   130  
   131  func (o *options) uploadBuildDir(targetBucket string) (string, error) {
   132  	f, err := os.CreateTemp("", "")
   133  	if err != nil {
   134  		return "", fmt.Errorf("failed to create temp file: %w", err)
   135  	}
   136  	name := f.Name()
   137  	_ = f.Close()
   138  	defer os.Remove(name)
   139  
   140  	log.Printf("Creating source tarball at %s...\n", name)
   141  	var args []string
   142  	if !o.withGitDirectory {
   143  		args = append(args, "--exclude", ".git")
   144  	}
   145  	args = append(args, "-czf", name, ".")
   146  	if err := runCmd("tar", args...); err != nil {
   147  		return "", fmt.Errorf("failed to tar files: %s", err)
   148  	}
   149  
   150  	u := uuid.New()
   151  	uploaded := fmt.Sprintf("%s/%s.tgz", targetBucket, u.String())
   152  	log.Printf("Uploading %s to %s...\n", name, uploaded)
   153  	if err := runCmd("gsutil", "cp", name, uploaded); err != nil {
   154  		return "", fmt.Errorf("failed to upload files: %s", err)
   155  	}
   156  
   157  	return uploaded, nil
   158  }
   159  
   160  func getExtraSubs(o options) map[string]string {
   161  	envs := strings.Split(o.envPassthrough, ",")
   162  	subs := map[string]string{}
   163  	for _, e := range envs {
   164  		e = strings.TrimSpace(e)
   165  		if e != "" {
   166  			subs[e] = os.Getenv(e)
   167  		}
   168  	}
   169  	return subs
   170  }
   171  
   172  func runSingleJob(o options, jobName, uploaded, version string, subs map[string]string) error {
   173  	s := make([]string, 0, len(subs)+1)
   174  	for k, v := range subs {
   175  		s = append(s, fmt.Sprintf("_%s=%s", k, v))
   176  	}
   177  
   178  	s = append(s, "_GIT_TAG="+version)
   179  	args := []string{
   180  		"builds", "submit",
   181  		"--verbosity", "info",
   182  		"--config", o.cloudbuildFile,
   183  		"--substitutions", strings.Join(s, ","),
   184  	}
   185  
   186  	if o.project != "" {
   187  		args = append(args, "--project", o.project)
   188  	}
   189  
   190  	if o.scratchBucket != "" {
   191  		args = append(args, "--gcs-log-dir", o.scratchBucket+gcsLogsDir)
   192  		args = append(args, "--gcs-source-staging-dir", o.scratchBucket+gcsSourceDir)
   193  	}
   194  
   195  	if uploaded != "" {
   196  		args = append(args, uploaded)
   197  	} else {
   198  		if o.noSource {
   199  			args = append(args, "--no-source")
   200  		} else {
   201  			args = append(args, ".")
   202  		}
   203  	}
   204  
   205  	cmd := exec.Command("gcloud", args...)
   206  
   207  	var logFilePath string
   208  	if o.logDir != "" {
   209  		logFilePath = path.Join(o.logDir, strings.Replace(jobName, "/", "-", -1)+".log")
   210  		f, err := os.Create(logFilePath)
   211  
   212  		if err != nil {
   213  			return fmt.Errorf("couldn't create %s: %w", logFilePath, err)
   214  		}
   215  
   216  		defer f.Sync()
   217  		defer f.Close()
   218  
   219  		cmd.Stdout = f
   220  		cmd.Stderr = f
   221  	} else {
   222  		cmd.Stdout = os.Stdout
   223  		cmd.Stderr = os.Stderr
   224  	}
   225  
   226  	if err := cmd.Run(); err != nil {
   227  		if o.logDir != "" {
   228  			buildLog, _ := os.ReadFile(logFilePath)
   229  			fmt.Println(string(buildLog))
   230  		}
   231  		return fmt.Errorf("error running %s: %w", cmd.Args, err)
   232  	}
   233  
   234  	return nil
   235  }
   236  
   237  type variants map[string]map[string]string
   238  
   239  func getVariants(o options) (variants, error) {
   240  	content, err := os.ReadFile(path.Join(o.configDir, "variants.yaml"))
   241  	if err != nil {
   242  		if !os.IsNotExist(err) {
   243  			return nil, fmt.Errorf("failed to load variants.yaml: %w", err)
   244  		}
   245  		if o.variant != "" {
   246  			return nil, fmt.Errorf("no variants.yaml found, but a build variant (%q) was specified", o.variant)
   247  		}
   248  		return nil, nil
   249  	}
   250  	v := struct {
   251  		Variants variants `json:"variants"`
   252  	}{}
   253  	if err := yaml.UnmarshalStrict(content, &v); err != nil {
   254  		return nil, fmt.Errorf("failed to read variants.yaml: %w", err)
   255  	}
   256  	if o.variant != "" {
   257  		va, ok := v.Variants[o.variant]
   258  		if !ok {
   259  			return nil, fmt.Errorf("requested variant %q, which is not present in variants.yaml", o.variant)
   260  		}
   261  		return variants{o.variant: va}, nil
   262  	}
   263  	return v.Variants, nil
   264  }
   265  
   266  func runBuildJobs(o options) []error {
   267  	var uploaded string
   268  	if o.scratchBucket != "" {
   269  		if !o.noSource {
   270  			var err error
   271  			uploaded, err = o.uploadBuildDir(o.scratchBucket + gcsSourceDir)
   272  			if err != nil {
   273  				return []error{fmt.Errorf("failed to upload source: %w", err)}
   274  			}
   275  		}
   276  	} else {
   277  		log.Println("Skipping advance upload and relying on gcloud...")
   278  	}
   279  
   280  	log.Println("Running build jobs...")
   281  	tag, err := getVersion(o.versionTagFilter)
   282  	if err != nil {
   283  		return []error{fmt.Errorf("failed to get current tag: %w", err)}
   284  	}
   285  
   286  	if !o.allowDirty && strings.HasSuffix(tag, "-dirty") {
   287  		return []error{fmt.Errorf("the working copy is dirty")}
   288  	}
   289  
   290  	vs, err := getVariants(o)
   291  	if err != nil {
   292  		return []error{err}
   293  	}
   294  
   295  	if len(vs) == 0 {
   296  		log.Println("No variants.yaml, starting single build job...")
   297  		if err := runSingleJob(o, "build", uploaded, tag, getExtraSubs(o)); err != nil {
   298  			return []error{err}
   299  		}
   300  		var imageName, _ = getImageName(o, tag, "")
   301  		log.Printf("Successfully built image: %v \n", imageName)
   302  		return nil
   303  	}
   304  
   305  	log.Printf("Found variants.yaml, starting %d build jobs...\n", len(vs))
   306  
   307  	w := sync.WaitGroup{}
   308  	w.Add(len(vs))
   309  	var errors []error
   310  	extraSubs := getExtraSubs(o)
   311  	for k, v := range vs {
   312  		go func(job string, vc map[string]string) {
   313  			defer w.Done()
   314  			log.Printf("Starting job %q...\n", job)
   315  			if err := runSingleJob(o, job, uploaded, tag, mergeMaps(extraSubs, vc)); err != nil {
   316  				errors = append(errors, fmt.Errorf("job %q failed: %w", job, err))
   317  				log.Printf("Job %q failed: %v\n", job, err)
   318  			} else {
   319  				var imageName, _ = getImageName(o, tag, job)
   320  				log.Printf("Successfully built image: %v \n", imageName)
   321  				log.Printf("Job %q completed.\n", job)
   322  			}
   323  		}(k, v)
   324  	}
   325  	w.Wait()
   326  	return errors
   327  }
   328  
   329  type options struct {
   330  	buildDir         string
   331  	configDir        string
   332  	cloudbuildFile   string
   333  	logDir           string
   334  	scratchBucket    string
   335  	project          string
   336  	allowDirty       bool
   337  	noSource         bool
   338  	variant          string
   339  	versionTagFilter string
   340  	envPassthrough   string
   341  
   342  	// withGitDirectory will include the .git directory when uploading the source to GCB
   343  	withGitDirectory bool
   344  }
   345  
   346  func mergeMaps(maps ...map[string]string) map[string]string {
   347  	out := map[string]string{}
   348  	for _, m := range maps {
   349  		for k, v := range m {
   350  			out[k] = v
   351  		}
   352  	}
   353  	return out
   354  }
   355  
   356  func parseFlags() options {
   357  	o := options{}
   358  	flag.StringVar(&o.buildDir, "build-dir", "", "If provided, this directory will be uploaded as the source for the Google Cloud Build run.")
   359  	flag.StringVar(&o.cloudbuildFile, "gcb-config", "cloudbuild.yaml", "If provided, this will be used as the name of the Google Cloud Build config file.")
   360  	flag.StringVar(&o.logDir, "log-dir", "", "If provided, build logs will be sent to files in this directory instead of to stdout/stderr.")
   361  	flag.StringVar(&o.scratchBucket, "scratch-bucket", "", "The complete GCS path for Cloud Build to store scratch files (sources, logs).")
   362  	flag.StringVar(&o.project, "project", "", "If specified, use a non-default GCP project.")
   363  	flag.BoolVar(&o.allowDirty, "allow-dirty", false, "If true, allow pushing dirty builds.")
   364  	flag.BoolVar(&o.noSource, "no-source", false, "If true, no source will be uploaded with this build.")
   365  	flag.StringVar(&o.variant, "variant", "", "If specified, build only the given variant. An error if no variants are defined.")
   366  	flag.StringVar(&o.versionTagFilter, "version-tag-filter", "", "If specified, only tags that match the specified glob pattern are used in version detection.")
   367  	flag.StringVar(&o.envPassthrough, "env-passthrough", "", "Comma-separated list of specified environment variables to be passed to GCB as substitutions with an _ prefix. If the variable doesn't exist, the substitution will exist but be empty.")
   368  	flag.BoolVar(&o.withGitDirectory, "with-git-dir", o.withGitDirectory, "If true, upload the .git directory to GCB, so we can e.g. get the git log and tag.")
   369  
   370  	flag.Parse()
   371  
   372  	if flag.NArg() < 1 {
   373  		_, _ = fmt.Fprintln(os.Stderr, "expected a config directory to be provided")
   374  		os.Exit(1)
   375  	}
   376  
   377  	o.configDir = strings.TrimSuffix(flag.Arg(0), "/")
   378  
   379  	return o
   380  }
   381  
   382  func main() {
   383  	o := parseFlags()
   384  
   385  	if o.buildDir == "" {
   386  		o.buildDir = o.configDir
   387  	}
   388  
   389  	log.Printf("Build directory: %s\n", o.buildDir)
   390  
   391  	// Canonicalize the config directory to be an absolute path.
   392  	// As we're about to cd into the build directory, we need a consistent way to reference the config files
   393  	// when the config directory is not the same as the build directory.
   394  	absConfigDir, absErr := filepath.Abs(o.configDir)
   395  	if absErr != nil {
   396  		log.Fatalf("Could not resolve absolute path for config directory: %v", absErr)
   397  	}
   398  
   399  	o.configDir = absConfigDir
   400  	o.cloudbuildFile = path.Join(o.configDir, o.cloudbuildFile)
   401  
   402  	configDirErr := o.validateConfigDir()
   403  	if configDirErr != nil {
   404  		log.Fatalf("Could not validate config directory: %v", configDirErr)
   405  	}
   406  
   407  	log.Printf("Config directory: %s\n", o.configDir)
   408  
   409  	log.Printf("cd-ing to build directory: %s\n", o.buildDir)
   410  	if err := os.Chdir(o.buildDir); err != nil {
   411  		log.Fatalf("Failed to chdir to build directory (%s): %v", o.buildDir, err)
   412  	}
   413  
   414  	errors := runBuildJobs(o)
   415  	if len(errors) != 0 {
   416  		log.Fatalf("Failed to run some build jobs: %v", errors)
   417  	}
   418  	log.Println("Finished.")
   419  }