github.com/Racer159/jackal@v0.32.7-0.20240401174413-0bd2339e4f2e/src/pkg/packager/prepare.go (about)

     1  // SPDX-License-Identifier: Apache-2.0
     2  // SPDX-FileCopyrightText: 2021-Present The Jackal Authors
     3  
     4  // Package packager contains functions for interacting with, managing and deploying Jackal packages.
     5  package packager
     6  
     7  import (
     8  	"fmt"
     9  	"os"
    10  	"path/filepath"
    11  	"regexp"
    12  	"sort"
    13  	"strings"
    14  
    15  	"github.com/goccy/go-yaml"
    16  
    17  	"github.com/Racer159/jackal/src/config"
    18  	"github.com/Racer159/jackal/src/config/lang"
    19  	"github.com/Racer159/jackal/src/internal/packager/helm"
    20  	"github.com/Racer159/jackal/src/internal/packager/kustomize"
    21  	"github.com/Racer159/jackal/src/internal/packager/template"
    22  	"github.com/Racer159/jackal/src/pkg/layout"
    23  	"github.com/Racer159/jackal/src/pkg/message"
    24  	"github.com/Racer159/jackal/src/pkg/packager/creator"
    25  	"github.com/Racer159/jackal/src/pkg/packager/variables"
    26  	"github.com/Racer159/jackal/src/pkg/utils"
    27  	"github.com/Racer159/jackal/src/types"
    28  	"github.com/defenseunicorns/pkg/helpers"
    29  	"github.com/google/go-containerregistry/pkg/crane"
    30  	v1 "k8s.io/api/apps/v1"
    31  	batchv1 "k8s.io/api/batch/v1"
    32  	corev1 "k8s.io/api/core/v1"
    33  
    34  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    35  	"k8s.io/apimachinery/pkg/runtime"
    36  )
    37  
    38  // imageMap is a map of image/boolean pairs.
    39  type imageMap map[string]bool
    40  
    41  // FindImages iterates over a Jackal.yaml and attempts to parse any images.
    42  func (p *Packager) FindImages() (imgMap map[string][]string, err error) {
    43  	cwd, err := os.Getwd()
    44  	if err != nil {
    45  		return nil, err
    46  	}
    47  	defer func() {
    48  		// Return to the original working directory
    49  		if err := os.Chdir(cwd); err != nil {
    50  			message.Warnf("Unable to return to the original working directory: %s", err.Error())
    51  		}
    52  	}()
    53  	if err := os.Chdir(p.cfg.CreateOpts.BaseDir); err != nil {
    54  		return nil, fmt.Errorf("unable to access directory %q: %w", p.cfg.CreateOpts.BaseDir, err)
    55  	}
    56  	message.Note(fmt.Sprintf("Using build directory %s", p.cfg.CreateOpts.BaseDir))
    57  
    58  	c := creator.NewPackageCreator(p.cfg.CreateOpts, p.cfg, cwd)
    59  
    60  	if err := helpers.CreatePathAndCopy(layout.JackalYAML, p.layout.JackalYAML); err != nil {
    61  		return nil, err
    62  	}
    63  
    64  	p.cfg.Pkg, p.warnings, err = c.LoadPackageDefinition(p.layout)
    65  	if err != nil {
    66  		return nil, err
    67  	}
    68  
    69  	for _, warning := range p.warnings {
    70  		message.Warn(warning)
    71  	}
    72  
    73  	return p.findImages()
    74  }
    75  
    76  func (p *Packager) findImages() (imgMap map[string][]string, err error) {
    77  	repoHelmChartPath := p.cfg.FindImagesOpts.RepoHelmChartPath
    78  	kubeVersionOverride := p.cfg.FindImagesOpts.KubeVersionOverride
    79  	whyImage := p.cfg.FindImagesOpts.Why
    80  
    81  	imagesMap := make(map[string][]string)
    82  	erroredCharts := []string{}
    83  	erroredCosignLookups := []string{}
    84  	whyResources := []string{}
    85  
    86  	for _, component := range p.cfg.Pkg.Components {
    87  		if len(component.Repos) > 0 && repoHelmChartPath == "" {
    88  			message.Note("This Jackal package contains git repositories, " +
    89  				"if any repos contain helm charts you want to template and " +
    90  				"search for images, make sure to specify the helm chart path " +
    91  				"via the --repo-chart-path flag")
    92  		}
    93  	}
    94  
    95  	componentDefinition := "\ncomponents:\n"
    96  
    97  	if err := variables.SetVariableMapInConfig(p.cfg); err != nil {
    98  		return nil, err
    99  	}
   100  
   101  	for _, component := range p.cfg.Pkg.Components {
   102  		if len(component.Charts)+len(component.Manifests)+len(component.Repos) < 1 {
   103  			// Skip if it doesn't have what we need
   104  			continue
   105  		}
   106  
   107  		if repoHelmChartPath != "" {
   108  			// Also process git repos that have helm charts
   109  			for _, repo := range component.Repos {
   110  				matches := strings.Split(repo, "@")
   111  				if len(matches) < 2 {
   112  					message.Warnf("Cannot convert git repo %s to helm chart without a version tag", repo)
   113  					continue
   114  				}
   115  
   116  				// Trim the first char to match how the packager expects it, this is messy,need to clean up better
   117  				repoHelmChartPath = strings.TrimPrefix(repoHelmChartPath, "/")
   118  
   119  				// If a repo helm chart path is specified,
   120  				component.Charts = append(component.Charts, types.JackalChart{
   121  					Name:    repo,
   122  					URL:     matches[0],
   123  					Version: matches[1],
   124  					GitPath: repoHelmChartPath,
   125  				})
   126  			}
   127  		}
   128  
   129  		// matchedImages holds the collection of images, reset per-component
   130  		matchedImages := make(imageMap)
   131  		maybeImages := make(imageMap)
   132  
   133  		// resources are a slice of generic structs that represent parsed K8s resources
   134  		var resources []*unstructured.Unstructured
   135  
   136  		componentPaths, err := p.layout.Components.Create(component)
   137  		if err != nil {
   138  			return nil, err
   139  		}
   140  		values, err := template.Generate(p.cfg)
   141  		if err != nil {
   142  			return nil, fmt.Errorf("unable to generate template values")
   143  		}
   144  		// Adding these so the default builtin values exist in case any helm charts rely on them
   145  		registryInfo := types.RegistryInfo{Address: p.cfg.FindImagesOpts.RegistryURL}
   146  		err = registryInfo.FillInEmptyValues()
   147  		if err != nil {
   148  			return nil, err
   149  		}
   150  		gitServer := types.GitServerInfo{}
   151  		err = gitServer.FillInEmptyValues()
   152  		if err != nil {
   153  			return nil, err
   154  		}
   155  		artifactServer := types.ArtifactServerInfo{}
   156  		artifactServer.FillInEmptyValues()
   157  		values.SetState(&types.JackalState{
   158  			RegistryInfo:   registryInfo,
   159  			GitServer:      gitServer,
   160  			ArtifactServer: artifactServer})
   161  		for _, chart := range component.Charts {
   162  
   163  			helmCfg := helm.New(
   164  				chart,
   165  				componentPaths.Charts,
   166  				componentPaths.Values,
   167  				helm.WithKubeVersion(kubeVersionOverride),
   168  				helm.WithPackageConfig(p.cfg),
   169  			)
   170  
   171  			err = helmCfg.PackageChart(component.DeprecatedCosignKeyPath)
   172  			if err != nil {
   173  				return nil, fmt.Errorf("unable to package the chart %s: %w", chart.Name, err)
   174  			}
   175  
   176  			valuesFilePaths, _ := helpers.RecursiveFileList(componentPaths.Values, nil, false)
   177  			for _, path := range valuesFilePaths {
   178  				if err := values.Apply(component, path, false); err != nil {
   179  					return nil, err
   180  				}
   181  			}
   182  
   183  			// Generate helm templates for this chart
   184  			chartTemplate, chartValues, err := helmCfg.TemplateChart()
   185  			if err != nil {
   186  				message.WarnErrf(err, "Problem rendering the helm template for %s: %s", chart.Name, err.Error())
   187  				erroredCharts = append(erroredCharts, chart.Name)
   188  				continue
   189  			}
   190  
   191  			// Break the template into separate resources
   192  			yamls, _ := utils.SplitYAML([]byte(chartTemplate))
   193  			resources = append(resources, yamls...)
   194  
   195  			chartTarball := helm.StandardName(componentPaths.Charts, chart) + ".tgz"
   196  
   197  			annotatedImages, err := helm.FindAnnotatedImagesForChart(chartTarball, chartValues)
   198  			if err != nil {
   199  				message.WarnErrf(err, "Problem looking for image annotations for %s: %s", chart.URL, err.Error())
   200  				erroredCharts = append(erroredCharts, chart.URL)
   201  				continue
   202  			}
   203  			for _, image := range annotatedImages {
   204  				matchedImages[image] = true
   205  			}
   206  
   207  			// Check if the --why flag is set
   208  			if whyImage != "" {
   209  				whyResourcesChart, err := findWhyResources(yamls, whyImage, component.Name, chart.Name, true)
   210  				if err != nil {
   211  					message.WarnErrf(err, "Error finding why resources for chart %s: %s", chart.Name, err.Error())
   212  				}
   213  				whyResources = append(whyResources, whyResourcesChart...)
   214  			}
   215  		}
   216  
   217  		for _, manifest := range component.Manifests {
   218  			for idx, k := range manifest.Kustomizations {
   219  				// Generate manifests from kustomizations and place in the package
   220  				kname := fmt.Sprintf("kustomization-%s-%d.yaml", manifest.Name, idx)
   221  				destination := filepath.Join(componentPaths.Manifests, kname)
   222  				if err := kustomize.Build(k, destination, manifest.KustomizeAllowAnyDirectory); err != nil {
   223  					return nil, fmt.Errorf("unable to build the kustomization for %s: %w", k, err)
   224  				}
   225  				manifest.Files = append(manifest.Files, destination)
   226  			}
   227  			// Get all manifest files
   228  			for idx, f := range manifest.Files {
   229  				if helpers.IsURL(f) {
   230  					mname := fmt.Sprintf("manifest-%s-%d.yaml", manifest.Name, idx)
   231  					destination := filepath.Join(componentPaths.Manifests, mname)
   232  					if err := utils.DownloadToFile(f, destination, component.DeprecatedCosignKeyPath); err != nil {
   233  						return nil, fmt.Errorf(lang.ErrDownloading, f, err.Error())
   234  					}
   235  					f = destination
   236  				} else {
   237  					filename := filepath.Base(f)
   238  					newDestination := filepath.Join(componentPaths.Manifests, filename)
   239  					if err := helpers.CreatePathAndCopy(f, newDestination); err != nil {
   240  						return nil, fmt.Errorf("unable to copy manifest %s: %w", f, err)
   241  					}
   242  					f = newDestination
   243  				}
   244  
   245  				if err := values.Apply(component, f, true); err != nil {
   246  					return nil, err
   247  				}
   248  				// Read the contents of each file
   249  				contents, err := os.ReadFile(f)
   250  				if err != nil {
   251  					message.WarnErrf(err, "Unable to read the file %s", f)
   252  					continue
   253  				}
   254  
   255  				// Break the manifest into separate resources
   256  				contentString := string(contents)
   257  				message.Debugf("%s", contentString)
   258  				yamls, _ := utils.SplitYAML(contents)
   259  				resources = append(resources, yamls...)
   260  
   261  				// Check if the --why flag is set and if it is process the manifests
   262  				if whyImage != "" {
   263  					whyResourcesManifest, err := findWhyResources(yamls, whyImage, component.Name, manifest.Name, false)
   264  					if err != nil {
   265  						message.WarnErrf(err, "Error finding why resources for manifest %s: %s", manifest.Name, err.Error())
   266  					}
   267  					whyResources = append(whyResources, whyResourcesManifest...)
   268  				}
   269  			}
   270  		}
   271  
   272  		spinner := message.NewProgressSpinner("Looking for images in component %q across %d resources", component.Name, len(resources))
   273  		defer spinner.Stop()
   274  
   275  		for _, resource := range resources {
   276  			if matchedImages, maybeImages, err = p.processUnstructuredImages(resource, matchedImages, maybeImages); err != nil {
   277  				message.WarnErrf(err, "Problem processing K8s resource %s", resource.GetName())
   278  			}
   279  		}
   280  
   281  		if sortedImages := sortImages(matchedImages, nil); len(sortedImages) > 0 {
   282  			// Log the header comment
   283  			componentDefinition += fmt.Sprintf("\n  - name: %s\n    images:\n", component.Name)
   284  			for _, image := range sortedImages {
   285  				// Use print because we want this dumped to stdout
   286  				imagesMap[component.Name] = append(imagesMap[component.Name], image)
   287  				componentDefinition += fmt.Sprintf("      - %s\n", image)
   288  			}
   289  		}
   290  
   291  		// Handle the "maybes"
   292  		if sortedImages := sortImages(maybeImages, matchedImages); len(sortedImages) > 0 {
   293  			var validImages []string
   294  			for _, image := range sortedImages {
   295  				if descriptor, err := crane.Head(image, config.GetCraneOptions(config.CommonOptions.Insecure)...); err != nil {
   296  					// Test if this is a real image, if not just quiet log to debug, this is normal
   297  					message.Debugf("Suspected image does not appear to be valid: %#v", err)
   298  				} else {
   299  					// Otherwise, add to the list of images
   300  					message.Debugf("Imaged digest found: %s", descriptor.Digest)
   301  					validImages = append(validImages, image)
   302  				}
   303  			}
   304  
   305  			if len(validImages) > 0 {
   306  				componentDefinition += fmt.Sprintf("      # Possible images - %s - %s\n", p.cfg.Pkg.Metadata.Name, component.Name)
   307  				for _, image := range validImages {
   308  					imagesMap[component.Name] = append(imagesMap[component.Name], image)
   309  					componentDefinition += fmt.Sprintf("      - %s\n", image)
   310  				}
   311  			}
   312  		}
   313  
   314  		spinner.Success()
   315  
   316  		// Handle cosign artifact lookups
   317  		if len(imagesMap[component.Name]) > 0 {
   318  			var cosignArtifactList []string
   319  			spinner := message.NewProgressSpinner("Looking up cosign artifacts for discovered images (0/%d)", len(imagesMap[component.Name]))
   320  			defer spinner.Stop()
   321  
   322  			for idx, image := range imagesMap[component.Name] {
   323  				spinner.Updatef("Looking up cosign artifacts for discovered images (%d/%d)", idx+1, len(imagesMap[component.Name]))
   324  				cosignArtifacts, err := utils.GetCosignArtifacts(image)
   325  				if err != nil {
   326  					message.WarnErrf(err, "Problem looking up cosign artifacts for %s: %s", image, err.Error())
   327  					erroredCosignLookups = append(erroredCosignLookups, image)
   328  				}
   329  				cosignArtifactList = append(cosignArtifactList, cosignArtifacts...)
   330  			}
   331  
   332  			spinner.Success()
   333  
   334  			if len(cosignArtifactList) > 0 {
   335  				imagesMap[component.Name] = append(imagesMap[component.Name], cosignArtifactList...)
   336  				componentDefinition += fmt.Sprintf("      # Cosign artifacts for images - %s - %s\n", p.cfg.Pkg.Metadata.Name, component.Name)
   337  				for _, cosignArtifact := range cosignArtifactList {
   338  					componentDefinition += fmt.Sprintf("      - %s\n", cosignArtifact)
   339  				}
   340  			}
   341  		}
   342  	}
   343  
   344  	if whyImage != "" {
   345  		if len(whyResources) == 0 {
   346  			message.Warnf("image %q not found in any charts or manifests", whyImage)
   347  		}
   348  		return nil, nil
   349  	}
   350  
   351  	fmt.Println(componentDefinition)
   352  
   353  	if len(erroredCharts) > 0 || len(erroredCosignLookups) > 0 {
   354  		errMsg := ""
   355  		if len(erroredCharts) > 0 {
   356  			errMsg = fmt.Sprintf("the following charts had errors: %s", erroredCharts)
   357  		}
   358  		if len(erroredCosignLookups) > 0 {
   359  			if errMsg != "" {
   360  				errMsg += "\n"
   361  			}
   362  			errMsg += fmt.Sprintf("the following images errored on cosign lookups: %s", erroredCosignLookups)
   363  		}
   364  		return imagesMap, fmt.Errorf(errMsg)
   365  	}
   366  
   367  	return imagesMap, nil
   368  }
   369  
   370  func (p *Packager) processUnstructuredImages(resource *unstructured.Unstructured, matchedImages, maybeImages imageMap) (imageMap, imageMap, error) {
   371  	var imageSanityCheck = regexp.MustCompile(`(?mi)"image":"([^"]+)"`)
   372  	var imageFuzzyCheck = regexp.MustCompile(`(?mi)["|=]([a-z0-9\-.\/:]+:[\w.\-]*[a-z\.\-][\w.\-]*)"`)
   373  	var json string
   374  
   375  	contents := resource.UnstructuredContent()
   376  	bytes, _ := resource.MarshalJSON()
   377  	json = string(bytes)
   378  
   379  	switch resource.GetKind() {
   380  	case "Deployment":
   381  		var deployment v1.Deployment
   382  		if err := runtime.DefaultUnstructuredConverter.FromUnstructured(contents, &deployment); err != nil {
   383  			return matchedImages, maybeImages, fmt.Errorf("could not parse deployment: %w", err)
   384  		}
   385  		matchedImages = buildImageMap(matchedImages, deployment.Spec.Template.Spec)
   386  
   387  	case "DaemonSet":
   388  		var daemonSet v1.DaemonSet
   389  		if err := runtime.DefaultUnstructuredConverter.FromUnstructured(contents, &daemonSet); err != nil {
   390  			return matchedImages, maybeImages, fmt.Errorf("could not parse daemonset: %w", err)
   391  		}
   392  		matchedImages = buildImageMap(matchedImages, daemonSet.Spec.Template.Spec)
   393  
   394  	case "StatefulSet":
   395  		var statefulSet v1.StatefulSet
   396  		if err := runtime.DefaultUnstructuredConverter.FromUnstructured(contents, &statefulSet); err != nil {
   397  			return matchedImages, maybeImages, fmt.Errorf("could not parse statefulset: %w", err)
   398  		}
   399  		matchedImages = buildImageMap(matchedImages, statefulSet.Spec.Template.Spec)
   400  
   401  	case "ReplicaSet":
   402  		var replicaSet v1.ReplicaSet
   403  		if err := runtime.DefaultUnstructuredConverter.FromUnstructured(contents, &replicaSet); err != nil {
   404  			return matchedImages, maybeImages, fmt.Errorf("could not parse replicaset: %w", err)
   405  		}
   406  		matchedImages = buildImageMap(matchedImages, replicaSet.Spec.Template.Spec)
   407  
   408  	case "Job":
   409  		var job batchv1.Job
   410  		if err := runtime.DefaultUnstructuredConverter.FromUnstructured(contents, &job); err != nil {
   411  			return matchedImages, maybeImages, fmt.Errorf("could not parse job: %w", err)
   412  		}
   413  		matchedImages = buildImageMap(matchedImages, job.Spec.Template.Spec)
   414  
   415  	default:
   416  		// Capture any custom images
   417  		matches := imageSanityCheck.FindAllStringSubmatch(json, -1)
   418  		for _, group := range matches {
   419  			message.Debugf("Found unknown match, Kind: %s, Value: %s", resource.GetKind(), group[1])
   420  			matchedImages[group[1]] = true
   421  		}
   422  	}
   423  
   424  	// Capture "maybe images" too for all kinds because they might be in unexpected places.... 👀
   425  	matches := imageFuzzyCheck.FindAllStringSubmatch(json, -1)
   426  	for _, group := range matches {
   427  		message.Debugf("Found possible fuzzy match, Kind: %s, Value: %s", resource.GetKind(), group[1])
   428  		maybeImages[group[1]] = true
   429  	}
   430  
   431  	return matchedImages, maybeImages, nil
   432  }
   433  
   434  func findWhyResources(resources []*unstructured.Unstructured, whyImage, componentName, resourceName string, isChart bool) ([]string, error) {
   435  	foundWhyResources := []string{}
   436  	for _, resource := range resources {
   437  		bytes, err := yaml.Marshal(resource.Object)
   438  		if err != nil {
   439  			return nil, err
   440  		}
   441  		yaml := string(bytes)
   442  		resourceTypeKey := "manifest"
   443  		if isChart {
   444  			resourceTypeKey = "chart"
   445  		}
   446  
   447  		if strings.Contains(yaml, whyImage) {
   448  			fmt.Printf("component: %s\n%s: %s\nresource:\n\n%s\n", componentName, resourceTypeKey, resourceName, yaml)
   449  			foundWhyResources = append(foundWhyResources, resourceName)
   450  		}
   451  	}
   452  	return foundWhyResources, nil
   453  }
   454  
   455  // BuildImageMap looks for init container, ephemeral and regular container images.
   456  func buildImageMap(images imageMap, pod corev1.PodSpec) imageMap {
   457  	for _, container := range pod.InitContainers {
   458  		images[container.Image] = true
   459  	}
   460  	for _, container := range pod.Containers {
   461  		images[container.Image] = true
   462  	}
   463  	for _, container := range pod.EphemeralContainers {
   464  		images[container.Image] = true
   465  	}
   466  	return images
   467  }
   468  
   469  // SortImages returns a sorted list of images.
   470  func sortImages(images, compareWith imageMap) []string {
   471  	sortedImages := sort.StringSlice{}
   472  	for image := range images {
   473  		if !compareWith[image] || compareWith == nil {
   474  			// Check compareWith, if it exists only add if not in that list.
   475  			sortedImages = append(sortedImages, image)
   476  		}
   477  	}
   478  	sort.Sort(sortedImages)
   479  	return sortedImages
   480  }