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 }