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

     1  // SPDX-License-Identifier: Apache-2.0
     2  // SPDX-FileCopyrightText: 2021-Present The Jackal Authors
     3  
     4  // Package layout contains functions for interacting with Jackal's package layout on disk.
     5  package layout
     6  
     7  import (
     8  	"fmt"
     9  	"os"
    10  	"path/filepath"
    11  	"slices"
    12  	"strings"
    13  
    14  	"github.com/Masterminds/semver/v3"
    15  	"github.com/Racer159/jackal/src/pkg/interactive"
    16  	"github.com/Racer159/jackal/src/pkg/message"
    17  	"github.com/Racer159/jackal/src/pkg/packager/deprecated"
    18  	"github.com/Racer159/jackal/src/pkg/utils"
    19  	"github.com/Racer159/jackal/src/types"
    20  	"github.com/defenseunicorns/pkg/helpers"
    21  	"github.com/google/go-containerregistry/pkg/crane"
    22  	"github.com/mholt/archiver/v3"
    23  	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
    24  )
    25  
    26  // PackagePaths is the default package layout.
    27  type PackagePaths struct {
    28  	Base       string
    29  	JackalYAML string
    30  	Checksums  string
    31  
    32  	Signature string
    33  
    34  	Components Components
    35  	SBOMs      SBOMs
    36  	Images     Images
    37  
    38  	isLegacyLayout bool
    39  }
    40  
    41  // InjectionMadnessPaths contains paths for injection madness.
    42  type InjectionMadnessPaths struct {
    43  	InjectionBinary      string
    44  	SeedImagesDir        string
    45  	InjectorPayloadTarGz string
    46  }
    47  
    48  // New returns a new PackagePaths struct.
    49  func New(baseDir string) *PackagePaths {
    50  	return &PackagePaths{
    51  		Base:       baseDir,
    52  		JackalYAML: filepath.Join(baseDir, JackalYAML),
    53  		Checksums:  filepath.Join(baseDir, Checksums),
    54  		Components: Components{
    55  			Base: filepath.Join(baseDir, ComponentsDir),
    56  		},
    57  	}
    58  }
    59  
    60  // ReadJackalYAML reads a jackal.yaml file into memory,
    61  // checks if it's using the legacy layout, and migrates deprecated component configs.
    62  func (pp *PackagePaths) ReadJackalYAML() (pkg types.JackalPackage, warnings []string, err error) {
    63  	if err := utils.ReadYaml(pp.JackalYAML, &pkg); err != nil {
    64  		return types.JackalPackage{}, nil, fmt.Errorf("unable to read jackal.yaml: %w", err)
    65  	}
    66  
    67  	if pp.IsLegacyLayout() {
    68  		warnings = append(warnings, "Detected deprecated package layout, migrating to new layout - support for this package will be dropped in v1.0.0")
    69  	}
    70  
    71  	if len(pkg.Build.Migrations) > 0 {
    72  		var componentWarnings []string
    73  		for idx, component := range pkg.Components {
    74  			// Handle component configuration deprecations
    75  			pkg.Components[idx], componentWarnings = deprecated.MigrateComponent(pkg.Build, component)
    76  			warnings = append(warnings, componentWarnings...)
    77  		}
    78  	}
    79  
    80  	return pkg, warnings, nil
    81  }
    82  
    83  // MigrateLegacy migrates a legacy package layout to the new layout.
    84  func (pp *PackagePaths) MigrateLegacy() (err error) {
    85  	var pkg types.JackalPackage
    86  	base := pp.Base
    87  
    88  	// legacy layout does not contain a checksums file, nor a signature
    89  	if helpers.InvalidPath(pp.Checksums) && pp.Signature == "" {
    90  		if err := utils.ReadYaml(pp.JackalYAML, &pkg); err != nil {
    91  			return err
    92  		}
    93  		buildVer, err := semver.NewVersion(pkg.Build.Version)
    94  		if err != nil {
    95  			return err
    96  		}
    97  		if !buildVer.LessThan(semver.MustParse("v0.25.0")) {
    98  			return nil
    99  		}
   100  		pp.isLegacyLayout = true
   101  	} else {
   102  		return nil
   103  	}
   104  
   105  	// Migrate legacy sboms
   106  	legacySBOMs := filepath.Join(base, "sboms")
   107  	if !helpers.InvalidPath(legacySBOMs) {
   108  		pp = pp.AddSBOMs()
   109  		message.Debugf("Migrating %q to %q", legacySBOMs, pp.SBOMs.Path)
   110  		if err := os.Rename(legacySBOMs, pp.SBOMs.Path); err != nil {
   111  			return err
   112  		}
   113  	}
   114  
   115  	// Migrate legacy images
   116  	legacyImagesTar := filepath.Join(base, "images.tar")
   117  	if !helpers.InvalidPath(legacyImagesTar) {
   118  		pp = pp.AddImages()
   119  		message.Debugf("Migrating %q to %q", legacyImagesTar, pp.Images.Base)
   120  		defer os.Remove(legacyImagesTar)
   121  		imgTags := []string{}
   122  		for _, component := range pkg.Components {
   123  			imgTags = append(imgTags, component.Images...)
   124  		}
   125  		// convert images to oci layout
   126  		// until this for-loop is complete, there will be a duplication of images, resulting in some wasted space
   127  		tagToDigest := make(map[string]string)
   128  		for _, tag := range imgTags {
   129  			img, err := crane.LoadTag(legacyImagesTar, tag)
   130  			if err != nil {
   131  				return err
   132  			}
   133  			if err := crane.SaveOCI(img, pp.Images.Base); err != nil {
   134  				return err
   135  			}
   136  			// Get the image digest so we can set an annotation in the image.json later
   137  			imgDigest, err := img.Digest()
   138  			if err != nil {
   139  				return err
   140  			}
   141  			tagToDigest[tag] = imgDigest.String()
   142  
   143  			if err := pp.Images.AddV1Image(img); err != nil {
   144  				return err
   145  			}
   146  		}
   147  		if err := utils.AddImageNameAnnotation(pp.Images.Base, tagToDigest); err != nil {
   148  			return err
   149  		}
   150  	}
   151  
   152  	// Migrate legacy components
   153  	//
   154  	// Migration of paths within components occurs during `deploy`
   155  	// no other operation should need to know about legacy component paths
   156  	for _, component := range pkg.Components {
   157  		_, err := pp.Components.Create(component)
   158  		if err != nil {
   159  			return err
   160  		}
   161  	}
   162  
   163  	return nil
   164  }
   165  
   166  // IsLegacyLayout returns true if the package is using the legacy layout.
   167  func (pp *PackagePaths) IsLegacyLayout() bool {
   168  	return pp.isLegacyLayout
   169  }
   170  
   171  // SignPackage signs the jackal.yaml in a Jackal package.
   172  func (pp *PackagePaths) SignPackage(signingKeyPath, signingKeyPassword string, isInteractive bool) error {
   173  	if signingKeyPath == "" {
   174  		return nil
   175  	}
   176  
   177  	pp.Signature = filepath.Join(pp.Base, Signature)
   178  
   179  	passwordFunc := func(_ bool) ([]byte, error) {
   180  		if signingKeyPassword != "" {
   181  			return []byte(signingKeyPassword), nil
   182  		}
   183  		if !isInteractive {
   184  			return nil, nil
   185  		}
   186  		return interactive.PromptSigPassword()
   187  	}
   188  	_, err := utils.CosignSignBlob(pp.JackalYAML, pp.Signature, signingKeyPath, passwordFunc)
   189  	if err != nil {
   190  		return fmt.Errorf("unable to sign the package: %w", err)
   191  	}
   192  
   193  	return nil
   194  }
   195  
   196  // GenerateChecksums walks through all of the files starting at the base path and generates a checksum file.
   197  //
   198  // Each file within the basePath represents a layer within the Jackal package.
   199  //
   200  // Returns a SHA256 checksum of the checksums.txt file.
   201  func (pp *PackagePaths) GenerateChecksums() (string, error) {
   202  	var checksumsData = []string{}
   203  
   204  	for rel, abs := range pp.Files() {
   205  		if rel == JackalYAML || rel == Checksums {
   206  			continue
   207  		}
   208  
   209  		sum, err := helpers.GetSHA256OfFile(abs)
   210  		if err != nil {
   211  			return "", err
   212  		}
   213  		checksumsData = append(checksumsData, fmt.Sprintf("%s %s", sum, rel))
   214  	}
   215  	slices.Sort(checksumsData)
   216  
   217  	// Create the checksums file
   218  	if err := os.WriteFile(pp.Checksums, []byte(strings.Join(checksumsData, "\n")+"\n"), helpers.ReadWriteUser); err != nil {
   219  		return "", err
   220  	}
   221  
   222  	// Calculate the checksum of the checksum file
   223  	return helpers.GetSHA256OfFile(pp.Checksums)
   224  }
   225  
   226  // ArchivePackage creates an archive for a Jackal package.
   227  func (pp *PackagePaths) ArchivePackage(destinationTarball string, maxPackageSizeMB int) error {
   228  	spinner := message.NewProgressSpinner("Writing %s to %s", pp.Base, destinationTarball)
   229  	defer spinner.Stop()
   230  
   231  	// Make the archive
   232  	archiveSrc := []string{pp.Base + string(os.PathSeparator)}
   233  	if err := archiver.Archive(archiveSrc, destinationTarball); err != nil {
   234  		return fmt.Errorf("unable to create package: %w", err)
   235  	}
   236  	spinner.Updatef("Wrote %s to %s", pp.Base, destinationTarball)
   237  
   238  	fi, err := os.Stat(destinationTarball)
   239  	if err != nil {
   240  		return fmt.Errorf("unable to read the package archive: %w", err)
   241  	}
   242  	spinner.Successf("Package saved to %q", destinationTarball)
   243  
   244  	// Convert Megabytes to bytes.
   245  	chunkSize := maxPackageSizeMB * 1000 * 1000
   246  
   247  	// If a chunk size was specified and the package is larger than the chunk size, split it into chunks.
   248  	if maxPackageSizeMB > 0 && fi.Size() > int64(chunkSize) {
   249  		if fi.Size()/int64(chunkSize) > 999 {
   250  			return fmt.Errorf("unable to split the package archive into multiple files: must be less than 1,000 files")
   251  		}
   252  		message.Notef("Package is larger than %dMB, splitting into multiple files", maxPackageSizeMB)
   253  		err := utils.SplitFile(destinationTarball, chunkSize)
   254  		if err != nil {
   255  			return fmt.Errorf("unable to split the package archive into multiple files: %w", err)
   256  		}
   257  	}
   258  	return nil
   259  }
   260  
   261  // AddImages sets the default image paths.
   262  func (pp *PackagePaths) AddImages() *PackagePaths {
   263  	pp.Images.Base = filepath.Join(pp.Base, ImagesDir)
   264  	pp.Images.OCILayout = filepath.Join(pp.Images.Base, OCILayout)
   265  	pp.Images.Index = filepath.Join(pp.Images.Base, IndexJSON)
   266  	return pp
   267  }
   268  
   269  // AddSBOMs sets the default sbom paths.
   270  func (pp *PackagePaths) AddSBOMs() *PackagePaths {
   271  	pp.SBOMs = SBOMs{
   272  		Path: filepath.Join(pp.Base, SBOMDir),
   273  	}
   274  	return pp
   275  }
   276  
   277  // SetFromLayers maps layers to package paths.
   278  func (pp *PackagePaths) SetFromLayers(layers []ocispec.Descriptor) {
   279  	paths := []string{}
   280  	for _, layer := range layers {
   281  		if layer.Annotations[ocispec.AnnotationTitle] != "" {
   282  			paths = append(paths, layer.Annotations[ocispec.AnnotationTitle])
   283  		}
   284  	}
   285  	pp.SetFromPaths(paths)
   286  }
   287  
   288  // SetFromPaths maps paths to package paths.
   289  func (pp *PackagePaths) SetFromPaths(paths []string) {
   290  	for _, rel := range paths {
   291  		// Convert from the standard '/' to the OS path separator for Windows support
   292  		switch path := filepath.FromSlash(rel); {
   293  		case path == JackalYAML:
   294  			pp.JackalYAML = filepath.Join(pp.Base, path)
   295  		case path == Signature:
   296  			pp.Signature = filepath.Join(pp.Base, path)
   297  		case path == Checksums:
   298  			pp.Checksums = filepath.Join(pp.Base, path)
   299  		case path == SBOMTar:
   300  			pp.SBOMs.Path = filepath.Join(pp.Base, path)
   301  		case path == OCILayoutPath:
   302  			pp.Images.OCILayout = filepath.Join(pp.Base, path)
   303  		case path == IndexPath:
   304  			pp.Images.Index = filepath.Join(pp.Base, path)
   305  		case strings.HasPrefix(path, ImagesBlobsDir):
   306  			if pp.Images.Base == "" {
   307  				pp.Images.Base = filepath.Join(pp.Base, ImagesDir)
   308  			}
   309  			pp.Images.AddBlob(filepath.Base(path))
   310  		case strings.HasPrefix(path, ComponentsDir) && filepath.Ext(path) == ".tar":
   311  			if pp.Components.Base == "" {
   312  				pp.Components.Base = filepath.Join(pp.Base, ComponentsDir)
   313  			}
   314  			componentName := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
   315  			if pp.Components.Tarballs == nil {
   316  				pp.Components.Tarballs = make(map[string]string)
   317  			}
   318  			pp.Components.Tarballs[componentName] = filepath.Join(pp.Base, path)
   319  		default:
   320  			message.Debug("ignoring path", path)
   321  		}
   322  	}
   323  }
   324  
   325  // Files returns a map of all the files in the package.
   326  func (pp *PackagePaths) Files() map[string]string {
   327  	pathMap := make(map[string]string)
   328  
   329  	stripBase := func(path string) string {
   330  		rel, _ := filepath.Rel(pp.Base, path)
   331  		// Convert from the OS path separator to the standard '/' for Windows support
   332  		return filepath.ToSlash(rel)
   333  	}
   334  
   335  	add := func(path string) {
   336  		if path == "" {
   337  			return
   338  		}
   339  		pathMap[stripBase(path)] = path
   340  	}
   341  
   342  	add(pp.JackalYAML)
   343  	add(pp.Signature)
   344  	add(pp.Checksums)
   345  
   346  	add(pp.Images.OCILayout)
   347  	add(pp.Images.Index)
   348  	for _, blob := range pp.Images.Blobs {
   349  		add(blob)
   350  	}
   351  
   352  	for _, tarball := range pp.Components.Tarballs {
   353  		add(tarball)
   354  	}
   355  
   356  	if pp.SBOMs.IsTarball() {
   357  		add(pp.SBOMs.Path)
   358  	}
   359  	return pathMap
   360  }