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 }