Skip to content
Merged
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
14 changes: 14 additions & 0 deletions pkg/build/build_phase.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package build
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
Expand Down Expand Up @@ -1828,6 +1829,19 @@ func (phase *BuildPhase) prepareStageInstructions(ctx context.Context, img *imag
serviceLabels[imagePkg.WerfParentStageID] = baseStageImage.Image.GetStageDesc().StageID.String()
}

if phase.Conveyor.EnableSbom() && img.StapelImageConfig != nil {
if _, ok := serviceLabels[imagePkg.WerfParentStageID]; !ok {
serviceLabels[imagePkg.WerfParentStageID] = ""
}
record, err := json.Marshal(sbomStageRecord{
Stage: stg.Name(), StageDigest: stg.GetDigest(), ParentStageID: serviceLabels[imagePkg.WerfParentStageID],
})
if err != nil {
return fmt.Errorf("encode SBOM stage record: %w", err)
}
serviceLabels[imagePkg.WerfSBOMStageLabel] = string(record)
}
Comment on lines +1832 to +1843

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major (claim). A new service label with a per-stage value is new committed config → new stage content → every SBOM-enabled stapel stage rebuilds once after upgrade. The PR body says "without invalidating image-build caches". That sentence is false and should go; the one-time rebuild itself is fine and worth a line in the release notes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cache-invalidation claim does not match the actual lookup order. BuildPhase.onImageStage finds a suitable cached stage and returns before calling prepareStageInstructions; the new service label is written only after a miss. A newly committed image config has different content, but that does not change the recipe/anchor key used to select an existing stage.

b6a9ee3 adds regressions for both image-anchor reuse and an individual cached stage with SBOM enabled and no provenance label. Both pass, leave the old label absent and avoid rebuilding. A mutation rejecting such a cached stage fails the regression. The description now states the precise consequence: old image caches remain reusable, but their binary provenance remains unknown until a newly built chain supplies it. No release-note claim of an unavoidable rebuild was added.


// TODO: refactor this workaround required for the image spec stage.
stageImage.Image.SetBuildServiceLabels(serviceLabels)

Expand Down
55 changes: 55 additions & 0 deletions pkg/build/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"sync"
"sync/atomic"

cdx "github.com/CycloneDX/cyclonedx-go"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/registry"
v1 "github.com/google/go-containerregistry/pkg/v1"
Expand All @@ -24,20 +25,66 @@ import (
"github.com/werf/lockgate/pkg/distributed_locker"
"github.com/werf/lockgate/pkg/distributed_locker/optimistic_locking_store"
buildImage "github.com/werf/werf/v3/pkg/build/image"
"github.com/werf/werf/v3/pkg/build/signing"
"github.com/werf/werf/v3/pkg/build/stage"
"github.com/werf/werf/v3/pkg/build/verify_annotation"
"github.com/werf/werf/v3/pkg/config"
"github.com/werf/werf/v3/pkg/container_backend"
"github.com/werf/werf/v3/pkg/container_backend/stage_builder"
"github.com/werf/werf/v3/pkg/docker_registry"
"github.com/werf/werf/v3/pkg/git_repo"
"github.com/werf/werf/v3/pkg/git_repo/gitdata"
"github.com/werf/werf/v3/pkg/giterminism_manager"
imagePkg "github.com/werf/werf/v3/pkg/image"
"github.com/werf/werf/v3/pkg/opstats"
"github.com/werf/werf/v3/pkg/sbom/shippedscope"
"github.com/werf/werf/v3/pkg/storage"
"github.com/werf/werf/v3/pkg/storage/manager"
"github.com/werf/werf/v3/pkg/storage/synchronization/lock_manager"
"github.com/werf/werf/v3/pkg/true_git"
"github.com/werf/werf/v3/pkg/werf"
"github.com/werf/werf/v3/test/pkg/utils"
)

func newSBOMStageOrderImage(ctx context.Context) *buildImage.Image {
tmpDir, homeDir := ginkgo.GinkgoT().TempDir(), ginkgo.GinkgoT().TempDir()
gomega.Expect(werf.Init(tmpDir, homeDir)).To(gomega.Succeed())
gomega.Expect(true_git.Init(ctx, true_git.Options{})).To(gomega.Succeed())
gitDataManager, err := gitdata.GetHostGitDataManager(ctx)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(git_repo.Init(gitDataManager)).To(gomega.Succeed())
projectDir := filepath.Join(tmpDir, "project")
gomega.Expect(os.CopyFS(projectDir, os.DirFS("testdata/sbom_stage_order"))).To(gomega.Succeed())
for _, args := range [][]string{
{"init", "--initial-branch=main"},
{"config", "user.name", "Test"},
{"config", "user.email", "test@example.com"},
{"config", "commit.gpgsign", "false"},
{"add", "."},
{"commit", "-m", "initial"},
} {
utils.RunSucceedCommand(ctx, projectDir, "git", args...)
}
head := strings.TrimSpace(utils.SucceedCommandOutputString(ctx, projectDir, "git", "rev-parse", "HEAD"))
repo, err := git_repo.OpenLocalRepo(ctx, "own", projectDir, git_repo.OpenLocalRepoOptions{})
gomega.Expect(err).NotTo(gomega.HaveOccurred())
giterminismManager, err := giterminism_manager.NewManager(ctx, "", projectDir, repo, head, giterminism_manager.NewManagerOptions{})
gomega.Expect(err).NotTo(gomega.HaveOccurred())
_, werfConfig, err := config.GetWerfConfig(ctx, "", "", "", giterminismManager, config.WerfConfigOptions{})
gomega.Expect(err).NotTo(gomega.HaveOccurred())
imageConfig, ok := werfConfig.GetImage("app").(config.StapelImageInterface)
gomega.Expect(ok).To(gomega.BeTrue())
images, err := buildImage.MapStapelConfigToImages(ctx, werfConfig.Meta, imageConfig, "linux/amd64", false, buildImage.CommonImageOptions{
GiterminismManager: giterminismManager,
ProjectDir: projectDir, ProjectName: werfConfig.Meta.Project, ContainerWerfDir: "/.werf", TmpDir: tmpDir,
VerityAnnotationOptions: verify_annotation.Options{Enabled: true},
ManifestSigningOptions: signing.ManifestSigningOptions{Enabled: true},
})
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(images).To(gomega.HaveLen(1))
return images[0]
}

func newFinalRepoBuildPhase(ctx context.Context) (*BuildPhase, *manager.StorageManager) {
gomega.Expect(docker_registry.Init(ctx, false, false, nil, nil)).To(gomega.Succeed())
var storages []storage.StagesStorage
Expand Down Expand Up @@ -766,3 +813,11 @@ type publicationContainerBackend struct {
var _ container_backend.ContainerBackend = (*publicationContainerBackend)(nil)

func (b *publicationContainerBackend) HasStapelBuildSupport() bool { return true }

func recordSavedShippedScope(ctx context.Context, bom *cdx.BOM, backend imageSaver, werfImgName, imageName string) {
if !shippedscope.HasFileBackedComponents(bom) && !shippedscope.HasBinaryBackedComponents(ctx, bom) {
return
}
filesets, produced := collectShippedScope(ctx, backend, werfImgName, imageName, sbomStageProvenance{})
recordShippedScope(ctx, bom, filesets, produced, werfImgName)
}
18 changes: 18 additions & 0 deletions pkg/build/introspect_anchor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/werf/werf/v3/pkg/build/image"
"github.com/werf/werf/v3/pkg/build/stage"
"github.com/werf/werf/v3/pkg/config"
imagePkg "github.com/werf/werf/v3/pkg/image"
"github.com/werf/werf/v3/pkg/werf"
)
Expand Down Expand Up @@ -42,6 +43,23 @@ var _ = ginkgo.Describe("Introspecting a stage of an image reused by its content
return phase, img
}

ginkgo.DescribeTable("reuses pre-provenance caches with SBOM enabled", func(ctx ginkgo.SpecContext, reuseAnchor bool) {
phase, img := newPhase()
phase.Conveyor.werfConfig.Meta.Build.Sbom = &config.MetaBuildSbom{Enable: true}
img.StapelImageConfig = &config.StapelImage{StapelImageBase: &config.StapelImageBase{Name: "app"}}
if reuseAnchor {
gomega.Expect(phase.resolveAvailableContentAnchors(ctx)).To(gomega.Succeed())
gomega.Expect(img.AnchorReused).To(gomega.BeTrue())
gomega.Expect(img.GetContentTagDesc().Info.Labels).NotTo(gomega.HaveKey(imagePkg.WerfSBOMStageLabel))
} else {
stg := img.GetStages()[1]
gomega.Expect(phase.onImageStage(ctx, img, stg)).To(gomega.Succeed())
gomega.Expect(stg.GetMeta().Rebuilt).To(gomega.BeFalse())
gomega.Expect(stg.GetStageImage().Image.GetStageDesc().Info.Labels).NotTo(gomega.HaveKey(imagePkg.WerfSBOMStageLabel))
}
gomega.Expect(record.processedStages).To(gomega.BeEmpty())
}, ginkgo.Entry("image content anchor", true), ginkgo.Entry("individual cached stage", false))

ginkgo.DescribeTable("processes the stages instead of reusing the anchor",
func(ctx ginkgo.SpecContext, target IntrospectTarget) {
phase, img := newPhase(target)
Expand Down
136 changes: 136 additions & 0 deletions pkg/build/sbom_provenance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package build

import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"slices"
"strconv"
"strings"

"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"

"github.com/werf/common-go/pkg/util"
"github.com/werf/werf/v3/pkg/build/stage"
"github.com/werf/werf/v3/pkg/image"
"github.com/werf/werf/v3/pkg/sbom/shippedscope"
)

type sbomStageRecord struct {
Stage stage.StageName `json:"stage"`
StageDigest string `json:"stageDigest"`
ParentStageID string `json:"parentStageID"`
}

type sbomStageProvenance struct {
Layers shippedscope.ProducedLayers
DiffIDs []v1.Hash
}

func resolveSBOMStageProvenance(ctx context.Context, final *image.StageDesc, readConfig func(context.Context, string) (*v1.ConfigFile, error)) (sbomStageProvenance, error) {
unknown := sbomStageProvenance{}
if final == nil || final.StageID == nil || final.Info == nil || final.Info.Repository == "" {
return unknown, fmt.Errorf("resolve SBOM stages: final stage descriptor is incomplete")
}

ref := final.Info.RepoDigest
if ref == "" {
ref = final.Info.Name
}
current, err := readConfig(ctx, ref)
if err != nil {
return unknown, fmt.Errorf("read SBOM stage config %q: %w", ref, err)
}
if current == nil {
return unknown, fmt.Errorf("read SBOM stage config %q: empty config", ref)
}

result := sbomStageProvenance{DiffIDs: slices.Clone(current.RootFS.DiffIDs)}
id := final.StageID.String()
digest := final.StageID.Digest
seen := make(map[string]bool)
stages := []stage.StageName{
stage.From, stage.BeforeInstall, stage.DependenciesBeforeInstall, stage.GitArchive,
stage.Packages, stage.Install, stage.DependenciesAfterInstall, stage.BeforeSetup,
stage.DependenciesBeforeSetup, stage.Setup, stage.DependenciesAfterSetup,
stage.GitCache, stage.GitLatestPatch, stage.ImageSpec, stage.VerityAnnotation, stage.Sign,
}
previousPosition := len(stages)
for {
if err := ctx.Err(); err != nil {
return unknown, err
}
if seen[id] {
return unknown, fmt.Errorf("resolve SBOM stages: cyclic parent %q", id)
}
seen[id] = true

var record sbomStageRecord
if err := json.Unmarshal([]byte(current.Config.Labels[image.WerfSBOMStageLabel]), &record); err != nil {
return unknown, fmt.Errorf("read SBOM stage record %q: %w", id, err)
}
if record.StageDigest != digest || record.ParentStageID != current.Config.Labels[image.WerfParentStageID] {
return unknown, fmt.Errorf("resolve SBOM stages: record does not belong to stage %q", id)
}
position := slices.Index(stages, record.Stage)
if position < 0 || position >= previousPosition {
return unknown, fmt.Errorf("resolve SBOM stages: unexpected stage %q at %q", record.Stage, id)
}
previousPosition = position
if record.Stage == stage.From {
result.Layers.Known = true
slices.Reverse(result.Layers.Ranges)

return result, nil
}

parentDigest, timestamp, ok := strings.Cut(record.ParentStageID, "-")
if !ok || parentDigest == "" {
return unknown, fmt.Errorf("resolve SBOM stages: invalid parent %q", record.ParentStageID)
}
if _, err := hex.DecodeString(parentDigest); err != nil {
return unknown, fmt.Errorf("read SBOM parent digest %q: %w", parentDigest, err)
}
if ts, err := strconv.ParseInt(timestamp, 10, 64); err != nil || ts <= 0 {
return unknown, fmt.Errorf("resolve SBOM stages: invalid parent timestamp %q", timestamp)
}
parentRef, err := name.NewTag(final.Info.Repository + ":" + record.ParentStageID)
if err != nil {
return unknown, fmt.Errorf("parse SBOM parent reference: %w", err)
}
parent, err := readConfig(ctx, parentRef.Name())
if err != nil {
return unknown, fmt.Errorf("read SBOM parent config %q: %w", parentRef.Name(), err)
}
if parent == nil {
return unknown, fmt.Errorf("read SBOM parent config %q: empty config", parentRef.Name())
}
parentLayers := parent.RootFS.DiffIDs
currentLayers := current.RootFS.DiffIDs
if len(parentLayers) > len(currentLayers) || !slices.Equal(parentLayers, currentLayers[:len(parentLayers)]) {
return unknown, fmt.Errorf("resolve SBOM stages: parent layers are not a prefix of stage %q", id)
}
if record.Stage == stage.Install || record.Stage == stage.BeforeSetup || record.Stage == stage.Setup {
result.Layers.Ranges = append(result.Layers.Ranges, shippedscope.ProducedLayerRange{Start: len(parentLayers), End: len(currentLayers)})
}

current = parent
id = record.ParentStageID
digest = parentDigest
}
}

func sbomProvenanceChecksum(provenance sbomStageProvenance) string {
parts := []string{strconv.FormatBool(provenance.Layers.Known), "diffids"}
for _, diffID := range provenance.DiffIDs {
parts = append(parts, diffID.String())
}
parts = append(parts, "ranges")
for _, layerRange := range provenance.Layers.Ranges {
parts = append(parts, fmt.Sprintf("%d:%d", layerRange.Start, layerRange.End))
}

return util.Sha256Hash(parts...)
}
Loading
Loading