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 }