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

     1  // SPDX-License-Identifier: Apache-2.0
     2  // SPDX-FileCopyrightText: 2021-Present The Jackal Authors
     3  
     4  // Package creator contains functions for creating Jackal packages.
     5  package creator
     6  
     7  import (
     8  	"context"
     9  	"errors"
    10  	"fmt"
    11  	"os"
    12  	"path/filepath"
    13  	"strconv"
    14  	"strings"
    15  	"time"
    16  
    17  	"github.com/Racer159/jackal/src/config"
    18  	"github.com/Racer159/jackal/src/config/lang"
    19  	"github.com/Racer159/jackal/src/extensions/bigbang"
    20  	"github.com/Racer159/jackal/src/internal/packager/git"
    21  	"github.com/Racer159/jackal/src/internal/packager/helm"
    22  	"github.com/Racer159/jackal/src/internal/packager/images"
    23  	"github.com/Racer159/jackal/src/internal/packager/kustomize"
    24  	"github.com/Racer159/jackal/src/internal/packager/sbom"
    25  	"github.com/Racer159/jackal/src/pkg/layout"
    26  	"github.com/Racer159/jackal/src/pkg/message"
    27  	"github.com/Racer159/jackal/src/pkg/packager/actions"
    28  	"github.com/Racer159/jackal/src/pkg/packager/sources"
    29  	"github.com/Racer159/jackal/src/pkg/transform"
    30  	"github.com/Racer159/jackal/src/pkg/utils"
    31  	"github.com/Racer159/jackal/src/pkg/zoci"
    32  	"github.com/Racer159/jackal/src/types"
    33  	"github.com/defenseunicorns/pkg/helpers"
    34  	"github.com/defenseunicorns/pkg/oci"
    35  	"github.com/mholt/archiver/v3"
    36  )
    37  
    38  var (
    39  	// verify that PackageCreator implements Creator
    40  	_ Creator = (*PackageCreator)(nil)
    41  )
    42  
    43  // PackageCreator provides methods for creating normal (not skeleton) Jackal packages.
    44  type PackageCreator struct {
    45  	createOpts types.JackalCreateOptions
    46  
    47  	// TODO: (@lucasrod16) remove PackagerConfig once actions do not depend on it: https://github.com/Racer159/jackal/pull/2276
    48  	cfg *types.PackagerConfig
    49  }
    50  
    51  // NewPackageCreator returns a new PackageCreator.
    52  func NewPackageCreator(createOpts types.JackalCreateOptions, cfg *types.PackagerConfig, cwd string) *PackageCreator {
    53  	if createOpts.DifferentialPackagePath != "" && !filepath.IsAbs(createOpts.DifferentialPackagePath) {
    54  		createOpts.DifferentialPackagePath = filepath.Join(cwd, createOpts.DifferentialPackagePath)
    55  	}
    56  
    57  	return &PackageCreator{createOpts, cfg}
    58  }
    59  
    60  // LoadPackageDefinition loads and configures a jackal.yaml file during package create.
    61  func (pc *PackageCreator) LoadPackageDefinition(dst *layout.PackagePaths) (pkg types.JackalPackage, warnings []string, err error) {
    62  	pkg, warnings, err = dst.ReadJackalYAML()
    63  	if err != nil {
    64  		return types.JackalPackage{}, nil, err
    65  	}
    66  
    67  	pkg.Metadata.Architecture = config.GetArch(pkg.Metadata.Architecture)
    68  
    69  	// Compose components into a single jackal.yaml file
    70  	pkg, composeWarnings, err := ComposeComponents(pkg, pc.createOpts.Flavor)
    71  	if err != nil {
    72  		return types.JackalPackage{}, nil, err
    73  	}
    74  
    75  	warnings = append(warnings, composeWarnings...)
    76  
    77  	// After components are composed, template the active package.
    78  	pkg, templateWarnings, err := FillActiveTemplate(pkg, pc.createOpts.SetVariables)
    79  	if err != nil {
    80  		return types.JackalPackage{}, nil, fmt.Errorf("unable to fill values in template: %w", err)
    81  	}
    82  
    83  	warnings = append(warnings, templateWarnings...)
    84  
    85  	// After templates are filled process any create extensions
    86  	pkg.Components, err = pc.processExtensions(pkg.Components, dst, pkg.Metadata.YOLO)
    87  	if err != nil {
    88  		return types.JackalPackage{}, nil, err
    89  	}
    90  
    91  	// If we are creating a differential package, remove duplicate images and repos.
    92  	if pc.createOpts.DifferentialPackagePath != "" {
    93  		pkg.Build.Differential = true
    94  
    95  		diffData, err := loadDifferentialData(pc.createOpts.DifferentialPackagePath)
    96  		if err != nil {
    97  			return types.JackalPackage{}, nil, err
    98  		}
    99  
   100  		pkg.Build.DifferentialPackageVersion = diffData.DifferentialPackageVersion
   101  
   102  		versionsMatch := diffData.DifferentialPackageVersion == pkg.Metadata.Version
   103  		if versionsMatch {
   104  			return types.JackalPackage{}, nil, errors.New(lang.PkgCreateErrDifferentialSameVersion)
   105  		}
   106  
   107  		noVersionSet := diffData.DifferentialPackageVersion == "" || pkg.Metadata.Version == ""
   108  		if noVersionSet {
   109  			return types.JackalPackage{}, nil, errors.New(lang.PkgCreateErrDifferentialNoVersion)
   110  		}
   111  
   112  		pkg.Components, err = removeCopiesFromComponents(pkg.Components, diffData)
   113  		if err != nil {
   114  			return types.JackalPackage{}, nil, err
   115  		}
   116  	}
   117  
   118  	return pkg, warnings, nil
   119  }
   120  
   121  // Assemble assembles all of the package assets into Jackal's tmp directory layout.
   122  func (pc *PackageCreator) Assemble(dst *layout.PackagePaths, components []types.JackalComponent, arch string) error {
   123  	var imageList []transform.Image
   124  
   125  	skipSBOMFlagUsed := pc.createOpts.SkipSBOM
   126  	componentSBOMs := map[string]*layout.ComponentSBOM{}
   127  
   128  	for _, component := range components {
   129  		onCreate := component.Actions.OnCreate
   130  
   131  		onFailure := func() {
   132  			if err := actions.Run(pc.cfg, onCreate.Defaults, onCreate.OnFailure, nil); err != nil {
   133  				message.Debugf("unable to run component failure action: %s", err.Error())
   134  			}
   135  		}
   136  
   137  		if err := pc.addComponent(component, dst); err != nil {
   138  			onFailure()
   139  			return fmt.Errorf("unable to add component %q: %w", component.Name, err)
   140  		}
   141  
   142  		if err := actions.Run(pc.cfg, onCreate.Defaults, onCreate.OnSuccess, nil); err != nil {
   143  			onFailure()
   144  			return fmt.Errorf("unable to run component success action: %w", err)
   145  		}
   146  
   147  		if !skipSBOMFlagUsed {
   148  			componentSBOM, err := pc.getFilesToSBOM(component, dst)
   149  			if err != nil {
   150  				return fmt.Errorf("unable to create component SBOM: %w", err)
   151  			}
   152  			if componentSBOM != nil && len(componentSBOM.Files) > 0 {
   153  				componentSBOMs[component.Name] = componentSBOM
   154  			}
   155  		}
   156  
   157  		// Combine all component images into a single entry for efficient layer reuse.
   158  		for _, src := range component.Images {
   159  			refInfo, err := transform.ParseImageRef(src)
   160  			if err != nil {
   161  				return fmt.Errorf("failed to create ref for image %s: %w", src, err)
   162  			}
   163  			imageList = append(imageList, refInfo)
   164  		}
   165  	}
   166  
   167  	imageList = helpers.Unique(imageList)
   168  	var sbomImageList []transform.Image
   169  
   170  	// Images are handled separately from other component assets.
   171  	if len(imageList) > 0 {
   172  		message.HeaderInfof("📦 PACKAGE IMAGES")
   173  
   174  		dst.AddImages()
   175  
   176  		var pulled []images.ImgInfo
   177  		var err error
   178  
   179  		doPull := func() error {
   180  			imgConfig := images.ImageConfig{
   181  				ImagesPath:        dst.Images.Base,
   182  				ImageList:         imageList,
   183  				Insecure:          config.CommonOptions.Insecure,
   184  				Architectures:     []string{arch},
   185  				RegistryOverrides: pc.createOpts.RegistryOverrides,
   186  			}
   187  
   188  			pulled, err = imgConfig.PullAll()
   189  			return err
   190  		}
   191  
   192  		if err := helpers.Retry(doPull, 3, 5*time.Second, message.Warnf); err != nil {
   193  			return fmt.Errorf("unable to pull images after 3 attempts: %w", err)
   194  		}
   195  
   196  		for _, imgInfo := range pulled {
   197  			if err := dst.Images.AddV1Image(imgInfo.Img); err != nil {
   198  				return err
   199  			}
   200  			if imgInfo.HasImageLayers {
   201  				sbomImageList = append(sbomImageList, imgInfo.RefInfo)
   202  			}
   203  		}
   204  	}
   205  
   206  	// Ignore SBOM creation if the flag is set.
   207  	if skipSBOMFlagUsed {
   208  		message.Debug("Skipping image SBOM processing per --skip-sbom flag")
   209  	} else {
   210  		dst.AddSBOMs()
   211  		if err := sbom.Catalog(componentSBOMs, sbomImageList, dst); err != nil {
   212  			return fmt.Errorf("unable to create an SBOM catalog for the package: %w", err)
   213  		}
   214  	}
   215  
   216  	return nil
   217  }
   218  
   219  // Output does the following:
   220  //
   221  // - archives components
   222  //
   223  // - generates checksums for all package files
   224  //
   225  // - writes the loaded jackal.yaml to disk
   226  //
   227  // - signs the package
   228  //
   229  // - writes the Jackal package as a tarball to a local directory,
   230  // or an OCI registry based on the --output flag
   231  func (pc *PackageCreator) Output(dst *layout.PackagePaths, pkg *types.JackalPackage) (err error) {
   232  	// Process the component directories into compressed tarballs
   233  	// NOTE: This is purposefully being done after the SBOM cataloging
   234  	for _, component := range pkg.Components {
   235  		// Make the component a tar archive
   236  		if err := dst.Components.Archive(component, true); err != nil {
   237  			return fmt.Errorf("unable to archive component: %s", err.Error())
   238  		}
   239  	}
   240  
   241  	// Calculate all the checksums
   242  	pkg.Metadata.AggregateChecksum, err = dst.GenerateChecksums()
   243  	if err != nil {
   244  		return fmt.Errorf("unable to generate checksums for the package: %w", err)
   245  	}
   246  
   247  	if err := recordPackageMetadata(pkg, pc.createOpts); err != nil {
   248  		return err
   249  	}
   250  
   251  	if err := utils.WriteYaml(dst.JackalYAML, pkg, helpers.ReadUser); err != nil {
   252  		return fmt.Errorf("unable to write jackal.yaml: %w", err)
   253  	}
   254  
   255  	// Sign the package if a key has been provided
   256  	if err := dst.SignPackage(pc.createOpts.SigningKeyPath, pc.createOpts.SigningKeyPassword, !config.CommonOptions.Confirm); err != nil {
   257  		return err
   258  	}
   259  
   260  	// Create a remote ref + client for the package (if output is OCI)
   261  	// then publish the package to the remote.
   262  	if helpers.IsOCIURL(pc.createOpts.Output) {
   263  		ref, err := zoci.ReferenceFromMetadata(pc.createOpts.Output, &pkg.Metadata, &pkg.Build)
   264  		if err != nil {
   265  			return err
   266  		}
   267  		remote, err := zoci.NewRemote(ref, oci.PlatformForArch(config.GetArch()))
   268  		if err != nil {
   269  			return err
   270  		}
   271  
   272  		ctx := context.TODO()
   273  		err = remote.PublishPackage(ctx, pkg, dst, config.CommonOptions.OCIConcurrency)
   274  		if err != nil {
   275  			return fmt.Errorf("unable to publish package: %w", err)
   276  		}
   277  		message.HorizontalRule()
   278  		flags := ""
   279  		if config.CommonOptions.Insecure {
   280  			flags = "--insecure"
   281  		}
   282  		message.Title("To inspect/deploy/pull:", "")
   283  		message.JackalCommand("package inspect %s %s", helpers.OCIURLPrefix+remote.Repo().Reference.String(), flags)
   284  		message.JackalCommand("package deploy %s %s", helpers.OCIURLPrefix+remote.Repo().Reference.String(), flags)
   285  		message.JackalCommand("package pull %s %s", helpers.OCIURLPrefix+remote.Repo().Reference.String(), flags)
   286  	} else {
   287  		// Use the output path if the user specified it.
   288  		packageName := fmt.Sprintf("%s%s", sources.NameFromMetadata(pkg, pc.createOpts.IsSkeleton), sources.PkgSuffix(pkg.Metadata.Uncompressed))
   289  		tarballPath := filepath.Join(pc.createOpts.Output, packageName)
   290  
   291  		// Try to remove the package if it already exists.
   292  		_ = os.Remove(tarballPath)
   293  
   294  		// Create the package tarball.
   295  		if err := dst.ArchivePackage(tarballPath, pc.createOpts.MaxPackageSizeMB); err != nil {
   296  			return fmt.Errorf("unable to archive package: %w", err)
   297  		}
   298  	}
   299  
   300  	// Output the SBOM files into a directory if specified.
   301  	if pc.createOpts.ViewSBOM || pc.createOpts.SBOMOutputDir != "" {
   302  		outputSBOM := pc.createOpts.SBOMOutputDir
   303  		var sbomDir string
   304  		if err := dst.SBOMs.Unarchive(); err != nil {
   305  			return fmt.Errorf("unable to unarchive SBOMs: %w", err)
   306  		}
   307  		sbomDir = dst.SBOMs.Path
   308  
   309  		if outputSBOM != "" {
   310  			out, err := dst.SBOMs.OutputSBOMFiles(outputSBOM, pkg.Metadata.Name)
   311  			if err != nil {
   312  				return err
   313  			}
   314  			sbomDir = out
   315  		}
   316  
   317  		if pc.createOpts.ViewSBOM {
   318  			sbom.ViewSBOMFiles(sbomDir)
   319  		}
   320  	}
   321  	return nil
   322  }
   323  
   324  func (pc *PackageCreator) processExtensions(components []types.JackalComponent, layout *layout.PackagePaths, isYOLO bool) (processedComponents []types.JackalComponent, err error) {
   325  	// Create component paths and process extensions for each component.
   326  	for _, c := range components {
   327  		componentPaths, err := layout.Components.Create(c)
   328  		if err != nil {
   329  			return nil, err
   330  		}
   331  
   332  		// Big Bang
   333  		if c.Extensions.BigBang != nil {
   334  			if c, err = bigbang.Run(isYOLO, componentPaths, c); err != nil {
   335  				return nil, fmt.Errorf("unable to process bigbang extension: %w", err)
   336  			}
   337  		}
   338  
   339  		processedComponents = append(processedComponents, c)
   340  	}
   341  
   342  	return processedComponents, nil
   343  }
   344  
   345  func (pc *PackageCreator) addComponent(component types.JackalComponent, dst *layout.PackagePaths) error {
   346  	message.HeaderInfof("📦 %s COMPONENT", strings.ToUpper(component.Name))
   347  
   348  	componentPaths, err := dst.Components.Create(component)
   349  	if err != nil {
   350  		return err
   351  	}
   352  
   353  	onCreate := component.Actions.OnCreate
   354  	if err := actions.Run(pc.cfg, onCreate.Defaults, onCreate.Before, nil); err != nil {
   355  		return fmt.Errorf("unable to run component before action: %w", err)
   356  	}
   357  
   358  	// If any helm charts are defined, process them.
   359  	for _, chart := range component.Charts {
   360  		helmCfg := helm.New(chart, componentPaths.Charts, componentPaths.Values)
   361  		if err := helmCfg.PackageChart(componentPaths.Charts); err != nil {
   362  			return err
   363  		}
   364  	}
   365  
   366  	for filesIdx, file := range component.Files {
   367  		message.Debugf("Loading %#v", file)
   368  
   369  		rel := filepath.Join(layout.FilesDir, strconv.Itoa(filesIdx), filepath.Base(file.Target))
   370  		dst := filepath.Join(componentPaths.Base, rel)
   371  		destinationDir := filepath.Dir(dst)
   372  
   373  		if helpers.IsURL(file.Source) {
   374  			if file.ExtractPath != "" {
   375  				// get the compressedFileName from the source
   376  				compressedFileName, err := helpers.ExtractBasePathFromURL(file.Source)
   377  				if err != nil {
   378  					return fmt.Errorf(lang.ErrFileNameExtract, file.Source, err.Error())
   379  				}
   380  
   381  				compressedFile := filepath.Join(componentPaths.Temp, compressedFileName)
   382  
   383  				// If the file is an archive, download it to the componentPath.Temp
   384  				if err := utils.DownloadToFile(file.Source, compressedFile, component.DeprecatedCosignKeyPath); err != nil {
   385  					return fmt.Errorf(lang.ErrDownloading, file.Source, err.Error())
   386  				}
   387  
   388  				err = archiver.Extract(compressedFile, file.ExtractPath, destinationDir)
   389  				if err != nil {
   390  					return fmt.Errorf(lang.ErrFileExtract, file.ExtractPath, compressedFileName, err.Error())
   391  				}
   392  			} else {
   393  				if err := utils.DownloadToFile(file.Source, dst, component.DeprecatedCosignKeyPath); err != nil {
   394  					return fmt.Errorf(lang.ErrDownloading, file.Source, err.Error())
   395  				}
   396  			}
   397  		} else {
   398  			if file.ExtractPath != "" {
   399  				if err := archiver.Extract(file.Source, file.ExtractPath, destinationDir); err != nil {
   400  					return fmt.Errorf(lang.ErrFileExtract, file.ExtractPath, file.Source, err.Error())
   401  				}
   402  			} else {
   403  				if err := helpers.CreatePathAndCopy(file.Source, dst); err != nil {
   404  					return fmt.Errorf("unable to copy file %s: %w", file.Source, err)
   405  				}
   406  			}
   407  		}
   408  
   409  		if file.ExtractPath != "" {
   410  			// Make sure dst reflects the actual file or directory.
   411  			updatedExtractedFileOrDir := filepath.Join(destinationDir, file.ExtractPath)
   412  			if updatedExtractedFileOrDir != dst {
   413  				if err := os.Rename(updatedExtractedFileOrDir, dst); err != nil {
   414  					return fmt.Errorf(lang.ErrWritingFile, dst, err)
   415  				}
   416  			}
   417  		}
   418  
   419  		// Abort packaging on invalid shasum (if one is specified).
   420  		if file.Shasum != "" {
   421  			if err := helpers.SHAsMatch(dst, file.Shasum); err != nil {
   422  				return err
   423  			}
   424  		}
   425  
   426  		if file.Executable || helpers.IsDir(dst) {
   427  			_ = os.Chmod(dst, helpers.ReadWriteExecuteUser)
   428  		} else {
   429  			_ = os.Chmod(dst, helpers.ReadWriteUser)
   430  		}
   431  	}
   432  
   433  	if len(component.DataInjections) > 0 {
   434  		spinner := message.NewProgressSpinner("Loading data injections")
   435  		defer spinner.Stop()
   436  
   437  		for dataIdx, data := range component.DataInjections {
   438  			spinner.Updatef("Copying data injection %s for %s", data.Target.Path, data.Target.Selector)
   439  
   440  			rel := filepath.Join(layout.DataInjectionsDir, strconv.Itoa(dataIdx), filepath.Base(data.Target.Path))
   441  			dst := filepath.Join(componentPaths.Base, rel)
   442  
   443  			if helpers.IsURL(data.Source) {
   444  				if err := utils.DownloadToFile(data.Source, dst, component.DeprecatedCosignKeyPath); err != nil {
   445  					return fmt.Errorf(lang.ErrDownloading, data.Source, err.Error())
   446  				}
   447  			} else {
   448  				if err := helpers.CreatePathAndCopy(data.Source, dst); err != nil {
   449  					return fmt.Errorf("unable to copy data injection %s: %s", data.Source, err.Error())
   450  				}
   451  			}
   452  		}
   453  		spinner.Success()
   454  	}
   455  
   456  	if len(component.Manifests) > 0 {
   457  		// Get the proper count of total manifests to add.
   458  		manifestCount := 0
   459  
   460  		for _, manifest := range component.Manifests {
   461  			manifestCount += len(manifest.Files)
   462  			manifestCount += len(manifest.Kustomizations)
   463  		}
   464  
   465  		spinner := message.NewProgressSpinner("Loading %d K8s manifests", manifestCount)
   466  		defer spinner.Stop()
   467  
   468  		// Iterate over all manifests.
   469  		for _, manifest := range component.Manifests {
   470  			for fileIdx, path := range manifest.Files {
   471  				rel := filepath.Join(layout.ManifestsDir, fmt.Sprintf("%s-%d.yaml", manifest.Name, fileIdx))
   472  				dst := filepath.Join(componentPaths.Base, rel)
   473  
   474  				// Copy manifests without any processing.
   475  				spinner.Updatef("Copying manifest %s", path)
   476  				if helpers.IsURL(path) {
   477  					if err := utils.DownloadToFile(path, dst, component.DeprecatedCosignKeyPath); err != nil {
   478  						return fmt.Errorf(lang.ErrDownloading, path, err.Error())
   479  					}
   480  				} else {
   481  					if err := helpers.CreatePathAndCopy(path, dst); err != nil {
   482  						return fmt.Errorf("unable to copy manifest %s: %w", path, err)
   483  					}
   484  				}
   485  			}
   486  
   487  			for kustomizeIdx, path := range manifest.Kustomizations {
   488  				// Generate manifests from kustomizations and place in the package.
   489  				spinner.Updatef("Building kustomization for %s", path)
   490  
   491  				kname := fmt.Sprintf("kustomization-%s-%d.yaml", manifest.Name, kustomizeIdx)
   492  				rel := filepath.Join(layout.ManifestsDir, kname)
   493  				dst := filepath.Join(componentPaths.Base, rel)
   494  
   495  				if err := kustomize.Build(path, dst, manifest.KustomizeAllowAnyDirectory); err != nil {
   496  					return fmt.Errorf("unable to build kustomization %s: %w", path, err)
   497  				}
   498  			}
   499  		}
   500  		spinner.Success()
   501  	}
   502  
   503  	// Load all specified git repos.
   504  	if len(component.Repos) > 0 {
   505  		spinner := message.NewProgressSpinner("Loading %d git repos", len(component.Repos))
   506  		defer spinner.Stop()
   507  
   508  		for _, url := range component.Repos {
   509  			// Pull all the references if there is no `@` in the string.
   510  			gitCfg := git.NewWithSpinner(types.GitServerInfo{}, spinner)
   511  			if err := gitCfg.Pull(url, componentPaths.Repos, false); err != nil {
   512  				return fmt.Errorf("unable to pull git repo %s: %w", url, err)
   513  			}
   514  		}
   515  		spinner.Success()
   516  	}
   517  
   518  	if err := actions.Run(pc.cfg, onCreate.Defaults, onCreate.After, nil); err != nil {
   519  		return fmt.Errorf("unable to run component after action: %w", err)
   520  	}
   521  
   522  	return nil
   523  }
   524  
   525  func (pc *PackageCreator) getFilesToSBOM(component types.JackalComponent, dst *layout.PackagePaths) (*layout.ComponentSBOM, error) {
   526  	componentPaths, err := dst.Components.Create(component)
   527  	if err != nil {
   528  		return nil, err
   529  	}
   530  	// Create an struct to hold the SBOM information for this component.
   531  	componentSBOM := &layout.ComponentSBOM{
   532  		Files:     []string{},
   533  		Component: componentPaths,
   534  	}
   535  
   536  	appendSBOMFiles := func(path string) {
   537  		if helpers.IsDir(path) {
   538  			files, _ := helpers.RecursiveFileList(path, nil, false)
   539  			componentSBOM.Files = append(componentSBOM.Files, files...)
   540  		} else {
   541  			componentSBOM.Files = append(componentSBOM.Files, path)
   542  		}
   543  	}
   544  
   545  	for filesIdx, file := range component.Files {
   546  		path := filepath.Join(componentPaths.Files, strconv.Itoa(filesIdx), filepath.Base(file.Target))
   547  		appendSBOMFiles(path)
   548  	}
   549  
   550  	for dataIdx, data := range component.DataInjections {
   551  		path := filepath.Join(componentPaths.DataInjections, strconv.Itoa(dataIdx), filepath.Base(data.Target.Path))
   552  
   553  		appendSBOMFiles(path)
   554  	}
   555  
   556  	return componentSBOM, nil
   557  }