github.com/redhat-appstudio/e2e-tests@v0.0.0-20240520140907-9709f6f59323/pkg/utils/build/source_image.go (about) 1 package build 2 3 import ( 4 "bufio" 5 "bytes" 6 "encoding/json" 7 "fmt" 8 "io" 9 "net/http" 10 "net/url" 11 "os" 12 "path" 13 "path/filepath" 14 "regexp" 15 "strings" 16 17 "github.com/go-git/go-git/v5" 18 "github.com/go-git/go-git/v5/plumbing" 19 "github.com/moby/buildkit/frontend/dockerfile/parser" 20 "github.com/openshift/library-go/pkg/image/reference" 21 "sigs.k8s.io/controller-runtime/pkg/client" 22 23 "github.com/redhat-appstudio/e2e-tests/pkg/clients/github" 24 "github.com/redhat-appstudio/e2e-tests/pkg/clients/tekton" 25 "github.com/redhat-appstudio/e2e-tests/pkg/constants" 26 "github.com/redhat-appstudio/e2e-tests/pkg/utils" 27 pipeline "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" 28 ) 29 30 const ( 31 extraSourceSubDir = "extra_src_dir" 32 rpmSubDir = "rpm_dir" 33 srcTarFileRegex = "extra-src-[0-9a-f]+.tar" 34 shaValueRegex = "[a-f0-9]{40}" 35 tarGzFileRegex = ".tar.gz$" 36 gomodDependencySubDir = "deps/gomod/pkg/mod/cache/download/" 37 pipDependencySubDir = "deps/pip/" 38 ) 39 40 func GetBinaryImage(pr *pipeline.PipelineRun) string { 41 for _, p := range pr.Spec.Params { 42 if p.Name == "output-image" { 43 return p.Value.StringVal 44 } 45 } 46 return "" 47 } 48 49 func IsSourceBuildEnabled(pr *pipeline.PipelineRun) bool { 50 for _, p := range pr.Status.PipelineRunStatusFields.PipelineSpec.Params { 51 if p.Name == "build-source-image" { 52 if p.Default.StringVal == "true" { 53 return true 54 } 55 } 56 } 57 return false 58 } 59 60 func IsHermeticBuildEnabled(pr *pipeline.PipelineRun) bool { 61 for _, p := range pr.Spec.Params { 62 if p.Name == "hermetic" { 63 if p.Value.StringVal == "true" { 64 return true 65 } 66 } 67 } 68 return false 69 } 70 71 func GetPrefetchValue(pr *pipeline.PipelineRun) string { 72 for _, p := range pr.Spec.Params { 73 if p.Name == "prefetch-input" { 74 return p.Value.StringVal 75 } 76 } 77 return "" 78 } 79 80 func IsSourceFilesExistsInSourceImage(srcImage string, gitUrl string, isHermetic bool, prefetchValue string) (bool, error) { 81 //Extract the src image locally 82 tmpDir, err := ExtractImage(srcImage) 83 defer os.RemoveAll(tmpDir) 84 if err != nil { 85 return false, err 86 } 87 88 // Check atleast one file present under extra_src_dir 89 absExtraSourceDirPath := filepath.Join(tmpDir, extraSourceSubDir) 90 fileNames, err := utils.GetFileNamesFromDir(absExtraSourceDirPath) 91 if err != nil { 92 return false, fmt.Errorf("error while getting files: %v", err) 93 } 94 if len(fileNames) == 0 { 95 return false, fmt.Errorf("no tar file found in extra_src_dir, found files %v", fileNames) 96 } 97 98 // Get all the extra-src-*.tar files 99 extraSrcTarFiles := utils.FilterSliceUsingPattern(srcTarFileRegex, fileNames) 100 if len(extraSrcTarFiles) == 0 { 101 return false, fmt.Errorf("no tar file found with pattern %s", srcTarFileRegex) 102 } 103 fmt.Printf("Files found with pattern %s: %v\n", srcTarFileRegex, extraSrcTarFiles) 104 105 //Untar all the extra-src-[0-9]+.tar files 106 for _, tarFile := range extraSrcTarFiles { 107 absExtraSourceTarPath := filepath.Join(absExtraSourceDirPath, tarFile) 108 err = utils.Untar(absExtraSourceDirPath, absExtraSourceTarPath) 109 if err != nil { 110 return false, fmt.Errorf("error while untaring %s: %v", tarFile, err) 111 } 112 } 113 114 //Check if application source files exists 115 _, err = doAppSourceFilesExist(absExtraSourceDirPath) 116 if err != nil { 117 return false, err 118 } 119 // Check the pre-fetch dependency related files 120 if isHermetic { 121 _, err := IsPreFetchDependenciesFilesExists(gitUrl, absExtraSourceDirPath, isHermetic, prefetchValue) 122 if err != nil { 123 return false, err 124 } 125 } 126 127 return true, nil 128 } 129 130 // doAppSourceFilesExist checks if there is app source archive included. 131 // For the builds based on Konflux image, multiple app sources could be included. 132 func doAppSourceFilesExist(absExtraSourceDirPath string) (bool, error) { 133 //Get the file list from extra_src_dir 134 fileNames, err := utils.GetFileNamesFromDir(absExtraSourceDirPath) 135 if err != nil { 136 return false, fmt.Errorf("error while getting files: %v", err) 137 } 138 139 //Get the component source with pattern <repo-name>-<git-sha>.tar.gz 140 filePatternToFind := "^.+-" + shaValueRegex + tarGzFileRegex 141 resultFiles := utils.FilterSliceUsingPattern(filePatternToFind, fileNames) 142 if len(resultFiles) == 0 { 143 return false, fmt.Errorf("did not found the component source inside extra_src_dir, files found are: %v", fileNames) 144 } 145 146 fmt.Println("file names:", fileNames) 147 fmt.Println("app sources:", resultFiles) 148 149 for _, sourceGzTarFileName := range resultFiles { 150 //Untar the <repo-name>-<git-sha>.tar.gz file 151 err = utils.Untar(absExtraSourceDirPath, filepath.Join(absExtraSourceDirPath, sourceGzTarFileName)) 152 if err != nil { 153 return false, fmt.Errorf("error while untaring %s: %v", sourceGzTarFileName, err) 154 } 155 156 //Get the file list from extra_src_dir/<repo-name>-<sha> 157 sourceGzTarDirName := strings.TrimSuffix(sourceGzTarFileName, ".tar.gz") 158 absSourceGzTarPath := filepath.Join(absExtraSourceDirPath, sourceGzTarDirName) 159 fileNames, err = utils.GetFileNamesFromDir(absSourceGzTarPath) 160 if err != nil { 161 return false, fmt.Errorf("error while getting files from %s: %v", sourceGzTarDirName, err) 162 } 163 if len(fileNames) == 0 { 164 return false, fmt.Errorf("no file found under extra_src_dir/<repo-name>-<git-sha>") 165 } 166 } 167 168 return true, nil 169 } 170 171 // NewGithubClient creates a GitHub client with custom organization. 172 // The token is retrieved in the same way as what SuiteController does. 173 func NewGithubClient(organization string) (*github.Github, error) { 174 token := utils.GetEnv(constants.GITHUB_TOKEN_ENV, "") 175 if gh, err := github.NewGithubClient(token, organization); err != nil { 176 return nil, err 177 } else { 178 return gh, nil 179 } 180 } 181 182 // ReadFileFromGitRepo reads a file from a remote Git repository hosted in GitHub. 183 // The filePath should be a relative path to the root of the repository. 184 // File content is returned. If error occurs, the error will be returned and 185 // empty string is returned as nothing is read. 186 // If branch is omitted, file is read from the "main" branch. 187 func ReadFileFromGitRepo(repoUrl, filePath, branch string) (string, error) { 188 fromBranch := branch 189 if fromBranch == "" { 190 fromBranch = "main" 191 } 192 wrapErr := func(err error) error { 193 return fmt.Errorf("error while reading file %s from repository %s: %v", filePath, repoUrl, err) 194 } 195 parsedUrl, err := url.Parse(repoUrl) 196 if err != nil { 197 return "", wrapErr(err) 198 } 199 org, repo := path.Split(parsedUrl.Path) 200 gh, err := NewGithubClient(strings.Trim(org, "/")) 201 if err != nil { 202 return "", wrapErr(err) 203 } 204 repoContent, err := gh.GetFile(repo, filePath, fromBranch) 205 if err != nil { 206 return "", wrapErr(err) 207 } 208 if content, err := repoContent.GetContent(); err != nil { 209 return "", wrapErr(err) 210 } else { 211 return content, nil 212 } 213 } 214 215 // ReadRequirements reads dependencies from compiled requirements.txt by pip-compile, 216 // and it assumes the requirements.txt is simple in the root of the repository. 217 // The requirements are returned a list of strings, each of them is in form name==version. 218 func ReadRequirements(repoUrl string) ([]string, error) { 219 const requirementsFile = "requirements.txt" 220 221 wrapErr := func(err error) error { 222 return fmt.Errorf("error while reading requirements.txt from repo %s: %v", repoUrl, err) 223 } 224 225 content, err := ReadFileFromGitRepo(repoUrl, requirementsFile, "") 226 if err != nil { 227 return nil, wrapErr(err) 228 } 229 230 reqs := make([]string, 0, 5) 231 // Match line: "requests==2.31.0 \" 232 reqRegex := regexp.MustCompile(`^\S.+ \\$`) 233 234 scanner := bufio.NewScanner(strings.NewReader(content)) 235 for scanner.Scan() { 236 line := scanner.Text() 237 if reqRegex.MatchString(line) { 238 reqs = append(reqs, strings.TrimSuffix(line, " \\")) 239 } 240 } 241 242 return reqs, nil 243 } 244 245 func IsPreFetchDependenciesFilesExists(gitUrl, absExtraSourceDirPath string, isHermetic bool, prefetchValue string) (bool, error) { 246 var absDependencyPath string 247 if prefetchValue == "gomod" { 248 fmt.Println("Checking go dependency files") 249 absDependencyPath = filepath.Join(absExtraSourceDirPath, gomodDependencySubDir) 250 } else if prefetchValue == "pip" { 251 fmt.Println("Checking python dependency files") 252 absDependencyPath = filepath.Join(absExtraSourceDirPath, pipDependencySubDir) 253 } else { 254 return false, fmt.Errorf("pre-fetch value type is not implemented") 255 } 256 257 fileNames, err := utils.GetFileNamesFromDir(absDependencyPath) 258 if err != nil { 259 return false, fmt.Errorf("error while getting files from %s: %v", absDependencyPath, err) 260 } 261 if len(fileNames) == 0 { 262 return false, fmt.Errorf("no file found under extra_src_dir/deps/") 263 } 264 265 // Easy to check for pip. Check if all requirements are included in the built source image. 266 if prefetchValue == "pip" { 267 fileSet := make(map[string]int) 268 for _, name := range fileNames { 269 fileSet[name] = 1 270 } 271 fmt.Println("file set:", fileSet) 272 273 requirements, err := ReadRequirements(gitUrl) 274 fmt.Println("requirements:", requirements) 275 if err != nil { 276 return false, fmt.Errorf("error while reading requirements.txt from repo %s: %v", gitUrl, err) 277 } 278 var sdistFilename string 279 for _, requirement := range requirements { 280 if strings.Contains(requirement, "==") { 281 sdistFilename = strings.Replace(requirement, "==", "-", 1) + ".tar.gz" 282 } else if strings.Contains(requirement, " @ https://") { 283 sdistFilename = fmt.Sprintf("external-%s", strings.Split(requirement, " ")[0]) 284 } else { 285 fmt.Println("unknown requirement form:", requirement) 286 continue 287 } 288 if _, exists := fileSet[sdistFilename]; !exists { 289 return false, fmt.Errorf("requirement '%s' is not included", requirement) 290 } 291 } 292 } 293 294 return true, nil 295 } 296 297 // readDockerfile reads Dockerfile dockerfile from repository repoURL. 298 // The Dockerfile is resolved by following the logic applied to the buildah task definition. 299 func readDockerfile(pathContext, dockerfile, repoURL, repoRevision string) ([]byte, error) { 300 tempRepoDir, err := os.MkdirTemp("", "-test-repo") 301 if err != nil { 302 return nil, err 303 } 304 defer os.RemoveAll(tempRepoDir) 305 testRepo, err := git.PlainClone(tempRepoDir, false, &git.CloneOptions{URL: repoURL}) 306 if err != nil { 307 return nil, err 308 } 309 310 // checkout to the revision. use go-git ResolveRevision since revision could be a branch, tag or commit hash 311 commitHash, err := testRepo.ResolveRevision(plumbing.Revision(repoRevision)) 312 if err != nil { 313 return nil, err 314 } 315 workTree, err := testRepo.Worktree() 316 if err != nil { 317 return nil, err 318 } 319 if err := workTree.Checkout(&git.CheckoutOptions{Hash: *commitHash}); err != nil { 320 return nil, err 321 } 322 323 // check dockerfile in different paths 324 var dockerfilePath string 325 dockerfilePath = filepath.Join(tempRepoDir, dockerfile) 326 if content, err := os.ReadFile(dockerfilePath); err == nil { 327 return content, nil 328 } 329 dockerfilePath = filepath.Join(tempRepoDir, pathContext, dockerfile) 330 if content, err := os.ReadFile(dockerfilePath); err == nil { 331 return content, nil 332 } 333 if strings.HasPrefix(dockerfile, "https://") { 334 if resp, err := http.Get(dockerfile); err == nil { 335 defer resp.Body.Close() 336 if body, err := io.ReadAll(resp.Body); err == nil { 337 return body, err 338 } else { 339 return nil, err 340 } 341 } else { 342 return nil, err 343 } 344 } 345 return nil, fmt.Errorf( 346 fmt.Sprintf("resolveDockerfile: can't resolve Dockerfile from path context %s and dockerfile %s", 347 pathContext, dockerfile), 348 ) 349 } 350 351 // ReadDockerfileUsedForBuild reads the Dockerfile and return its content. 352 func ReadDockerfileUsedForBuild(c client.Client, tektonController *tekton.TektonController, pr *pipeline.PipelineRun) ([]byte, error) { 353 var paramDockerfileValue, paramPathContextValue string 354 var paramUrlValue, paramRevisionValue string 355 var err error 356 getParam := tektonController.GetTaskRunParam 357 358 if paramDockerfileValue, err = getParam(c, pr, "build-container", "DOCKERFILE"); err != nil { 359 return nil, err 360 } 361 362 if paramPathContextValue, err = getParam(c, pr, "build-container", "CONTEXT"); err != nil { 363 return nil, err 364 } 365 366 // get git-clone param url and revision 367 if paramUrlValue, err = getParam(c, pr, "clone-repository", "url"); err != nil { 368 return nil, err 369 } 370 371 if paramRevisionValue, err = getParam(c, pr, "clone-repository", "revision"); err != nil { 372 return nil, err 373 } 374 375 dockerfileContent, err := readDockerfile(paramPathContextValue, paramDockerfileValue, paramUrlValue, paramRevisionValue) 376 if err != nil { 377 return nil, err 378 } 379 return dockerfileContent, nil 380 } 381 382 type SourceBuildResult struct { 383 Status string `json:"status"` 384 Message string `json:"message,omitempty"` 385 DependenciesIncluded bool `json:"dependencies_included"` 386 BaseImageSourceIncluded bool `json:"base_image_source_included"` 387 ImageUrl string `json:"image_url"` 388 ImageDigest string `json:"image_digest"` 389 } 390 391 // ReadSourceBuildResult reads source-build task result BUILD_RESULT and returns the decoded data. 392 func ReadSourceBuildResult(c client.Client, tektonController *tekton.TektonController, pr *pipeline.PipelineRun) (*SourceBuildResult, error) { 393 sourceBuildResult, err := tektonController.GetTaskRunResult(c, pr, "build-source-image", "BUILD_RESULT") 394 if err != nil { 395 return nil, err 396 } 397 var buildResult SourceBuildResult 398 if err = json.Unmarshal([]byte(sourceBuildResult), &buildResult); err != nil { 399 return nil, err 400 } 401 return &buildResult, nil 402 } 403 404 type Dockerfile struct { 405 parsedContent *parser.Result 406 } 407 408 func ParseDockerfile(content []byte) (*Dockerfile, error) { 409 parsedContent, err := parser.Parse(bytes.NewReader(content)) 410 if err != nil { 411 return nil, err 412 } 413 df := Dockerfile{ 414 parsedContent: parsedContent, 415 } 416 return &df, nil 417 } 418 419 func (d *Dockerfile) ParentImages() []string { 420 parentImages := make([]string, 0, 5) 421 for _, child := range d.parsedContent.AST.Children { 422 if child.Value == "FROM" { 423 parentImages = append(parentImages, child.Next.Value) 424 } 425 } 426 return parentImages 427 } 428 429 func (d *Dockerfile) IsBuildFromScratch() bool { 430 parentImages := d.ParentImages() 431 return parentImages[len(parentImages)-1] == "scratch" 432 } 433 434 // convertImageToBuildahOutputForm converts an image pullspec to the corresponding form within 435 // BASE_IMAGES_DIGESTS output by buildah task. 436 func convertImageToBuildahOutputForm(imagePullspec string) (string, error) { 437 ref, err := reference.Parse(imagePullspec) 438 if err != nil { 439 return "", fmt.Errorf("fail to parse image %s: %s", imagePullspec, err) 440 } 441 var tag string 442 digest := ref.ID 443 if digest == "" { 444 val, err := FetchImageDigest(imagePullspec) 445 if err != nil { 446 return "", fmt.Errorf("fail to fetch image digest of %s: %s", imagePullspec, err) 447 } 448 digest = val 449 450 tag = ref.Tag 451 if tag == "" { 452 tag = "latest" 453 } 454 } else { 455 tag = "<none>" 456 } 457 digest = strings.TrimPrefix(digest, "sha256:") 458 // image could have no namespace. 459 converted := strings.TrimSuffix(filepath.Join(ref.Registry, ref.Namespace), "/") 460 return fmt.Sprintf("%s/%s:%s@sha256:%s", converted, ref.Name, tag, digest), nil 461 } 462 463 // ConvertParentImagesToBaseImagesDigestsForm is a helper function for testing the order is matched 464 // between BASE_IMAGES_DIGESTS and parent images within Dockerfile. 465 // ConvertParentImagesToBaseImagesDigestsForm de-duplicates the images what buildah task does for BASE_IMAGES_DIGESTS. 466 func (d *Dockerfile) ConvertParentImagesToBaseImagesDigestsForm() ([]string, error) { 467 convertedImagePullspecs := make([]string, 0, 5) 468 seen := make(map[string]int) 469 parentImages := d.ParentImages() 470 for _, imagePullspec := range parentImages { 471 if imagePullspec == "scratch" { 472 continue 473 } 474 if _, exists := seen[imagePullspec]; exists { 475 continue 476 } 477 seen[imagePullspec] = 1 478 if converted, err := convertImageToBuildahOutputForm(imagePullspec); err == nil { 479 convertedImagePullspecs = append(convertedImagePullspecs, converted) 480 } else { 481 return nil, err 482 } 483 } 484 return convertedImagePullspecs, nil 485 } 486 487 func isRegistryAllowed(registry string) bool { 488 // For the list of allowed registries, refer to source-build task definition. 489 allowedRegistries := map[string]int{ 490 "registry.access.redhat.com": 1, 491 "registry.redhat.io": 1, 492 } 493 _, exists := allowedRegistries[registry] 494 return exists 495 } 496 497 func IsImagePulledFromAllowedRegistry(imagePullspec string) (bool, error) { 498 if ref, err := reference.Parse(imagePullspec); err == nil { 499 return isRegistryAllowed(ref.Registry), nil 500 } else { 501 return false, err 502 } 503 } 504 505 func SourceBuildTaskRunLogsContain( 506 tektonController *tekton.TektonController, pr *pipeline.PipelineRun, message string) (bool, error) { 507 logs, err := tektonController.GetTaskRunLogs(pr.GetName(), "build-source-image", pr.GetNamespace()) 508 if err != nil { 509 return false, err 510 } 511 for _, logMessage := range logs { 512 if strings.Contains(logMessage, message) { 513 return true, nil 514 } 515 } 516 return false, nil 517 } 518 519 func ResolveSourceImageByVersionRelease(image string) (string, error) { 520 config, err := FetchImageConfig(image) 521 if err != nil { 522 return "", err 523 } 524 labels := config.Config.Labels 525 var version, release string 526 var exists bool 527 if version, exists = labels["version"]; !exists { 528 return "", fmt.Errorf("cannot find out version label from image config") 529 } 530 if release, exists = labels["release"]; !exists { 531 return "", fmt.Errorf("cannot find out release label from image config") 532 } 533 ref, err := reference.Parse(image) 534 if err != nil { 535 return "", err 536 } 537 ref.ID = "" 538 ref.Tag = fmt.Sprintf("%s-%s-source", version, release) 539 return ref.Exact(), nil 540 } 541 542 func AllParentSourcesIncluded(parentSourceImage, builtSourceImage string) (bool, error) { 543 parentConfig, err := FetchImageConfig(parentSourceImage) 544 if err != nil { 545 return false, fmt.Errorf("error while getting parent source image manifest %s: %w", parentSourceImage, err) 546 } 547 builtConfig, err := FetchImageConfig(builtSourceImage) 548 if err != nil { 549 return false, fmt.Errorf("error while getting built source image manifest %s: %w", builtSourceImage, err) 550 } 551 srpmSha256Sums := make(map[string]int) 552 var parts []string 553 for _, history := range builtConfig.History { 554 // Example history: #(nop) bsi version 0.2.0-dev adding artifact: 5f526f4 555 parts = strings.Split(history.CreatedBy, " ") 556 // The last part 5f526f4 is the checksum calculated from the file included in the generated blob. 557 srpmSha256Sums[parts[len(parts)-1]] = 1 558 } 559 for _, history := range parentConfig.History { 560 parts = strings.Split(history.CreatedBy, " ") 561 if _, exists := srpmSha256Sums[parts[len(parts)-1]]; !exists { 562 return false, nil 563 } 564 } 565 return true, nil 566 } 567 568 func ResolveKonfluxSourceImage(image string) (string, error) { 569 digest, err := FetchImageDigest(image) 570 if err != nil { 571 return "", fmt.Errorf("error while fetching image digest of %s: %w", image, err) 572 } 573 ref, err := reference.Parse(image) 574 if err != nil { 575 return "", fmt.Errorf("error while parsing image %s: %w", image, err) 576 } 577 ref.ID = "" 578 ref.Tag = fmt.Sprintf("sha256-%s.src", digest) 579 return ref.Exact(), nil 580 }