Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 154 additions & 4 deletions cmd/helmExecute.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,24 @@ import (
"path"
"path/filepath"

"github.com/SAP/jenkins-library/pkg/buildsettings"
"github.com/SAP/jenkins-library/pkg/command"
"github.com/SAP/jenkins-library/pkg/docker"
"github.com/SAP/jenkins-library/pkg/helm"
piperhttp "github.com/SAP/jenkins-library/pkg/http"
"github.com/SAP/jenkins-library/pkg/kubernetes"
"github.com/SAP/jenkins-library/pkg/log"
"github.com/SAP/jenkins-library/pkg/piperenv"
"github.com/SAP/jenkins-library/pkg/piperutils"
"github.com/SAP/jenkins-library/pkg/syft"
"github.com/SAP/jenkins-library/pkg/telemetry"
"github.com/SAP/jenkins-library/pkg/versioning"
)

// helmDockerConfigDir is the directory syft uses to authenticate against the
// container registry when scanning referenced images.
const helmDockerConfigDir = "/root/.docker"

func helmExecute(config helmExecuteOptions, telemetryData *telemetry.CustomData, commonPipelineEnvironment *helmExecuteCommonPipelineEnvironment) {
helmConfig := kubernetes.HelmExecuteOptions{
AdditionalParameters: config.AdditionalParameters,
Expand Down Expand Up @@ -73,13 +84,36 @@ func helmExecute(config helmExecuteOptions, telemetryData *telemetry.CustomData,

helmExecutor := kubernetes.NewHelmExecutor(helmConfig, utils, GeneralConfig.Verbose, log.Writer())

// dependencies required for SBOM generation (syft): a command runner to
// execute the syft binary, an http client to download it, and file utils.
execRunner := &command.Command{}
execRunner.Stdout(log.Writer())
execRunner.Stderr(log.Writer())
httpClient := &piperhttp.Client{}
fileUtils := &piperutils.Files{}

// error situations should stop execution through log.Entry().Fatal() call which leads to an os.Exit(1) in the end
if err := runHelmExecute(config, helmExecutor, utils, commonPipelineEnvironment); err != nil {
if err := runHelmExecute(config, helmExecutor, utils, commonPipelineEnvironment, execRunner, fileUtils, httpClient); err != nil {
log.Entry().WithError(err).Fatalf("step execution failed: %v", err)
}
}

func runHelmExecute(config helmExecuteOptions, helmExecutor kubernetes.HelmExecutor, utils fileHandler, commonPipelineEnvironment *helmExecuteCommonPipelineEnvironment) error {
func runHelmExecute(config helmExecuteOptions, helmExecutor kubernetes.HelmExecutor, utils fileHandler, commonPipelineEnvironment *helmExecuteCommonPipelineEnvironment, execRunner command.ExecRunner, fileUtils piperutils.FileUtils, httpClient piperhttp.Sender) error {
// SLC-29: record build-settings traceability for the step (independent of
// the SBOM/publish flow). Best-effort — a failure must not fail the step.
dockerImage, _ := GetDockerImageValue("helmExecute")
buildSettings := buildsettings.BuildOptions{
Publish: config.Publish,
CreateBOM: config.CreateBOM,
BuildSettingsInfo: config.BuildSettingsInfo,
DockerImage: dockerImage,
}
if info, err := buildsettings.CreateBuildSettingsInfo(&buildSettings, "helmExecute"); err != nil {
log.Entry().Warnf("failed to create build settings info: %v", err)
} else {
commonPipelineEnvironment.custom.buildSettingsInfo = info
}

if config.RenderValuesTemplate {
err := parseAndRenderCPETemplate(config, GeneralConfig.EnvRootPath, utils)
if err != nil {
Expand Down Expand Up @@ -117,16 +151,19 @@ func runHelmExecute(config helmExecuteOptions, helmExecutor kubernetes.HelmExecu
return fmt.Errorf("failed to execute helm publish: %v", err)
}
commonPipelineEnvironment.custom.helmChartURL = targetURL
if config.CreateBOM {
generateSBOMs(config, helmExecutor, execRunner, fileUtils, httpClient)
}
default:
if err := runHelmExecuteDefault(config, helmExecutor, commonPipelineEnvironment); err != nil {
if err := runHelmExecuteDefault(config, helmExecutor, commonPipelineEnvironment, execRunner, fileUtils, httpClient); err != nil {
return err
}
}

return nil
}

func runHelmExecuteDefault(config helmExecuteOptions, helmExecutor kubernetes.HelmExecutor, commonPipelineEnvironment *helmExecuteCommonPipelineEnvironment) error {
func runHelmExecuteDefault(config helmExecuteOptions, helmExecutor kubernetes.HelmExecutor, commonPipelineEnvironment *helmExecuteCommonPipelineEnvironment, execRunner command.ExecRunner, fileUtils piperutils.FileUtils, httpClient piperhttp.Sender) error {
if len(config.Dependency) > 0 {
if err := helmExecutor.RunHelmDependency(); err != nil {
return fmt.Errorf("failed to execute helm dependency: %v", err)
Expand All @@ -143,6 +180,119 @@ func runHelmExecuteDefault(config helmExecuteOptions, helmExecutor kubernetes.He
return fmt.Errorf("failed to execute helm publish: %v", err)
}
commonPipelineEnvironment.custom.helmChartURL = targetURL
if config.CreateBOM {
generateSBOMs(config, helmExecutor, execRunner, fileUtils, httpClient)
}
}

return nil
}

// generateSBOMs produces both SBOMs for the published chart, sharing a single
// discovered image set so the chart BOM and the container BOMs describe the
// same images. Both are best-effort: a failure is logged but never fails the
// step.
func generateSBOMs(config helmExecuteOptions, helmExecutor kubernetes.HelmExecutor, execRunner command.ExecRunner, fileUtils piperutils.FileUtils, httpClient piperhttp.Sender) {
images := discoverImages(config, helmExecutor)

if err := generateContainerSBOMs(config, images, execRunner, fileUtils, httpClient); err != nil {
log.Entry().Warnf("container SBOM generation failed: %v", err)
}

if err := helm.GenerateChartSBOM(config.ChartPath, "bom-helm.xml", images, fileUtils); err != nil {
log.Entry().Warnf("chart SBOM generation failed: %v", err)
} else {
log.Entry().Infof("helmExecute SBOM: generated chart SBOM bom-helm.xml for %s", config.ChartPath)
}
}

// generateContainerSBOMs generates a CycloneDX SBOM (bom-docker-<N>.xml) for
// each of the given container images, using Syft. The registry is derived from
// each full image reference.
func generateContainerSBOMs(config helmExecuteOptions, imageNameTags []string, execRunner command.ExecRunner, fileUtils piperutils.FileUtils, httpClient piperhttp.Sender) error {
if len(imageNameTags) == 0 {
log.Entry().Info("helmExecute SBOM: no container images available, skipping syft scan")
return nil
}

registryURL, err := docker.ContainerRegistryFromImage(imageNameTags[0])
if err != nil {
log.Entry().Infof("helmExecute SBOM: failed to derive registry from image %q: %v", imageNameTags[0], err)
return fmt.Errorf("failed to derive registry from image %q: %w", imageNameTags[0], err)
}

images := make([]string, 0, len(imageNameTags))
for _, image := range imageNameTags {
nameTag, err := docker.ContainerImageNameTagFromImage(image)
if err != nil {
log.Entry().Infof("helmExecute SBOM: failed to parse image %q: %v", image, err)
return fmt.Errorf("failed to parse image %q: %w", image, err)
}
images = append(images, nameTag)
}

if err := syft.GenerateSBOM(config.SyftDownloadURL, helmDockerConfigDir, execRunner, fileUtils, httpClient, registryURL, images); err != nil {
return err
}

// Syft omits the PURL for the root/parent component of an image BOM
// (anchore/syft#1408); without it the BOM fails CycloneDX validation with
// "Purl is missing for the root component!". Inject a clean docker PURL.
return injectContainerBOMPurls()
}

// discoverImages returns the container images referenced by the chart. It
// prefers images rendered by `helm template`; if templating fails or yields no
// images, it falls back to the containerImageNameTags CPE list.
func discoverImages(config helmExecuteOptions, helmExecutor kubernetes.HelmExecutor) []string {
manifests, err := helmExecutor.RunHelmTemplate()
if err != nil {
log.Entry().Warnf("helmExecute SBOM: helm template failed, falling back to CPE image list: %v", err)
return config.ContainerImageNameTags
}

images, err := kubernetes.ExtractImagesFromManifests(manifests)
if err != nil {
log.Entry().Warnf("helmExecute SBOM: failed to parse rendered manifests, falling back to CPE image list: %v", err)
return config.ContainerImageNameTags
}

if len(images) == 0 {
return config.ContainerImageNameTags
}
return images
}

// injectContainerBOMPurls sets a clean, registry-free docker PURL on the root
// component of every bom-docker-*.xml produced by Syft. Syft does not emit a
// PURL for the parent component (anchore/syft#1408), which makes the BOM fail
// CycloneDX validation. Best-effort: individual failures are logged, not fatal.
func injectContainerBOMPurls() error {
Comment thread
fabienfritz marked this conversation as resolved.
files, err := filepath.Glob("bom-docker-*.xml")
if err != nil || len(files) == 0 {
log.Entry().Debug("helmExecute SBOM: no bom-docker-*.xml files to update with a root PURL")
return nil
}

for _, file := range files {
component := piperutils.GetComponent(file)
if component.Name == "" {
log.Entry().Warnf("helmExecute SBOM: unable to read root component from %s, skipping PURL injection", file)
continue
}

// Build a clean, registry-free docker PURL (e.g. pkg:docker/nginx@1.25)
// for the root component — shared with kaniko (see
// piperutils.BuildRegistryFreeDockerPurl).
constructedPurl, _, _, _, err := piperutils.BuildRegistryFreeDockerPurl(component.Name, component.Version)
if err != nil {
log.Entry().Warnf("helmExecute SBOM: %v for %s", err, file)
continue
}

if err := piperutils.UpdatePurl(file, constructedPurl); err != nil {
log.Entry().Warnf("helmExecute SBOM: unable to update root purl in %s: %v", file, err)
}
}

return nil
Expand Down
106 changes: 105 additions & 1 deletion cmd/helmExecute_generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading