diff --git a/.specify/feature.json b/.specify/feature.json index 97b1da2150..2fb32920b5 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/020-elf-signing-anchor-digest" + "feature_directory": "specs/020-sbom-vex-build-stages" } diff --git a/AGENTS.md b/AGENTS.md index e2caa35832..7eda05ef77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,8 +80,6 @@ Correct: `task test:unit paths="./pkg/sbom/..." -- -focus=MyTest` - `task deps:install:pm` — extract the pm binary (linux/amd64) to `dest=` (default `./bin/pm`). Optional. -`format` and `lint*` come from a remote taskfile ([werf/common-ci](https://github.com/werf/common-ci)), so they need `TASK_X_REMOTE_TASKFILES=1` and network access. - ## Verifying changes (MANDATORY) After changing Go code, run these in order — `task format` mutates files, so it goes first: diff --git a/pkg/build/artifact_propagation.go b/pkg/build/artifact_propagation.go new file mode 100644 index 0000000000..f436a7aba8 --- /dev/null +++ b/pkg/build/artifact_propagation.go @@ -0,0 +1,100 @@ +package build + +import ( + "context" + "fmt" + + "github.com/werf/logboek" + "github.com/werf/werf/v2/pkg/image" + "github.com/werf/werf/v2/pkg/oci/artifact" + "github.com/werf/werf/v2/pkg/storage" + "github.com/werf/werf/v2/pkg/storage/manager" +) + +func ensureAttachedArtifacts(ctx context.Context, repository, digest string) error { + if repository == "" || repository == storage.LocalStorageAddress || digest == "" { + return fmt.Errorf("artifact source descriptor is incomplete") + } + + index, err := artifact.PullFallbackIndex(ctx, repository, digest) + if err != nil { + return fmt.Errorf("check source artifact index %s@%s: %w", repository, digest, err) + } + + manifest, err := index.IndexManifest() + if err != nil { + return fmt.Errorf("read source artifact index %s@%s: %w", repository, digest, err) + } + if len(manifest.Manifests) == 0 { + return fmt.Errorf("source image %s@%s has no attached artifacts", repository, digest) + } + + return nil +} + +func propagateArtifacts(ctx context.Context, projectName, imageName string, source, destination *image.StageDesc, caches []storage.StagesStorage, sourceStorages ...storage.StagesStorage) error { + var sourceStorage storage.StagesStorage + if len(sourceStorages) > 0 { + sourceStorage = sourceStorages[0] + } + return propagateArtifactsWithManager(ctx, projectName, imageName, source, destination, caches, sourceStorage, nil, nil) +} + +func propagateArtifactsWithManager(ctx context.Context, projectName, imageName string, source, destination *image.StageDesc, caches []storage.StagesStorage, sourceStorage, destinationStorage storage.StagesStorage, storageManager manager.StorageManagerInterface) error { + if source == nil || source.Info == nil { + return fmt.Errorf("source image descriptor is unavailable") + } + if source.Info.Repository == "" || source.Info.Repository == storage.LocalStorageAddress { + return nil + } + + if sourceStorage == nil { + sourceStorage = &storage.RepoStagesStorage{RepoAddress: source.Info.Repository} + } + + copyArtifacts := func(ctx context.Context, sourceStorage storage.StagesStorage, sourceDigest string, destinationStorage storage.StagesStorage, destinationDigest string) error { + if storageManager != nil { + return storageManager.CopyAttachedArtifacts(ctx, sourceStorage, sourceDigest, destinationStorage, destinationDigest) + } + return sourceStorage.CopyAttachedArtifacts(ctx, sourceStorage.Address(), sourceDigest, destinationStorage.Address(), destinationDigest) + } + + if destination != nil && destination.Info != nil && + destination.Info.Repository != "" && + destination.Info.Repository != storage.LocalStorageAddress && + destination.Info.Repository != source.Info.Repository { + if destinationStorage == nil || destinationStorage.Address() != destination.Info.Repository { + destinationStorage = &storage.RepoStagesStorage{RepoAddress: destination.Info.Repository} + } + if err := logboek.Context(ctx).Default().LogProcess("image %s: copy artifacts into final repo %s", imageName, destination.Info.Repository).DoError(func() error { + return copyArtifacts(ctx, sourceStorage, source.Info.GetDigest(), destinationStorage, destination.Info.GetDigest()) + }); err != nil { + return fmt.Errorf("copy attached artifacts into final repo %s: %w", destination.Info.Repository, err) + } + } + + for _, cache := range caches { + if cache == nil || cache.Address() == storage.LocalStorageAddress || cache.Address() == source.Info.Repository { + continue + } + + destinationDigest := source.Info.GetDigest() + if projectName != "" && source.StageID != nil && source.StageID.Digest != "" { + cacheDesc, err := cache.GetStageDesc(ctx, projectName, *source.StageID) + if err != nil || cacheDesc == nil || cacheDesc.Info == nil { + if err == nil { + err = fmt.Errorf("cache stage descriptor is unavailable") + } + logboek.Context(ctx).Warn().LogF("Warning: unable to resolve destination descriptor in cache stages storage %s: %s\n", cache.String(), err) + continue + } + destinationDigest = cacheDesc.Info.GetDigest() + } + + if err := copyArtifacts(ctx, sourceStorage, source.Info.GetDigest(), cache, destinationDigest); err != nil { + logboek.Context(ctx).Warn().LogF("Warning: unable to copy artifacts into cache stages storage %s: %s\n", cache.String(), err) + } + } + + return nil +} diff --git a/pkg/build/artifact_propagation_test.go b/pkg/build/artifact_propagation_test.go new file mode 100644 index 0000000000..5a3c04fe47 --- /dev/null +++ b/pkg/build/artifact_propagation_test.go @@ -0,0 +1,258 @@ +package build + +import ( + "bytes" + "context" + "net/http/httptest" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" + + "github.com/werf/logboek" + "github.com/werf/werf/v2/pkg/attestation" + "github.com/werf/werf/v2/pkg/docker_registry" + "github.com/werf/werf/v2/pkg/image" + "github.com/werf/werf/v2/pkg/oci/artifact" + "github.com/werf/werf/v2/pkg/storage" + "github.com/werf/werf/v2/pkg/storage/manager" + "github.com/werf/werf/v2/test/mock" +) + +type recordingArtifactStorageManager struct { + manager.StorageManagerInterface + copyCalls int + lastSourceDigest string + lastDestinationDigest string +} + +func (m *recordingArtifactStorageManager) CopyAttachedArtifacts(_ context.Context, _ storage.StagesStorage, sourceDigest string, _ storage.StagesStorage, destinationDigest string) error { + m.copyCalls++ + m.lastSourceDigest = sourceDigest + m.lastDestinationDigest = destinationDigest + return nil +} + +var _ = Describe("artifact propagation", func() { + It("rejects an incomplete artifact source descriptor", func(ctx SpecContext) { + err := ensureAttachedArtifacts(ctx, "", "") + + Expect(err).To(MatchError("artifact source descriptor is incomplete")) + }) + + It("rejects a nil source descriptor", func(ctx SpecContext) { + err := propagateArtifacts(ctx, "project", "app", nil, nil, nil) + + Expect(err).To(MatchError("source image descriptor is unavailable")) + }) + + It("routes production propagation through StorageManager", func(ctx SpecContext) { + storageManager := &recordingArtifactStorageManager{} + source := &image.StageDesc{Info: &image.Info{Repository: "registry.example/source", RepoDigest: "registry.example/source@sha256:source"}} + destination := &image.StageDesc{Info: &image.Info{Repository: "registry.example/final", RepoDigest: "registry.example/final@sha256:destination"}} + + Expect(propagateArtifactsWithManager(ctx, "project", "app", source, destination, nil, nil, nil, storageManager)).To(Succeed()) + Expect(storageManager.copyCalls).To(Equal(1)) + }) + + It("propagates a multi-platform index using its resolved destination digest", func(ctx SpecContext) { + storageManager := &recordingArtifactStorageManager{} + source := &image.StageDesc{ + StageID: image.NewStageID("stage-digest", 1), + Info: &image.Info{ + IsIndex: true, + Repository: "registry.example/source", + RepoDigest: "registry.example/source@sha256:source-index", + }, + } + cache := mock.NewMockStagesStorage(gomock.NewController(GinkgoT())) + cache.EXPECT().Address().Return("registry.example/cache").AnyTimes() + cache.EXPECT().String().Return("registry.example/cache").AnyTimes() + cache.EXPECT().GetStageDesc(ctx, "project", *source.StageID).Return(&image.StageDesc{Info: &image.Info{ + IsIndex: true, + Repository: "registry.example/cache", + RepoDigest: "registry.example/cache@sha256:destination-index", + }}, nil) + + Expect(propagateArtifactsWithManager(ctx, "project", "app", source, nil, []storage.StagesStorage{cache}, nil, nil, storageManager)).To(Succeed()) + Expect(storageManager.copyCalls).To(Equal(1)) + Expect(storageManager.lastSourceDigest).To(Equal("sha256:source-index")) + Expect(storageManager.lastDestinationDigest).To(Equal("sha256:destination-index")) + }) + + It("skips local-only artifact sources", func(ctx SpecContext) { + err := propagateArtifacts(ctx, "project", "app", &image.StageDesc{ + Info: &image.Info{Repository: ":local", RepoDigest: ":local@sha256:local"}, + }, nil, nil) + + Expect(err).To(Succeed()) + }) + + Describe("propagation errors", func() { + var ( + server *httptest.Server + sourceRepo string + sourceDigest string + remoteOpts []remote.Option + ) + + stageDescFor := func(repo, digest string) *image.StageDesc { + return &image.StageDesc{Info: &image.Info{ + Repository: repo, + RepoDigest: repo + "@" + digest, + }} + } + + pushImageToRepo := func(ctx SpecContext, repo string) string { + img, err := random.Image(256, 1) + Expect(err).To(Succeed()) + ref, err := name.NewTag(repo + ":v1") + Expect(err).To(Succeed()) + Expect(remote.Write(ref, img, append([]remote.Option{remote.WithContext(ctx)}, remoteOpts...)...)).To(Succeed()) + digest, err := img.Digest() + Expect(err).To(Succeed()) + return digest.String() + } + + BeforeEach(func(ctx SpecContext) { + Expect(docker_registry.Init(ctx, false, false, nil, nil)).To(Succeed()) + + server = httptest.NewServer(registry.New()) + host := strings.TrimPrefix(server.URL, "http://") + sourceRepo = host + "/test/source" + remoteOpts = []remote.Option{remote.WithAuth(authn.Anonymous)} + + img, err := random.Image(256, 1) + Expect(err).To(Succeed()) + ref, err := name.NewTag(sourceRepo + ":v1") + Expect(err).To(Succeed()) + Expect(remote.Write(ref, img, append([]remote.Option{remote.WithContext(ctx)}, remoteOpts...)...)).To(Succeed()) + digest, err := img.Digest() + Expect(err).To(Succeed()) + sourceDigest = digest.String() + + store := artifact.NewOCIStore(sourceRepo, "app", remoteOpts...) + Expect(store.Attach(ctx, sourceDigest, attestation.DSSEMediaType, []byte(`{"v":1}`), "checksum-v1", "", "")).To(Succeed()) + }) + + AfterEach(func() { + server.Close() + }) + + It("returns a final propagation error", func(ctx SpecContext) { + err := propagateArtifacts(ctx, "project", "app", stageDescFor(sourceRepo, sourceDigest), stageDescFor("127.0.0.1:1/unreachable/final", sourceDigest), nil) + + Expect(err).To(MatchError(ContainSubstring("copy attached artifacts into final repo"))) + }) + + It("propagates artifacts to final and cache repositories", func(ctx SpecContext) { + finalRepo := strings.TrimPrefix(server.URL, "http://") + "/test/final" + cacheRepo := strings.TrimPrefix(server.URL, "http://") + "/test/cache" + finalDigest := pushImageToRepo(ctx, finalRepo) + cacheDigest := pushImageToRepo(ctx, cacheRepo) + cache := mock.NewMockStagesStorage(gomock.NewController(GinkgoT())) + cache.EXPECT().Address().Return(cacheRepo).AnyTimes() + cache.EXPECT().String().Return(cacheRepo).AnyTimes() + cache.EXPECT().GetStageDesc(gomock.Any(), "project", image.StageID{Digest: "stage-digest"}).Return(stageDescFor(cacheRepo, cacheDigest), nil) + + source := stageDescFor(sourceRepo, sourceDigest) + source.StageID = &image.StageID{Digest: "stage-digest"} + err := propagateArtifacts(ctx, "project", "app", source, stageDescFor(finalRepo, finalDigest), []storage.StagesStorage{cache}) + Expect(err).To(Succeed()) + + for _, destination := range []struct { + repo string + digest string + }{ + {repo: finalRepo, digest: finalDigest}, + {repo: cacheRepo, digest: cacheDigest}, + } { + store := artifact.NewOCIStore(destination.repo, "app", remoteOpts...) + content, err := store.GetAttachedContent(ctx, destination.digest, attestation.DSSEMediaType, nil) + Expect(err).To(Succeed()) + Expect(content).To(MatchJSON(`{"v":1}`)) + } + }) + + It("resolves the cache destination digest before propagation", func(ctx SpecContext) { + cacheRepo := strings.TrimPrefix(server.URL, "http://") + "/test/cache-digest" + cacheDigest := pushImageToRepo(ctx, cacheRepo) + cache := mock.NewMockStagesStorage(gomock.NewController(GinkgoT())) + cache.EXPECT().Address().Return(cacheRepo).AnyTimes() + cache.EXPECT().String().Return(cacheRepo).AnyTimes() + cache.EXPECT().GetStageDesc(gomock.Any(), "project", gomock.Any()).Return(stageDescFor(cacheRepo, cacheDigest), nil) + + source := stageDescFor(sourceRepo, sourceDigest) + source.StageID = &image.StageID{Digest: "stage-digest"} + Expect(propagateArtifacts(ctx, "project", "app", source, nil, []storage.StagesStorage{cache})).To(Succeed()) + + store := artifact.NewOCIStore(cacheRepo, "app", remoteOpts...) + content, err := store.GetAttachedContent(ctx, cacheDigest, attestation.DSSEMediaType, nil) + Expect(err).To(Succeed()) + Expect(content).To(MatchJSON(`{"v":1}`)) + }) + + It("skips propagation when the destination repository is identical", func(ctx SpecContext) { + destination := stageDescFor(sourceRepo, "sha256:does-not-exist") + Expect(propagateArtifacts(ctx, "project", "app", stageDescFor(sourceRepo, sourceDigest), destination, nil)).To(Succeed()) + }) + + It("deduplicates an artifact with the same identity", func(ctx SpecContext) { + destinationRepo := strings.TrimPrefix(server.URL, "http://") + "/test/dedup" + destinationDigest := pushImageToRepo(ctx, destinationRepo) + destinationStore := artifact.NewOCIStore(destinationRepo, "app", remoteOpts...) + Expect(destinationStore.Attach(ctx, destinationDigest, attestation.DSSEMediaType, []byte(`{"v":2}`), "checksum-v1", "", "")).To(Succeed()) + + Expect(propagateArtifacts(ctx, "project", "app", stageDescFor(sourceRepo, sourceDigest), stageDescFor(destinationRepo, destinationDigest), nil)).To(Succeed()) + + content, err := destinationStore.GetAttachedContent(ctx, destinationDigest, attestation.DSSEMediaType, nil) + Expect(err).To(Succeed()) + Expect(content).To(MatchJSON(`{"v":2}`)) + index, err := artifact.PullFallbackIndex(ctx, destinationRepo, destinationDigest, remoteOpts...) + Expect(err).To(Succeed()) + manifest, err := index.IndexManifest() + Expect(err).To(Succeed()) + Expect(manifest.Manifests).To(HaveLen(1)) + }) + + It("restores artifacts from a secondary repository onto the primary digest", func(ctx SpecContext) { + primaryRepo := strings.TrimPrefix(server.URL, "http://") + "/test/primary" + primaryDigest := pushImageToRepo(ctx, primaryRepo) + source := stageDescFor(sourceRepo, sourceDigest) + destination := stageDescFor(primaryRepo, primaryDigest) + + Expect(ensureAttachedArtifacts(ctx, source.Info.Repository, source.Info.GetDigest())).To(Succeed()) + Expect(propagateArtifacts(ctx, "project", "app", source, destination, nil)).To(Succeed()) + + store := artifact.NewOCIStore(primaryRepo, "app", remoteOpts...) + content, err := store.GetAttachedContent(ctx, primaryDigest, attestation.DSSEMediaType, nil) + Expect(err).To(Succeed()) + Expect(content).To(MatchJSON(`{"v":1}`)) + }) + + It("rejects a secondary source image without attached artifacts", func(ctx SpecContext) { + repo := strings.TrimPrefix(server.URL, "http://") + "/test/missing-artifacts" + digest := pushImageToRepo(ctx, repo) + + err := ensureAttachedArtifacts(ctx, repo, digest) + Expect(err).To(MatchError(ContainSubstring("has no attached artifacts"))) + }) + + It("logs cache propagation errors and continues", func(ctx SpecContext) { + var output bytes.Buffer + logCtx := logboek.NewContext(ctx, logboek.NewLogger(&output, &output)) + cache := mock.NewMockStagesStorage(gomock.NewController(GinkgoT())) + cache.EXPECT().Address().Return("127.0.0.1:1/unreachable/cache").AnyTimes() + cache.EXPECT().String().Return("127.0.0.1:1/unreachable/cache").AnyTimes() + + Expect(propagateArtifacts(logCtx, "", "app", stageDescFor(sourceRepo, sourceDigest), nil, []storage.StagesStorage{cache})).To(Succeed()) + Expect(output.String()).To(ContainSubstring("Warning: unable to copy artifacts into cache stages storage")) + }) + }) +}) diff --git a/pkg/build/artifact_stage_lifecycle.go b/pkg/build/artifact_stage_lifecycle.go new file mode 100644 index 0000000000..4bc6e46ba7 --- /dev/null +++ b/pkg/build/artifact_stage_lifecycle.go @@ -0,0 +1,22 @@ +package build + +import ( + "context" + + "github.com/werf/werf/v2/pkg/build/image" + "github.com/werf/werf/v2/pkg/build/stage" +) + +func runRestoredArtifactStages(ctx context.Context, img *image.Image, phase Phase) error { + for _, stg := range img.GetStages() { + if artifactStage, ok := stg.(interface { + GetArtifactMetadata() *stage.ArtifactStageMetadata + }); !ok || artifactStage.GetArtifactMetadata() == nil { + continue + } + if err := phase.OnImageStage(ctx, img, stg); err != nil { + return err + } + } + return nil +} diff --git a/pkg/build/artifact_stage_lifecycle_test.go b/pkg/build/artifact_stage_lifecycle_test.go new file mode 100644 index 0000000000..9f132ab9a1 --- /dev/null +++ b/pkg/build/artifact_stage_lifecycle_test.go @@ -0,0 +1,41 @@ +package build + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + build_image "github.com/werf/werf/v2/pkg/build/image" + "github.com/werf/werf/v2/pkg/build/signing" + "github.com/werf/werf/v2/pkg/build/stage" + imagePkg "github.com/werf/werf/v2/pkg/image" +) + +type restoredArtifactPhase struct { + Phase + called []stage.StageName +} + +func (p *restoredArtifactPhase) OnImageStage(_ context.Context, _ *build_image.Image, stg stage.Interface) error { + p.called = append(p.called, stg.Name()) + return nil +} + +var _ = Describe("restored artifact stages", func() { + It("runs artifact stages without replaying ordinary image stages", func(ctx SpecContext) { + sbom := stage.GenerateSbomStage( + &stage.BaseStageOptions{TargetPlatform: "linux/amd64"}, + signing.SbomSigningOptions{}, + "scanner-input", + func(context.Context, *imagePkg.StageDesc, string, string) error { return nil }, + ) + regular := stage.GenerateImageSpecStage(nil, &stage.BaseStageOptions{}) + img := &build_image.Image{} + img.SetStages([]stage.Interface{regular, sbom}) + phase := &restoredArtifactPhase{} + + Expect(runRestoredArtifactStages(ctx, img, phase)).To(Succeed()) + Expect(phase.called).To(Equal([]stage.StageName{stage.Sbom})) + }) +}) diff --git a/pkg/build/artifact_stage_migration_test.go b/pkg/build/artifact_stage_migration_test.go new file mode 100644 index 0000000000..19c168e53f --- /dev/null +++ b/pkg/build/artifact_stage_migration_test.go @@ -0,0 +1,42 @@ +package build + +import ( + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("artifact stage migration", func() { + It("does not retain transitional SBOM or VEX step implementations", func() { + root := "." + var sourceFiles []string + err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + sourceFiles = append(sourceFiles, path) + return nil + }) + Expect(err).NotTo(HaveOccurred()) + + for _, path := range sourceFiles { + contents, err := os.ReadFile(path) + Expect(err).NotTo(HaveOccurred()) + Expect(string(contents)).NotTo(ContainSubstring("sbom"+"Step"), path) + Expect(string(contents)).NotTo(ContainSubstring("vex"+"Step"), path) + } + + buildPhase, err := os.ReadFile("build_phase.go") + Expect(err).NotTo(HaveOccurred()) + Expect(string(buildPhase)).To(ContainSubstring("GenerateSbomStage")) + Expect(string(buildPhase)).To(ContainSubstring("NewVexStage")) + Expect(string(buildPhase)).To(ContainSubstring("if len(images) == 1")) + Expect(string(buildPhase)).To(ContainSubstring("runMultiplatformVexArtifactStage")) + }) +}) diff --git a/pkg/build/artifact_subject.go b/pkg/build/artifact_subject.go new file mode 100644 index 0000000000..7afccd6b6c --- /dev/null +++ b/pkg/build/artifact_subject.go @@ -0,0 +1,69 @@ +package build + +import ( + "github.com/werf/werf/v2/pkg/build/image" + imagePkg "github.com/werf/werf/v2/pkg/image" +) + +func contentStageDesc(img *image.Image) *imagePkg.StageDesc { + if img == nil { + return nil + } + + lastStage := img.GetLastNonEmptyStage() + if lastStage == nil || lastStage.GetStageImage() == nil || lastStage.GetStageImage().Image == nil { + return img.GetContentTagDesc() + } + + return lastStage.GetStageImage().Image.GetStageDesc() +} + +func finalStageDescForImage(phase *BuildPhase, name string, images []*image.Image) *imagePkg.StageDesc { + if len(images) == 1 { + if images[0] == nil { + return nil + } + return images[0].GetContentTagDesc() + } + + if phase == nil || phase.Conveyor == nil || phase.Conveyor.imagesTree == nil { + return nil + } + + if multiImg := phase.Conveyor.imagesTree.GetMultiplatformImage(name); multiImg != nil { + return multiImg.GetFinalStageDesc() + } + + return nil +} + +func vexTargetPlatform(images []*image.Image) string { + if len(images) != 1 || images[0] == nil { + return "" + } + + return images[0].TargetPlatform +} + +func finalStageDescForPlatform(phase *BuildPhase, name string, images []*image.Image, targetPlatform string) *imagePkg.StageDesc { + finalStageDesc := finalStageDescForImage(phase, name, images) + if finalStageDesc == nil || finalStageDesc.Info == nil || !finalStageDesc.Info.IsIndex { + return finalStageDesc + } + + for _, manifest := range finalStageDesc.Info.Index { + if manifest != nil && manifest.Platform == targetPlatform { + return &imagePkg.StageDesc{Info: manifest} + } + } + + for index, img := range images { + if img == nil || img.TargetPlatform != targetPlatform || index >= len(finalStageDesc.Info.Index) { + continue + } + + return &imagePkg.StageDesc{Info: finalStageDesc.Info.Index[index]} + } + + return nil +} diff --git a/pkg/build/artifact_subject_test.go b/pkg/build/artifact_subject_test.go new file mode 100644 index 0000000000..047fc70b57 --- /dev/null +++ b/pkg/build/artifact_subject_test.go @@ -0,0 +1,121 @@ +package build + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/werf/werf/v2/pkg/build/image" + imagePkg "github.com/werf/werf/v2/pkg/image" +) + +var _ = Describe("artifact subjects", func() { + newImage := func(name, platform, digest string) *image.Image { + img := &image.Image{Name: name, TargetPlatform: platform} + img.SetContentTagDesc(&imagePkg.StageDesc{ + StageID: imagePkg.NewStageID(digest, 1), + Info: &imagePkg.Info{Repository: "primary", RepoDigest: "primary@" + digest}, + }) + return img + } + + It("selects the matching platform manifest for a multi-platform SBOM", func() { + images := []*image.Image{ + newImage("app", "linux/amd64", "sha256:amd64"), + newImage("app", "linux/arm64", "sha256:arm64"), + } + multiImage := image.NewMultiplatformImage("app", images, 0, 1) + multiImage.SetFinalStageDesc(&imagePkg.StageDesc{ + Info: &imagePkg.Info{ + IsIndex: true, + Index: []*imagePkg.Info{ + {Repository: "final", RepoDigest: "final@sha256:amd64"}, + {Repository: "final", RepoDigest: "final@sha256:arm64"}, + }, + }, + }) + tree := image.NewImagesTree(nil, image.ImagesTreeOptions{}) + tree.SetMultiplatformImage(multiImage) + phase := &BuildPhase{BasePhase: BasePhase{Conveyor: &Conveyor{imagesTree: tree}}} + + descriptor := finalStageDescForPlatform(phase, "app", images, "linux/arm64") + + Expect(descriptor).NotTo(BeNil()) + Expect(descriptor.Info.GetDigest()).To(Equal("sha256:arm64")) + }) + + It("selects a platform manifest by platform metadata regardless of index order", func() { + images := []*image.Image{ + newImage("app", "linux/amd64", "sha256:amd64"), + newImage("app", "linux/arm64", "sha256:arm64"), + } + multiImage := image.NewMultiplatformImage("app", images, 0, 1) + multiImage.SetFinalStageDesc(&imagePkg.StageDesc{Info: &imagePkg.Info{ + IsIndex: true, + Index: []*imagePkg.Info{ + {Platform: "linux/arm64", RepoDigest: "final@sha256:arm64"}, + {Platform: "linux/amd64", RepoDigest: "final@sha256:amd64"}, + }, + }}) + tree := image.NewImagesTree(nil, image.ImagesTreeOptions{}) + tree.SetMultiplatformImage(multiImage) + phase := &BuildPhase{BasePhase: BasePhase{Conveyor: &Conveyor{imagesTree: tree}}} + + descriptor := finalStageDescForPlatform(phase, "app", images, "linux/amd64") + + Expect(descriptor).NotTo(BeNil()) + Expect(descriptor.Info.GetDigest()).To(Equal("sha256:amd64")) + }) + + It("keeps the top-level index as the VEX subject for a multi-platform image", func() { + images := []*image.Image{ + newImage("app", "linux/amd64", "sha256:amd64"), + newImage("app", "linux/arm64", "sha256:arm64"), + } + multiImage := image.NewMultiplatformImage("app", images, 0, 1) + index := &imagePkg.StageDesc{Info: &imagePkg.Info{ + IsIndex: true, + Repository: "final", + RepoDigest: "final@sha256:index", + Index: []*imagePkg.Info{{RepoDigest: "final@sha256:amd64"}, {RepoDigest: "final@sha256:arm64"}}, + }} + multiImage.SetFinalStageDesc(index) + tree := image.NewImagesTree(nil, image.ImagesTreeOptions{}) + tree.SetMultiplatformImage(multiImage) + phase := &BuildPhase{BasePhase: BasePhase{Conveyor: &Conveyor{imagesTree: tree}}} + + descriptor := finalStageDescForImage(phase, "app", images) + + Expect(descriptor).To(BeIdenticalTo(index)) + Expect(descriptor.Info.GetDigest()).To(Equal("sha256:index")) + }) + + It("uses no platform annotation for a multi-platform VEX artifact", func() { + images := []*image.Image{ + {TargetPlatform: "linux/amd64"}, + {TargetPlatform: "linux/arm64"}, + } + + Expect(vexTargetPlatform(images)).To(BeEmpty()) + }) + + It("uses the manifest platform for a single-platform VEX artifact", func() { + Expect(vexTargetPlatform([]*image.Image{{TargetPlatform: "linux/amd64"}})).To(Equal("linux/amd64")) + }) + + It("returns no final subject when a multi-platform tree is unavailable", func() { + images := []*image.Image{newImage("app", "linux/amd64", "sha256:amd64"), newImage("app", "linux/arm64", "sha256:arm64")} + phase := &BuildPhase{} + + Expect(finalStageDescForImage(phase, "app", images)).To(BeNil()) + }) + + It("uses the published manifest as the final subject for a single-platform image", func() { + images := []*image.Image{newImage("app", "linux/amd64", "sha256:amd64")} + phase := &BuildPhase{BasePhase: BasePhase{Conveyor: &Conveyor{}}} + + descriptor := finalStageDescForPlatform(phase, "app", images, "linux/amd64") + + Expect(descriptor).To(BeIdenticalTo(images[0].GetContentTagDesc())) + Expect(descriptor.Info.GetDigest()).To(Equal("sha256:amd64")) + }) +}) diff --git a/pkg/build/build_phase.go b/pkg/build/build_phase.go index a7ed5cec43..9d86d3b5dc 100644 --- a/pkg/build/build_phase.go +++ b/pkg/build/build_phase.go @@ -11,7 +11,6 @@ import ( cdx "github.com/CycloneDX/cyclonedx-go" "github.com/google/uuid" "github.com/moby/buildkit/frontend/dockerfile/instructions" - "github.com/samber/lo" "github.com/sigstore/sigstore/pkg/signature" "github.com/werf/common-go/pkg/util" @@ -92,8 +91,8 @@ func NewBuildPhase(c *Conveyor, opts BuildPhaseOptions) *BuildPhase { return &BuildPhase{ BasePhase: BasePhase{c}, BuildPhaseOptions: opts, - sbomStep: newSbomStep(c.ContainerBackend, c.StorageManager.GetStagesStorage()), - vexStep: newVexStep(), + sbomProcessor: newSbomProcessor(c.ContainerBackend, c.StorageManager.GetStagesStorage(), c.StorageManager), + vexProcessor: newVexProcessor(c.StorageManager.GetStagesStorage(), c.StorageManager), ImagesReport: NewImagesReport(), } } @@ -101,8 +100,8 @@ func NewBuildPhase(c *Conveyor, opts BuildPhaseOptions) *BuildPhase { type BuildPhase struct { BasePhase BuildPhaseOptions - sbomStep *sbomStep - vexStep *vexStep + sbomProcessor *sbomProcessor + vexProcessor *vexProcessor StagesIterator *StagesIterator ImagesReport *ImagesReport @@ -126,6 +125,10 @@ func (phase *BuildPhase) Name() string { } func (phase *BuildPhase) BeforeImages(ctx context.Context) error { + if err := validateArtifactStorage(phase.Conveyor.StorageManager, phase.artifactsEnabled()); err != nil { + return err + } + if err := phase.Conveyor.StorageManager.InitCache(ctx); err != nil { return fmt.Errorf("unable to init storage manager cache: %w", err) } @@ -152,6 +155,13 @@ func (phase *BuildPhase) BeforeImages(ctx context.Context) error { func collectHolisticInputs(ctx context.Context, img *image.Image, conveyor stage.Conveyor, buildContextArchive container_backend.BuildContextArchiver) ([]string, error) { var inputs []string for _, stg := range img.GetStages() { + artifactStage, isArtifactStage := stg.(interface { + GetArtifactMetadata() *stage.ArtifactStageMetadata + }) + if isArtifactStage && artifactStage.GetArtifactMetadata() != nil { + continue + } + deps, err := stg.GetContentDependencies(ctx, conveyor, buildContextArchive) if err != nil { return nil, fmt.Errorf("stage %q GetContentDependencies: %w", stg.Name(), err) @@ -252,11 +262,11 @@ func (phase *BuildPhase) AfterImages(ctx context.Context) error { return err } - if err := phase.convergeSbomByImagesSets(ctx); err != nil { + if err := phase.publishMultiplatformVexArtifacts(ctx); err != nil { return err } - if err := phase.convergeVexByImagesSets(ctx); err != nil { + if err := phase.propagateArtifactsByImages(ctx); err != nil { return err } @@ -265,103 +275,78 @@ func (phase *BuildPhase) AfterImages(ctx context.Context) error { return phase.createReport(ctx, imagesPairs) } -func (phase *BuildPhase) convergeSbomByImagesSets(ctx context.Context) error { - if !phase.Conveyor.EnableSbom() { - return nil - } - - graph := phase.Conveyor.imagesTree.GetImagesGraph() - if graph == nil || len(graph.Nodes()) == 0 { +func validateArtifactStorage(storageManager manager.StorageManagerInterface, artifactsEnabled bool) error { + if !artifactsEnabled { return nil } - - if _, isLocal := phase.Conveyor.StorageManager.GetStagesStorage().(*storage.LocalStagesStorage); isLocal { - return fmt.Errorf("SBOM generation requires a container registry (specify --repo). Use --repo to enable SBOM or disable SBOM in the werf config (build.sbom.enable)") + if _, isLocal := storageManager.GetStagesStorage().(*storage.LocalStagesStorage); isLocal { + return fmt.Errorf("SBOM or VEX generation requires a container registry (specify --repo), or disable artifact generation") } - - tracker := convergefailure.NewTracker(os.Getenv(externalref.EnvName)) - phase.sbomFailures = tracker - - totalImages, convergeErr := phase.doConvergeSbomByImagesSets(ctx, graph, tracker) - - return tracker.Finish(ctx, totalImages, convergeErr) + return nil } -func (phase *BuildPhase) doConvergeSbomByImagesSets(ctx context.Context, graph *image.ImagesGraph, tracker *convergefailure.Tracker) (int, error) { - var totalImages int - - for _, imagesInSet := range graph.Levels() { - imagesByName := make(map[string][]*image.Image) - for _, img := range imagesInSet { - imagesByName[img.Name] = append(imagesByName[img.Name], img) - } - - names := make([]string, 0, len(imagesByName)) - for name := range imagesByName { - names = append(names, name) - } - - totalImages += len(names) +func (phase *BuildPhase) artifactsEnabled() bool { + if phase.Conveyor.EnableSbom() { + return true + } - if err := parallel.DoTasks(ctx, len(names), parallel.DoTasksOptions{ - MaxNumberOfWorkers: int(phase.Conveyor.ParallelTasksLimit), - InitDockerCLIForEachWorker: true, - }, func(ctx context.Context, taskId int) error { - name := names[taskId] - images := imagesByName[name] + if phase.Conveyor.imagesTree == nil { + return false + } - if tracker.SkipDependent(ctx, name, sbomImageDependencies(images)) { - return nil + for _, pair := range phase.Conveyor.imagesTree.GetImagesByName(false) { + _, images := pair.Unpair() + for _, img := range images { + if img == nil { + continue } - - if err := phase.convergeImageSbom(ctx, name, images, tracker.Breaker()); err != nil { - return tracker.Classify(err, name) + if vex := img.Vex(); vex != nil && vex.Document != "" { + return true } - return nil - }); err != nil { - return totalImages, err } } - return totalImages, nil -} - -// sbomImageDependencies describes, for the SBOM failure semantics, the images -// whose SBOMs are merged into this image's own SBOM. -func sbomImageDependencies(images []*image.Image) []convergefailure.ImageDependencies { - return lo.Map(images, func(img *image.Image, _ int) convergefailure.ImageDependencies { - return convergefailure.ImageDependencies{ - BaseImageName: img.GetBaseImageName(), - Imports: lo.Map(img.GetImportImagesInfo(), func(importInfo image.ImportImageInfo, _ int) convergefailure.ImportSource { - return convergefailure.ImportSource{ - ImageName: importInfo.ImageName, - External: importInfo.ExternalImage, - } - }), - } - }) + return false } -func (phase *BuildPhase) convergeImageSbom(ctx context.Context, name string, images []*image.Image, breaker *externalref.ResolverBreaker) error { - var signer signature.Signer - var signerIdentity string - if phase.SbomSigningOptions.Enabled { - signer = phase.SbomSigningOptions.Signer().SignerVerifier() - signerIdentity = phase.SbomSigningOptions.Signer().Fingerprint() +func (phase *BuildPhase) propagateArtifactsByImages(ctx context.Context) error { + if !phase.artifactsEnabled() { + return nil } - finalStageDesc := phase.finalStageDescForImage(name, images) - for _, img := range images { - if err := phase.convergePlatformImageSbom(ctx, name, img, finalStageDesc, signer, signerIdentity, breaker); err != nil { - return err + for _, pair := range phase.Conveyor.imagesTree.GetImagesByName(false) { + name, images := pair.Unpair() + for _, img := range images { + if img == nil { + continue + } + source := contentStageDesc(img) + if source == nil { + continue + } + if err := propagateArtifactsWithManager(ctx, phase.Conveyor.ProjectName(), name, source, finalStageDescForPlatform(phase, name, images, img.TargetPlatform), phase.Conveyor.StorageManager.GetCacheStagesStorageList(), phase.Conveyor.StorageManager.GetStagesStorage(), phase.Conveyor.StorageManager.GetFinalStagesStorage(), phase.Conveyor.StorageManager); err != nil { + return fmt.Errorf("propagate artifacts for image %q: %w", name, err) + } } - } + if len(images) > 1 { + multiImage := phase.Conveyor.imagesTree.GetMultiplatformImage(name) + if multiImage == nil || multiImage.GetStageDesc() == nil { + continue + } + if err := propagateArtifactsWithManager(ctx, phase.Conveyor.ProjectName(), name, multiImage.GetStageDesc(), multiImage.GetFinalStageDesc(), phase.Conveyor.StorageManager.GetCacheStagesStorageList(), phase.Conveyor.StorageManager.GetStagesStorage(), phase.Conveyor.StorageManager.GetFinalStagesStorage(), phase.Conveyor.StorageManager); err != nil { + return fmt.Errorf("propagate multiplatform artifacts for image %q: %w", name, err) + } + } + } return nil } -func (phase *BuildPhase) convergePlatformImageSbom(ctx context.Context, name string, img *image.Image, finalStageDesc *imagePkg.StageDesc, signer signature.Signer, signerIdentity string, breaker *externalref.ResolverBreaker) error { - stageDesc := img.GetLastNonEmptyStageDesc() +func (phase *BuildPhase) convergePlatformImageSbom(ctx context.Context, name string, img *image.Image, sourceStageDesc *imagePkg.StageDesc, signer signature.Signer, signerIdentity string, breaker *externalref.ResolverBreaker) error { + stageDesc := sourceStageDesc + if stageDesc == nil { + stageDesc = contentStageDesc(img) + } if stageDesc == nil { return fmt.Errorf("unable to converge sbom for image %q: stage descriptor is unavailable", name) } @@ -417,32 +402,13 @@ func (phase *BuildPhase) convergePlatformImageSbom(ctx context.Context, name str scanOpts := phase.scanOptionsForImage(img) - if err := phase.sbomStep.ConvergeWithMerge(ctx, name, stageDesc, scanOpts, mergeOpts, patchers, hasOsPmPackages, isStapelScratch, img.TargetPlatform, signer, signerIdentity); err != nil { + if err := phase.sbomProcessor.ConvergeWithMerge(ctx, name, stageDesc, scanOpts, mergeOpts, patchers, hasOsPmPackages, isStapelScratch, img.TargetPlatform, signer, signerIdentity); err != nil { if img.TargetPlatform != "" { return fmt.Errorf("unable to converge sbom for image %q (platform %s): %w", name, img.TargetPlatform, err) } return fmt.Errorf("unable to converge sbom for image %q: %w", name, err) } - if err := phase.sbomStep.PropagateArtifacts(ctx, name, stageDesc, finalStageDesc, phase.Conveyor.StorageManager.GetCacheStagesStorageList()); err != nil { - return fmt.Errorf("unable to propagate sbom for image %q: %w", name, err) - } - - return nil -} - -// finalStageDescForImage returns the final repo descriptor to copy the SBOM artifacts into, or nil -// when there is nothing to copy. A single-platform image never has one: publishFinalImage stores the -// final repo descriptor in the content tag desc, which convergeImageSbom already uses as the SBOM -// target. Reaching for the last non-empty stage here instead panics, because an image resolved from -// the cache short-circuits in BeforeImageStages and never gets one. -func (phase *BuildPhase) finalStageDescForImage(name string, images []*image.Image) *imagePkg.StageDesc { - if len(images) == 1 { - return nil - } - if multiImg := phase.Conveyor.imagesTree.GetMultiplatformImage(name); multiImg != nil { - return multiImg.GetFinalStageDesc() - } return nil } @@ -703,8 +669,14 @@ func (phase *BuildPhase) BeforeImageStages(ctx context.Context, img *image.Image if len(stages) == 0 { return deferFn, nil } - anchor := stages[len(stages)-1] - if !anchor.IsContentAnchor() { + var anchor stage.Interface + for index := len(stages) - 1; index >= 0; index-- { + if stages[index].IsContentAnchor() { + anchor = stages[index] + break + } + } + if anchor == nil { return deferFn, nil } @@ -756,14 +728,115 @@ func (phase *BuildPhase) BeforeImageStages(ctx context.Context, img *image.Image logboek.Context(ctx).Default().LogFHighlight("Use previously built image for %s by content-based tag\n", img.LogName()) container_backend.LogImageInfoByStageDesc(ctx, stageDesc, platform) } + phase.StagesIterator.PrevStage = anchor + phase.StagesIterator.PrevNonEmptyStage = anchor + phase.StagesIterator.PrevBuiltStage = anchor } else if phase.ShouldBeBuiltMode { logboek.Context(ctx).Warn().LogFHighlight("Content-based digest %s for image %s not found\n", anchor.GetDigest(), img.LogName()) logboek.Context(ctx).Warn().LogLn() } + phase.registerSbomStage(img) + if err := phase.registerSinglePlatformVexStage(ctx, img); err != nil { + return deferFn, err + } + return deferFn, nil } +func (phase *BuildPhase) registerSinglePlatformVexStage(ctx context.Context, img *image.Image) error { + if img == nil || img.Vex() == nil || img.Vex().Document == "" { + return nil + } + + images := phase.Conveyor.imagesTree.GetImagesByName(false) + for _, pair := range images { + name, imageSet := pair.Unpair() + if name == img.Name && len(imageSet) != 1 { + return nil + } + } + for _, existing := range img.GetStages() { + if existing.Name() == stage.Vex { + return nil + } + } + + vexContent, err := phase.Conveyor.GiterminismManager().FileReader().ReadVEXFile(ctx, img.Vex().Document) + if err != nil { + return fmt.Errorf("read VEX file %q for image %q: %w", img.Vex().Document, img.Name, err) + } + baseOptions := &stage.BaseStageOptions{ + TargetPlatform: img.TargetPlatform, + ImageName: img.Name, + ImageTmpDir: img.TmpDir, + ContainerWerfDir: img.ContainerWerfDir, + ProjectName: phase.Conveyor.ProjectName(), + } + var signingOptions signing.VexSigningOptions + if phase.VexSigningOptions.Enabled { + signingOptions = phase.VexSigningOptions + } + stages := img.GetStages() + publisher := func(ctx context.Context, parentDesc *imagePkg.StageDesc, imageName, targetPlatform string, content []byte, signer signature.Signer, signerIdentity string) error { + return phase.vexProcessor.Converge(ctx, content, parentDesc, imageName, targetPlatform, signer, signerIdentity) + } + img.SetStages(append(stages, stage.NewVexStage(stage.VexStageOptions{ + VexJSON: vexContent, + BaseStageOptions: baseOptions, + SigningOptions: signingOptions, + Publisher: publisher, + }))) + return nil +} + +func (phase *BuildPhase) registerSbomStage(img *image.Image) { + if img == nil || !phase.Conveyor.EnableSbom() { + return + } + for _, existing := range img.GetStages() { + if existing.Name() == stage.Sbom { + return + } + } + + stages := img.GetStages() + if len(stages) == 0 { + return + } + if phase.sbomFailures == nil { + phase.sbomFailures = convergefailure.NewTracker(os.Getenv(externalref.EnvName)) + } + + baseOptions := &stage.BaseStageOptions{ + TargetPlatform: img.TargetPlatform, + ImageName: img.Name, + ImageTmpDir: img.TmpDir, + ContainerWerfDir: img.ContainerWerfDir, + ProjectName: phase.Conveyor.ProjectName(), + } + dependency := phase.scanOptionsForImage(img).Checksum() + if sbomConfig := img.Sbom(); sbomConfig != nil { + dependency = util.Sha256Hash( + dependency, + "standard", fmt.Sprintf("%d", sbomConfig.Standard), + "gost_attack_surface", sbomConfig.Gost.AttackSurface.String(), + "gost_security_function", sbomConfig.Gost.SecurityFunction.String(), + ) + } + var signer signature.Signer + var signerIdentity string + if phase.SbomSigningOptions.Enabled { + signer = phase.SbomSigningOptions.Signer().SignerVerifier() + signerIdentity = phase.SbomSigningOptions.Signer().Fingerprint() + } + publisher := func(ctx context.Context, parentDesc *imagePkg.StageDesc, _, targetPlatform string) error { + return phase.convergePlatformImageSbom(ctx, img.Name, img, parentDesc, signer, signerIdentity, nil) + } + artifactStage := stage.GenerateSbomStage(baseOptions, phase.SbomSigningOptions, dependency, publisher) + img.SetStages(append(stages, artifactStage)) +} + func (phase *BuildPhase) AfterImageStages(ctx context.Context, img *image.Image) error { img.SetLastNonEmptyStage(phase.StagesIterator.PrevNonEmptyStage) return nil @@ -1073,32 +1146,44 @@ func (phase *BuildPhase) findAndFetchStageFromSecondaryStagesStorage(ctx context storageManager := phase.Conveyor.StorageManager atomicCopySuitableStageFromSecondaryStagesStorage := func(secondaryStageDesc *imagePkg.StageDesc, secondaryStagesStorage storage.StagesStorage) error { + var stageDescCopy *imagePkg.StageDesc err := logboek.Context(ctx).Default().LogProcess("Copy suitable stage from secondary %s", secondaryStagesStorage.String()).DoError(func() error { - if stageDescCopy, err := storageManager.CopySuitableStageDescByDigest(ctx, secondaryStageDesc, secondaryStagesStorage, storageManager.GetStagesStorage(), phase.Conveyor.ContainerBackend, img.TargetPlatform); err != nil { + var err error + stageDescCopy, err = storageManager.CopySuitableStageDescByDigest(ctx, secondaryStageDesc, secondaryStagesStorage, storageManager.GetStagesStorage(), phase.Conveyor.ContainerBackend, img.TargetPlatform) + if err != nil { return fmt.Errorf("unable to copy suitable stage %s from %s to %s: %w", secondaryStageDesc.StageID.String(), secondaryStagesStorage.String(), storageManager.GetStagesStorage().String(), err) - } else { - i := phase.Conveyor.GetOrCreateStageImage(stageDescCopy.Info.Name, phase.StagesIterator.GetPrevImage(img, stg), stg, img) - i.Image.SetStageDesc(stageDescCopy) - stg.SetStageImage(i) - - // The stage digest remains the same, but the content digest may differ (e.g., the content digest of git and some user stages depends on the git commit). - contentDigest, exist := stageDescCopy.Info.Labels[imagePkg.WerfStageContentDigestLabel] - if exist { - stg.SetContentDigest(contentDigest) - } else { - panic(fmt.Sprintf("expected stage %q content digest label to be set!", stg.Name())) - } + } - logboek.Context(ctx).Default().LogFHighlight("Use previously built image for %s\n", stg.LogDetailedName()) - container_backend.LogImageInfo(ctx, stg.GetStageImage().Image, phase.getPrevNonEmptyStageImageSize(), img.ShouldLogPlatform(), phase.getLogImageNetwork(img)) + i := phase.Conveyor.GetOrCreateStageImage(stageDescCopy.Info.Name, phase.StagesIterator.GetPrevImage(img, stg), stg, img) + i.Image.SetStageDesc(stageDescCopy) + stg.SetStageImage(i) - return nil + // The stage digest remains the same, but the content digest may differ (e.g., the content digest of git and some user stages depends on the git commit). + contentDigest, exist := stageDescCopy.Info.Labels[imagePkg.WerfStageContentDigestLabel] + if exist { + stg.SetContentDigest(contentDigest) + } else { + panic(fmt.Sprintf("expected stage %q content digest label to be set!", stg.Name())) } + + logboek.Context(ctx).Default().LogFHighlight("Use previously built image for %s\n", stg.LogDetailedName()) + container_backend.LogImageInfo(ctx, stg.GetStageImage().Image, phase.getPrevNonEmptyStageImageSize(), img.ShouldLogPlatform(), phase.getLogImageNetwork(img)) + + return nil }) if err != nil { return err } + if phase.artifactsEnabled() { + if err := ensureAttachedArtifacts(ctx, secondaryStageDesc.Info.Repository, secondaryStageDesc.Info.GetDigest()); err != nil { + return fmt.Errorf("secondary stage %s has incomplete artifacts: %w", secondaryStageDesc.StageID.String(), err) + } + if err := propagateArtifactsWithManager(ctx, phase.Conveyor.ProjectName(), img.Name, secondaryStageDesc, stageDescCopy, storageManager.GetCacheStagesStorageList(), secondaryStagesStorage, storageManager.GetStagesStorage(), storageManager); err != nil { + return fmt.Errorf("unable to propagate artifacts restored from secondary storage: %w", err) + } + } + if err := storageManager.CopyStageIntoCacheStorages( ctx, *stg.GetStageImage().Image.GetStageDesc().StageID, storageManager.GetCacheStagesStorageList(), @@ -1208,6 +1293,17 @@ func (phase *BuildPhase) calculateStage(ctx context.Context, img *image.Image, s phase.Conveyor.GetStageDigestMutex(stg.GetDigest()).Lock() }) + if artifactStage, ok := stg.(interface { + GetArtifactMetadata() *stage.ArtifactStageMetadata + }); ok && artifactStage.GetArtifactMetadata() != nil { + stageContentSig, err := calculateDigest(ctx, fmt.Sprintf("%s-content", stg.Name()), "", stg, phase.Conveyor, calculateDigestOptions{TargetPlatform: img.TargetPlatform}) + if err != nil { + return false, phase.Conveyor.GetStageDigestMutex(stg.GetDigest()).Unlock, fmt.Errorf("unable to calculate artifact stage %s content digest: %w", stg.Name(), err) + } + stg.SetContentDigest(stageContentSig) + return false, phase.Conveyor.GetStageDigestMutex(stg.GetDigest()).Unlock, nil + } + storageManager := phase.Conveyor.StorageManager stageDescSet, err := storageManager.GetStageDescSetByDigestWithCache(ctx, stg.LogDetailedName(), stageDigest, phase.getPrevNonEmptyStageCreationTsForStage(stg)) if err != nil { @@ -1362,7 +1458,14 @@ func (phase *BuildPhase) buildStage(ctx context.Context, img *image.Image, stg s container_backend.LogImageInfo(ctx, stg.GetStageImage().Image, phase.getPrevNonEmptyStageImageSize(), img.ShouldLogPlatform(), phase.getLogImageNetwork(img)) } - if err := logboek.Context(ctx).Default().LogProcess("Building stage %s%s", stg.LogDetailedName(), phase.emptyAnchorRebuildNote(ctx, img, stg)). + processName := "Building stage %s%s" + if artifactStage, ok := stg.(interface { + GetArtifactMetadata() *stage.ArtifactStageMetadata + }); ok && artifactStage.GetArtifactMetadata() != nil { + processName = "Processing artifact stage %s%s" + } + + if err := logboek.Context(ctx).Default().LogProcess(processName, stg.LogDetailedName(), phase.emptyAnchorRebuildNote(ctx, img, stg)). Options(func(options types.LogProcessOptionsInterface) { options.InfoSectionFunc(infoSectionFunc) options.Style(style.Highlight()) @@ -1389,6 +1492,24 @@ func (phase *BuildPhase) buildStage(ctx context.Context, img *image.Image, stg s func (phase *BuildPhase) atomicBuildStageImage(ctx context.Context, img *image.Image, stg stage.Interface) error { stageImage := stg.GetStageImage() + if artifactStage, ok := stg.(interface { + GetArtifactMetadata() *stage.ArtifactStageMetadata + }); ok && artifactStage.GetArtifactMetadata() != nil { + artifactMutator, ok := stg.(stage.ArtifactStage) + if !ok { + return fmt.Errorf("artifact stage %s does not implement artifact mutation", stg.Name()) + } + prevBuiltImage := phase.StagesIterator.GetPrevBuiltImage(img, stg) + if prevBuiltImage == nil || prevBuiltImage.Image == nil { + return fmt.Errorf("expected previous built image for artifact stage %s", stg.Name()) + } + if err := artifactMutator.MutateArtifact(ctx, prevBuiltImage, stageImage); err != nil { + return fmt.Errorf("unable to mutate artifact %s: %w", stg.Name(), err) + } + stageImage.Image.SetStageDesc(prevBuiltImage.Image.GetStageDesc()) + return nil + } + if stg.IsBuildable() { if err := logboek.Context(ctx).Streams().DoErrorWithTag(fmt.Sprintf("%s/%s", img.LogName(), stg.Name()), img.LogTagStyle(), func() error { opts := phase.ImageBuildOptions @@ -1470,7 +1591,11 @@ func (phase *BuildPhase) atomicBuildStageImage(ctx context.Context, img *image.I return fmt.Errorf("expected previous built image for mutable stage %s", stg.Name()) } - if err := stg.MutateImage(ctx, phase.Conveyor.StorageManager.GetStagesStorage(), prevBuiltImage, stageImage); err != nil { + imageMutator, ok := stg.(stage.ImageStage) + if !ok { + return fmt.Errorf("mutable stage %s does not implement image mutation", stg.Name()) + } + if err := imageMutator.MutateImage(ctx, phase.Conveyor.StorageManager.GetStagesStorage(), prevBuiltImage, stageImage); err != nil { if storage.IsErrBrokenImage(err) { // Invalidate manifest cache for the broken previous stage prevStageDesc := prevBuiltImage.Image.GetStageDesc() @@ -1691,9 +1816,8 @@ E.g.: }) } -// convergeVexByImagesSets publishes VEX artifacts for all images respecting dependency order. - -func (phase *BuildPhase) convergeVexByImagesSets(ctx context.Context) error { +// publishMultiplatformVexArtifacts publishes the image-level VEX artifact after the final index exists. +func (phase *BuildPhase) publishMultiplatformVexArtifacts(ctx context.Context) error { if _, isLocal := phase.Conveyor.StorageManager.GetStagesStorage().(*storage.LocalStagesStorage); isLocal { return nil } @@ -1724,8 +1848,11 @@ func (phase *BuildPhase) convergeVexByImagesSets(ctx context.Context) error { name := names[taskId] images := imagesByName[name] + if len(images) == 1 { + return nil + } - return phase.convergeImageVex(ctx, name, images) + return phase.runMultiplatformVexArtifactStage(ctx, name, images) }); err != nil { return err } @@ -1735,22 +1862,17 @@ func (phase *BuildPhase) convergeVexByImagesSets(ctx context.Context) error { return nil } -func (phase *BuildPhase) vexStageDesc(name string, images []*image.Image) *imagePkg.StageDesc { - if len(images) == 1 { - return images[0].GetLastNonEmptyStageDesc() - } - - if multiImg := phase.Conveyor.imagesTree.GetMultiplatformImage(name); multiImg != nil { - return multiImg.GetStageDesc() - } - - return nil -} - -func (phase *BuildPhase) convergeImageVex(ctx context.Context, name string, images []*image.Image) error { +func (phase *BuildPhase) runMultiplatformVexArtifactStage(ctx context.Context, name string, images []*image.Image) error { if len(images) == 0 { return nil } + if len(images) == 1 { + for _, stg := range images[0].GetStages() { + if stg.Name() == stage.Vex { + return nil + } + } + } primaryImg := images[0] @@ -1759,7 +1881,14 @@ func (phase *BuildPhase) convergeImageVex(ctx context.Context, name string, imag return nil } - stageDesc := phase.vexStageDesc(name, images) + var stageDesc *imagePkg.StageDesc + if len(images) == 1 { + stageDesc = contentStageDesc(primaryImg) + } else if multiImg := phase.Conveyor.imagesTree.GetMultiplatformImage(name); multiImg != nil { + stageDesc = multiImg.GetStageDesc() + } else { + stageDesc = image.NewMultiplatformImage(name, images, 0, 1).GetStageDesc() + } if stageDesc == nil { return fmt.Errorf("unable to converge VEX for image %q: stage descriptor is unavailable", name) } @@ -1771,14 +1900,20 @@ func (phase *BuildPhase) convergeImageVex(ctx context.Context, name string, imag return fmt.Errorf("read VEX file %q for image %q: %w", vexConfig.Document, name, err) } - var signer signature.Signer - var signerIdentity string - if phase.VexSigningOptions.Enabled { - signer = phase.VexSigningOptions.Signer().SignerVerifier() - signerIdentity = phase.VexSigningOptions.Signer().Fingerprint() + baseOptions := &stage.BaseStageOptions{ + ImageName: name, + ProjectName: phase.Conveyor.ProjectName(), + TargetPlatform: vexTargetPlatform(images), } - - if err := phase.vexStep.Converge(ctx, vexContent, stageDesc, name, primaryImg.TargetPlatform, signer, signerIdentity); err != nil { + vexStage := stage.NewVexStage(stage.VexStageOptions{ + VexJSON: vexContent, + BaseStageOptions: baseOptions, + SigningOptions: phase.VexSigningOptions, + Publisher: func(ctx context.Context, parentDesc *imagePkg.StageDesc, imageName, targetPlatform string, content []byte, signer signature.Signer, signerIdentity string) error { + return phase.vexProcessor.Converge(ctx, content, parentDesc, imageName, targetPlatform, signer, signerIdentity) + }, + }) + if err := vexStage.MutateArtifactWithDescriptor(ctx, stageDesc); err != nil { return fmt.Errorf("unable to converge VEX for image %q: %w", name, err) } @@ -1818,7 +1953,7 @@ func (phase *BuildPhase) collectBaseImageSbom(ctx context.Context, img *image.Im return nil, nil } - baseImageSbom, err := phase.sbomStep.GetImageBOM(ctx, img.GetBaseImageName(), baseImageInfo) + baseImageSbom, err := phase.sbomProcessor.GetImageBOM(ctx, img.GetBaseImageName(), baseImageInfo) if err != nil { if errors.Is(err, ErrSbomNotRequired) { return nil, nil @@ -1870,7 +2005,7 @@ func (phase *BuildPhase) collectImportImageSboms(ctx context.Context, img *image importLookupName = importInfo.ImageName } - importImageSbom, err := phase.sbomStep.GetImageBOM(ctx, importLookupName, importImageInfo) + importImageSbom, err := phase.sbomProcessor.GetImageBOM(ctx, importLookupName, importImageInfo) if err != nil { if errors.Is(err, ErrSbomNotRequired) { continue diff --git a/pkg/build/build_phase_test.go b/pkg/build/build_phase_test.go index 74bb57e5a0..004de49dd4 100644 --- a/pkg/build/build_phase_test.go +++ b/pkg/build/build_phase_test.go @@ -13,6 +13,8 @@ import ( "github.com/werf/werf/v2/pkg/build/stage" "github.com/werf/werf/v2/pkg/config" imagePkg "github.com/werf/werf/v2/pkg/image" + "github.com/werf/werf/v2/pkg/storage" + "github.com/werf/werf/v2/pkg/storage/manager" ) var _ = Describe("BuildPhase", func() { @@ -96,13 +98,44 @@ var _ = Describe("BuildPhase", func() { ) }) - It("skips SBOM convergence when no images were selected", func(ctx SpecContext) { + Describe("artifact storage validation", func() { + It("detects VEX configuration", func(ctx SpecContext) { + img, err := image.NewImage(ctx, "linux/amd64", "app", image.NoBaseImage, image.ImageOptions{ + Vex: &config.Vex{Document: "vex.json"}, + }) + Expect(err).To(Succeed()) + tree := image.NewImagesTree(nil, image.ImagesTreeOptions{}) + tree.AppendImageForTests(img) + phase := &BuildPhase{BasePhase: BasePhase{Conveyor: &Conveyor{ + werfConfig: &config.WerfConfig{Meta: &config.Meta{}}, + imagesTree: tree, + }}} + + Expect(phase.artifactsEnabled()).To(BeTrue()) + }) + + It("rejects enabled artifacts with local-only storage", func() { + storageManager := &artifactValidationStorageManager{stages: storage.NewLocalStagesStorage(nil)} + + err := validateArtifactStorage(storageManager, true) + + Expect(err).To(MatchError("SBOM or VEX generation requires a container registry (specify --repo), or disable artifact generation")) + }) + + It("allows disabled artifacts with local-only storage", func() { + storageManager := &artifactValidationStorageManager{stages: storage.NewLocalStagesStorage(nil)} + + Expect(validateArtifactStorage(storageManager, false)).To(Succeed()) + }) + }) + + It("skips artifact propagation when no images were selected", func(ctx SpecContext) { phase := &BuildPhase{BasePhase: BasePhase{Conveyor: &Conveyor{ werfConfig: &config.WerfConfig{Meta: &config.Meta{Build: config.MetaBuild{Sbom: &config.MetaBuildSbom{Enable: true}}}}, imagesTree: &image.ImagesTree{}, }}} - Expect(phase.convergeSbomByImagesSets(ctx)).To(Succeed()) + Expect(phase.propagateArtifactsByImages(ctx)).To(Succeed()) }) It("collects content dependencies from signing mutation stages", func(ctx SpecContext) { @@ -153,7 +186,7 @@ var _ = Describe("BuildPhase", func() { images := newMultiplatformImages(ctx, &config.Vex{Document: "vex.json"}) phase := newPhaseWithTree(image.NewMultiplatformImage("app", images, 0, 1)) - err := phase.convergeImageVex(ctx, "app", images) + err := phase.runMultiplatformVexArtifactStage(ctx, "app", images) Expect(err).To(MatchError(`unable to converge VEX for image "app": stage descriptor is unavailable`)) }) @@ -162,27 +195,36 @@ var _ = Describe("BuildPhase", func() { images := newMultiplatformImages(ctx, nil) phase := newPhaseWithTree(image.NewMultiplatformImage("app", images, 0, 1)) - Expect(phase.convergeImageVex(ctx, "app", images)).To(Succeed()) + Expect(phase.runMultiplatformVexArtifactStage(ctx, "app", images)).To(Succeed()) }) It("is a no-op for an image without VEX configuration and without a stage descriptor", func(ctx SpecContext) { phase := &BuildPhase{} - Expect(phase.convergeImageVex(ctx, "app", []*image.Image{newImage(ctx, "linux/amd64", nil)})).To(Succeed()) + Expect(phase.runMultiplatformVexArtifactStage(ctx, "app", []*image.Image{newImage(ctx, "linux/amd64", nil)})).To(Succeed()) }) It("is a no-op for an image with an empty VEX document", func(ctx SpecContext) { phase := &BuildPhase{} - Expect(phase.convergeImageVex(ctx, "app", []*image.Image{newImage(ctx, "linux/amd64", &config.Vex{})})).To(Succeed()) + Expect(phase.runMultiplatformVexArtifactStage(ctx, "app", []*image.Image{newImage(ctx, "linux/amd64", &config.Vex{})})).To(Succeed()) }) It("reports an unavailable stage descriptor when VEX is configured", func(ctx SpecContext) { phase := &BuildPhase{} - err := phase.convergeImageVex(ctx, "app", []*image.Image{newImage(ctx, "linux/amd64", &config.Vex{Document: "vex.json"})}) + err := phase.runMultiplatformVexArtifactStage(ctx, "app", []*image.Image{newImage(ctx, "linux/amd64", &config.Vex{Document: "vex.json"})}) Expect(err).To(MatchError(ContainSubstring(`unable to converge VEX for image "app": stage descriptor is unavailable`))) }) + + It("continues when a multi-image stage descriptor is available", func(ctx SpecContext) { + images := newMultiplatformImages(ctx, &config.Vex{}) + multiImg := image.NewMultiplatformImage("app", images, 0, 1) + multiImg.SetStageDesc(&imagePkg.StageDesc{Info: &imagePkg.Info{}}) + phase := newPhaseWithTree(multiImg) + + Expect(phase.runMultiplatformVexArtifactStage(ctx, "app", images)).To(Succeed()) + }) }) Describe("last non-empty stage descriptor", func() { @@ -200,60 +242,6 @@ var _ = Describe("BuildPhase", func() { }) }) - Describe("vexStageDesc", func() { - It("uses the content tag descriptor of a reused single-platform image", func(ctx SpecContext) { - expected := &imagePkg.StageDesc{Info: &imagePkg.Info{Name: "repo:image"}} - img, err := image.NewImage(ctx, "linux/amd64", "app", image.NoBaseImage, image.ImageOptions{}) - Expect(err).To(Succeed()) - img.SetContentTagDesc(expected) - - Expect((&BuildPhase{}).vexStageDesc("app", []*image.Image{img})).To(BeIdenticalTo(expected)) - }) - - It("returns nil for a single-platform image without any descriptor", func(ctx SpecContext) { - img, err := image.NewImage(ctx, "linux/amd64", "app", image.NoBaseImage, image.ImageOptions{}) - Expect(err).To(Succeed()) - - Expect((&BuildPhase{}).vexStageDesc("app", []*image.Image{img})).To(BeNil()) - }) - - It("uses the descriptor of the registered multiplatform image", func(ctx SpecContext) { - images := make([]*image.Image, 0, 2) - for _, platform := range []string{"linux/amd64", "linux/arm64"} { - img, err := image.NewImage(ctx, platform, "app", image.NoBaseImage, image.ImageOptions{}) - Expect(err).To(Succeed()) - img.SetContentTagDesc(&imagePkg.StageDesc{ - StageID: imagePkg.NewStageID("digest-"+platform, 0), - Info: &imagePkg.Info{Name: "repo:" + platform}, - }) - images = append(images, img) - } - - expected := &imagePkg.StageDesc{Info: &imagePkg.Info{Name: "repo:multiplatform"}} - multiImg := image.NewMultiplatformImage("app", images, 0, 1) - multiImg.SetStageDesc(expected) - - tree := image.NewImagesTree(nil, image.ImagesTreeOptions{}) - tree.SetMultiplatformImage(multiImg) - phase := &BuildPhase{BasePhase: BasePhase{Conveyor: &Conveyor{imagesTree: tree}}} - - Expect(phase.vexStageDesc("app", images)).To(BeIdenticalTo(expected)) - }) - - It("returns nil for a multiplatform image that was never registered", func(ctx SpecContext) { - images := make([]*image.Image, 0, 2) - for _, platform := range []string{"linux/amd64", "linux/arm64"} { - img, err := image.NewImage(ctx, platform, "app", image.NoBaseImage, image.ImageOptions{}) - Expect(err).To(Succeed()) - images = append(images, img) - } - - phase := &BuildPhase{BasePhase: BasePhase{Conveyor: &Conveyor{imagesTree: image.NewImagesTree(nil, image.ImagesTreeOptions{})}}} - - Expect(phase.vexStageDesc("app", images)).To(BeNil()) - }) - }) - Describe("calculateDigest", func() { It("digest is unchanged when EnableSbom() returns false (backward compatibility)", func(ctx SpecContext) { conveyorNoSbom := &Conveyor{ @@ -361,7 +349,16 @@ var _ = Describe("BuildPhase", func() { It("returns nil for a single-platform image resolved from the cache, without a built stage image", func() { phase := &BuildPhase{} - Expect(phase.finalStageDescForImage("app", []*image.Image{{}})).To(BeNil()) + Expect(finalStageDescForImage(phase, "app", []*image.Image{{}})).To(BeNil()) }) }) }) + +type artifactValidationStorageManager struct { + manager.StorageManagerInterface + stages storage.PrimaryStagesStorage +} + +func (m *artifactValidationStorageManager) GetStagesStorage() storage.PrimaryStagesStorage { + return m.stages +} diff --git a/pkg/build/conveyor.go b/pkg/build/conveyor.go index 39655792f6..cf9b542503 100644 --- a/pkg/build/conveyor.go +++ b/pkg/build/conveyor.go @@ -942,6 +942,9 @@ func (c *Conveyor) doImage(ctx context.Context, img *image.Image, phases []Phase if contentTagDesc := img.GetContentTagDesc(); contentTagDesc != nil { logboek.Context(ctx).LogOptionalLn() + if err := runRestoredArtifactStages(ctx, img, phase); err != nil { + return fmt.Errorf("run restored artifact stages for image %s: %w", img.GetLogName(), err) + } return nil } diff --git a/pkg/build/sbom_step.go b/pkg/build/sbom_processor.go similarity index 70% rename from pkg/build/sbom_step.go rename to pkg/build/sbom_processor.go index c7e84abf98..2192820f63 100644 --- a/pkg/build/sbom_step.go +++ b/pkg/build/sbom_processor.go @@ -15,7 +15,6 @@ import ( "github.com/werf/werf/v2/pkg/attestation" "github.com/werf/werf/v2/pkg/container_backend" "github.com/werf/werf/v2/pkg/image" - "github.com/werf/werf/v2/pkg/oci/artifact" "github.com/werf/werf/v2/pkg/sbom/cyclonedxutil" "github.com/werf/werf/v2/pkg/sbom/cyclonedxutil/gost" "github.com/werf/werf/v2/pkg/sbom/externalref" @@ -24,10 +23,11 @@ import ( osPm "github.com/werf/werf/v2/pkg/sbom/packages/os_pm" "github.com/werf/werf/v2/pkg/sbom/scanner" "github.com/werf/werf/v2/pkg/storage" + "github.com/werf/werf/v2/pkg/storage/manager" "github.com/werf/werf/v2/pkg/werf/global_warnings" ) -//go:generate mockgen -source sbom_step.go -package mock -destination ../../test/mock/bom_patcher.go -mock_names BOMPatcherInterface=MockBOMPatcher +//go:generate mockgen -source sbom_processor.go -package mock -destination ../../test/mock/bom_patcher.go -mock_names BOMPatcherInterface=MockBOMPatcher type BOMPatcherInterface interface { Apply(ctx context.Context, bom *cdx.BOM) (*cdx.BOM, error) @@ -37,38 +37,38 @@ type BOMPatcherInterface interface { // (e.g. it is a trusted builder image). Callers should handle this silently. var ErrSbomNotRequired = errors.New("sbom not required") -type sbomStep struct { +type sbomProcessor struct { containerBackend container_backend.ContainerBackend stagesStorage storage.StagesStorage + storageManager manager.StorageManagerInterface gostWarnOnce sync.Once } -func newSbomStep( +func newSbomProcessor( backend container_backend.ContainerBackend, stagesStorage storage.StagesStorage, -) *sbomStep { - return &sbomStep{ + storageManager manager.StorageManagerInterface, +) *sbomProcessor { + return &sbomProcessor{ containerBackend: backend, stagesStorage: stagesStorage, + storageManager: storageManager, } } -func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, stageDesc *image.StageDesc, scanOpts scanner.ScanOptions, mergeOpts cyclonedxutil.MergeOpts, patchers []BOMPatcherInterface, osPmEnabled, isStapelScratch bool, targetPlatform string, signer signature.Signer, signerIdentity string) error { - repo := stageDesc.Info.Repository +func (processor *sbomProcessor) ConvergeWithMerge(ctx context.Context, werfImgName string, stageDesc *image.StageDesc, scanOpts scanner.ScanOptions, mergeOpts cyclonedxutil.MergeOpts, patchers []BOMPatcherInterface, osPmEnabled, isStapelScratch bool, targetPlatform string, signer signature.Signer, signerIdentity string) error { parentDigest := stageDesc.Info.GetDigest() scanOpts.Commands[0].SourcePath = stageDesc.Info.Name - if err := step.prepareGostComponents(ctx, &mergeOpts); err != nil { + if err := processor.prepareGostComponents(ctx, &mergeOpts); err != nil { return err } - checksum := step.calculateStableChecksum(scanOpts, mergeOpts, signerIdentity, targetPlatform) + checksum := processor.calculateStableChecksum(scanOpts, mergeOpts, signerIdentity, targetPlatform) - store := artifact.NewOCIStore(repo, werfImgName) - - desc, found, err := attestation.FindAttachedArtifact(ctx, store, parentDigest, attestation.PredicateKindCycloneDX) + desc, found, err := processor.storageManager.FindAttachedArtifact(ctx, processor.stagesStorage, parentDigest, werfImgName, attestation.PredicateKindCycloneDX) if err != nil { return fmt.Errorf("check SBOM cache: %w", err) } @@ -77,7 +77,7 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, return nil } - if err := step.containerBackend.Pull(ctx, stageDesc.Info.Name, container_backend.PullOpts{TargetPlatform: targetPlatform}); err != nil { + if err := processor.containerBackend.Pull(ctx, stageDesc.Info.Name, container_backend.PullOpts{TargetPlatform: targetPlatform}); err != nil { return fmt.Errorf("unable to pull %q: %w", stageDesc.Info.Name, err) } @@ -94,7 +94,7 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, }, } } else { - bomJSON, err := step.containerBackend.GenerateSBOM(ctx, scanOpts) + bomJSON, err := processor.containerBackend.GenerateSBOM(ctx, scanOpts) if err != nil { return fmt.Errorf("generate SBOM: %w", err) } @@ -117,7 +117,7 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, } if osPmEnabled { - pmBOM, err := osPm.CollectBOM(ctx, step.containerBackend, stageDesc.Info.Name) + pmBOM, err := osPm.CollectBOM(ctx, processor.containerBackend, stageDesc.Info.Name) if err != nil { return fmt.Errorf("collect os-pm BOM: %w", err) } @@ -167,7 +167,11 @@ func (step *sbomStep) ConvergeWithMerge(ctx context.Context, werfImgName string, } if err := logboek.Context(ctx).Default().LogProcess("Push SBOM artifact").DoError(func() error { - return sbomImage.PushSBOM(ctx, resultJSON, repo, parentDigest, werfImgName, checksum, targetPlatform, signer) + return processor.storageManager.PublishAttestation(ctx, processor.stagesStorage, attestation.PredicateKindCycloneDX, resultJSON, parentDigest, werfImgName, attestation.PublishAttestationOptions{ + Signer: signer, + Checksum: checksum, + TargetPlatform: targetPlatform, + }) }); err != nil { return err } @@ -187,7 +191,7 @@ const sbomArtifactFormatVersion = "2" // (build context changes alter the stage digest), external reference enrichment // (non-deterministic external data), and generator logic changes (covered by // sbomArtifactFormatVersion). -func (step *sbomStep) calculateStableChecksum(scanOpts scanner.ScanOptions, mergeOpts cyclonedxutil.MergeOpts, signerIdentity, targetPlatform string) string { +func (processor *sbomProcessor) calculateStableChecksum(scanOpts scanner.ScanOptions, mergeOpts cyclonedxutil.MergeOpts, signerIdentity, targetPlatform string) string { return util.Sha256Hash( sbomArtifactFormatVersion, "scan", scanOpts.Checksum(), @@ -199,41 +203,12 @@ func (step *sbomStep) calculateStableChecksum(scanOpts scanner.ScanOptions, merg ) } -// PropagateArtifacts copies the artifacts attached to the image stage (e.g. its SBOM) -// into the final repo and the cache repos. Stages themselves are copied there before -// SBOM generation runs, so the artifacts have to catch up separately. -func (step *sbomStep) PropagateArtifacts(ctx context.Context, werfImgName string, stageDesc, finalStageDesc *image.StageDesc, cacheStagesStorageList []storage.StagesStorage) error { - srcRepo := stageDesc.Info.Repository - srcDigest := stageDesc.Info.GetDigest() - - if finalStageDesc != nil && finalStageDesc.Info.Repository != srcRepo { - if err := logboek.Context(ctx).Default().LogProcess("image %s: Copy SBOM artifacts into the final repo %s", werfImgName, finalStageDesc.Info.Repository).DoError(func() error { - return artifact.CopyAttachedArtifacts(ctx, srcRepo, srcDigest, finalStageDesc.Info.Repository, finalStageDesc.Info.GetDigest()) - }); err != nil { - return fmt.Errorf("copy attached artifacts into final repo %s: %w", finalStageDesc.Info.Repository, err) - } - } - - for _, cache := range cacheStagesStorageList { - if cache.Address() == storage.LocalStorageAddress || cache.Address() == srcRepo { - continue - } - if err := logboek.Context(ctx).Info().LogProcess("image %s: Copy SBOM artifacts into cache %s", werfImgName, cache.String()).DoError(func() error { - return artifact.CopyAttachedArtifacts(ctx, srcRepo, srcDigest, cache.Address(), srcDigest) - }); err != nil { - logboek.Context(ctx).Warn().LogF("Warning: unable to copy attached artifacts into cache stages storage %s: %s\n", cache.String(), err) - } - } - - return nil -} - -func (step *sbomStep) GetImageBOM(ctx context.Context, imageName string, imageInfo *image.Info) (*cdx.BOM, error) { +func (processor *sbomProcessor) GetImageBOM(ctx context.Context, imageName string, imageInfo *image.Info) (*cdx.BOM, error) { if imageInfo == nil { return nil, fmt.Errorf("image info is nil for %q", imageName) } - bom, err := step.pullImageSbom(ctx, imageName, imageInfo) + bom, err := processor.pullImageSbom(ctx, imageName, imageInfo) if err != nil { if isTrustedBuilderImage(imageInfo.Labels) { switch { @@ -258,7 +233,7 @@ func sbomMissingError(imageInfo *image.Info, err error) error { return fmt.Errorf("the image %q must have an SBOM artifact attached; to generate an SBOM for the image, rebuild it with SBOM generation enabled; note: if the image is a multi-platform image built by an older werf version, its SBOM is attached in a legacy platform-ambiguous format and cannot be used — rebuild the image with a newer werf version: %w", imageInfo.Name, err) } -func (step *sbomStep) pullImageSbom(ctx context.Context, imageName string, imageInfo *image.Info) (*cdx.BOM, error) { +func (processor *sbomProcessor) pullImageSbom(ctx context.Context, imageName string, imageInfo *image.Info) (*cdx.BOM, error) { parentDigest := imageInfo.GetDigest() if parentDigest == "" { return nil, fmt.Errorf("image digest not available for %q", imageInfo.Name) @@ -277,9 +252,9 @@ func (step *sbomStep) pullImageSbom(ctx context.Context, imageName string, image return bom, nil } -func (step *sbomStep) prepareGostComponents(ctx context.Context, mergeOpts *cyclonedxutil.MergeOpts) error { +func (processor *sbomProcessor) prepareGostComponents(ctx context.Context, mergeOpts *cyclonedxutil.MergeOpts) error { if !mergeOpts.Gost.AttackSurface.IsUndefined() || !mergeOpts.Gost.SecurityFunction.IsUndefined() { - step.gostWarnOnce.Do(func() { + processor.gostWarnOnce.Do(func() { logboek.Context(ctx).Default().LogF("Warning: GOST SBOM integration is experimental and its behavior may change in the future\n") }) } diff --git a/pkg/build/sbom_step_checksum_test.go b/pkg/build/sbom_step_checksum_test.go deleted file mode 100644 index fdb3fb862a..0000000000 --- a/pkg/build/sbom_step_checksum_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package build - -import ( - cdx "github.com/CycloneDX/cyclonedx-go" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/werf/werf/v2/pkg/sbom/cyclonedxutil" - "github.com/werf/werf/v2/pkg/sbom/cyclonedxutil/gost" - "github.com/werf/werf/v2/pkg/sbom/scanner" -) - -var _ = Describe("SbomStep Checksum", func() { - type checksumInputs struct { - scanOpts scanner.ScanOptions - mergeOpts cyclonedxutil.MergeOpts - signerIdentity string - targetPlatform string - } - - baseline := func() checksumInputs { - return checksumInputs{ - scanOpts: scanner.ScanOptions{}, - mergeOpts: cyclonedxutil.MergeOpts{}, - } - } - - checksumOf := func(in checksumInputs) string { - step := &sbomStep{} - return step.calculateStableChecksum(in.scanOpts, in.mergeOpts, in.signerIdentity, in.targetPlatform) - } - - It("same inputs produce same checksum", func() { - Expect(checksumOf(baseline())).To(Equal(checksumOf(baseline()))) - }) - - DescribeTable("changes when a single input changes", - func(mutate func(in *checksumInputs)) { - mutated := baseline() - mutate(&mutated) - Expect(checksumOf(mutated)).NotTo(Equal(checksumOf(baseline()))) - }, - Entry("scan options", func(in *checksumInputs) { - in.scanOpts = scanner.ScanOptions{Commands: []scanner.ScanCommand{{SourcePath: "image"}}} - }), - Entry("merge options: base BOM", func(in *checksumInputs) { - in.mergeOpts.BaseBOM = &cdx.BOM{ - BOMFormat: "CycloneDX", - SpecVersion: cdx.SpecVersion1_6, - Components: &[]cdx.Component{{Name: "base-lib", Version: "1.0.0"}}, - } - }), - Entry("gost attack surface", func(in *checksumInputs) { - in.mergeOpts.Gost.AttackSurface = gost.GostValueYes - }), - Entry("gost security function", func(in *checksumInputs) { - in.mergeOpts.Gost.SecurityFunction = gost.GostValueIndirect - }), - Entry("signer identity", func(in *checksumInputs) { - in.signerIdentity = "signer:abc" - }), - Entry("target platform", func(in *checksumInputs) { - in.targetPlatform = "linux/amd64" - }), - ) - - It("GOST config changes checksum even without base and import BOMs", func() { - withGost := baseline() - withGost.mergeOpts.Gost = gost.Config{ - AttackSurface: gost.GostValueYes, - SecurityFunction: gost.GostValueIndirect, - } - - Expect(withGost.mergeOpts.IsEmpty()).To(BeTrue()) - Expect(checksumOf(withGost)).NotTo(Equal(checksumOf(baseline()))) - }) - - It("different signer identities produce different checksums", func() { - first := baseline() - first.signerIdentity = "signer:key1" - - second := baseline() - second.signerIdentity = "signer:key2" - - Expect(checksumOf(first)).NotTo(Equal(checksumOf(second))) - }) - - It("format version change invalidates cache", func() { - Expect(checksumOf(baseline())).NotTo(Equal("aa969eabe2faad149265a94e60b173e527e0bc27898afcd0ec4e85a06b28f29b"), - "checksum must differ from format-v1 era (before format version was added)") - }) - - Describe("target platform", func() { - It("differs between platforms", func() { - amd64 := baseline() - amd64.targetPlatform = "linux/amd64" - - arm64 := baseline() - arm64.targetPlatform = "linux/arm64" - - Expect(checksumOf(amd64)).NotTo(Equal(checksumOf(arm64))) - }) - - It("is stable for the same platform", func() { - platform := baseline() - platform.targetPlatform = "linux/arm64" - - Expect(checksumOf(platform)).To(Equal(checksumOf(platform))) - }) - - It("changes checksum independently of signer identity", func() { - signedPlatformless := baseline() - signedPlatformless.signerIdentity = "signer:abc" - - signedPlatform := signedPlatformless - signedPlatform.targetPlatform = "linux/amd64" - - Expect(checksumOf(signedPlatformless)).NotTo(Equal(checksumOf(signedPlatform))) - }) - }) - - Describe("part encoding", func() { - It("does not collide when a part value absorbs a slot boundary", func() { - // A separator-joined encoding maps both of these onto the same input: - // "...-a-b" from a single part "a-b", and "...-a-b" from parts "a" and "b". - joinedIntoSigner := baseline() - joinedIntoSigner.signerIdentity = "a-b" - - splitAcrossParts := baseline() - splitAcrossParts.signerIdentity = "a" - splitAcrossParts.targetPlatform = "b" - - Expect(checksumOf(joinedIntoSigner)).NotTo(Equal(checksumOf(splitAcrossParts))) - }) - - It("does not collide when the same value moves between adjacent parts", func() { - asSigner := baseline() - asSigner.signerIdentity = "linux/amd64" - - asPlatform := baseline() - asPlatform.targetPlatform = "linux/amd64" - - Expect(checksumOf(asSigner)).NotTo(Equal(checksumOf(asPlatform))) - }) - - It("yields pairwise distinct checksums across single-input flips", func() { - flips := map[string]checksumInputs{"baseline": baseline()} - - scanFlip := baseline() - scanFlip.scanOpts = scanner.ScanOptions{Commands: []scanner.ScanCommand{{SourcePath: "image"}}} - flips["scan"] = scanFlip - - gostFlip := baseline() - gostFlip.mergeOpts.Gost.AttackSurface = gost.GostValueYes - flips["gost"] = gostFlip - - signerFlip := baseline() - signerFlip.signerIdentity = "signer:abc" - flips["signer"] = signerFlip - - platformFlip := baseline() - platformFlip.targetPlatform = "linux/arm64" - flips["platform"] = platformFlip - - seen := map[string]string{} - for name, in := range flips { - sum := checksumOf(in) - Expect(seen).NotTo(HaveKey(sum), "checksum of %q collides with %q", name, seen[sum]) - seen[sum] = name - } - }) - }) -}) diff --git a/pkg/build/sbom_step_error_test.go b/pkg/build/sbom_step_error_test.go deleted file mode 100644 index 92dd113dc2..0000000000 --- a/pkg/build/sbom_step_error_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package build - -import ( - "errors" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/werf/werf/v2/pkg/image" -) - -var _ = Describe("SbomStep SBOM missing error", func() { - It("keeps the attach guidance, stays dependency-kind neutral, and adds the legacy multi-platform hint", func() { - cause := errors.New("pull SBOM for \"base\": artifact not found") - err := sbomMissingError(&image.Info{Name: "registry.example.com/base:tag"}, cause) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("registry.example.com/base:tag")) - Expect(err.Error()).To(ContainSubstring("must have an SBOM artifact attached")) - Expect(err.Error()).To(ContainSubstring("rebuild the image with a newer werf version")) - Expect(err.Error()).To(ContainSubstring("legacy platform-ambiguous format")) - Expect(err.Error()).NotTo(ContainSubstring("base image"), "GetImageBOM serves both base and import dependencies; the message must not claim the image is a base") - Expect(errors.Is(err, cause)).To(BeTrue()) - }) -}) diff --git a/pkg/build/sbom_step_propagate_test.go b/pkg/build/sbom_step_propagate_test.go deleted file mode 100644 index 3dc796cc18..0000000000 --- a/pkg/build/sbom_step_propagate_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package build - -import ( - "net/http/httptest" - "strings" - - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/name" - "github.com/google/go-containerregistry/pkg/registry" - "github.com/google/go-containerregistry/pkg/v1/random" - "github.com/google/go-containerregistry/pkg/v1/remote" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "go.uber.org/mock/gomock" - - "github.com/werf/werf/v2/pkg/attestation" - "github.com/werf/werf/v2/pkg/docker_registry" - werfImage "github.com/werf/werf/v2/pkg/image" - "github.com/werf/werf/v2/pkg/oci/artifact" - "github.com/werf/werf/v2/pkg/storage" - "github.com/werf/werf/v2/test/mock" -) - -var _ = Describe("SbomStep PropagateArtifacts", func() { - var ( - server *httptest.Server - srcRepo string - finalRepo string - cacheRepo string - srcDigest string - remoteOpts []remote.Option - ) - - pushRandomImage := func(ctx SpecContext, repo string) string { - img, err := random.Image(256, 1) - Expect(err).To(Succeed()) - - ref, err := name.NewTag(repo + ":v1") - Expect(err).To(Succeed()) - Expect(remote.Write(ref, img, append([]remote.Option{remote.WithContext(ctx)}, remoteOpts...)...)).To(Succeed()) - - dgst, err := img.Digest() - Expect(err).To(Succeed()) - return dgst.String() - } - - copyImageByDigest := func(ctx SpecContext, fromRepo, toRepo, digest string) { - fromRef, err := name.NewDigest(fromRepo + "@" + digest) - Expect(err).To(Succeed()) - img, err := remote.Image(fromRef, append([]remote.Option{remote.WithContext(ctx)}, remoteOpts...)...) - Expect(err).To(Succeed()) - - toRef, err := name.NewDigest(toRepo + "@" + digest) - Expect(err).To(Succeed()) - Expect(remote.Write(toRef, img, append([]remote.Option{remote.WithContext(ctx)}, remoteOpts...)...)).To(Succeed()) - } - - stageDescFor := func(repo, digest string) *werfImage.StageDesc { - return &werfImage.StageDesc{ - StageID: &werfImage.StageID{}, - Info: &werfImage.Info{ - Repository: repo, - RepoDigest: repo + "@" + digest, - }, - } - } - - cacheStorage := func(address string) storage.StagesStorage { - s := mock.NewMockStagesStorage(gomock.NewController(GinkgoT())) - s.EXPECT().Address().Return(address).AnyTimes() - s.EXPECT().String().Return(address).AnyTimes() - return s - } - - BeforeEach(func(ctx SpecContext) { - Expect(docker_registry.Init(ctx, false, false, nil, nil)).To(Succeed()) - - server = httptest.NewServer(registry.New()) - host := strings.TrimPrefix(server.URL, "http://") - srcRepo = host + "/test/stages" - finalRepo = host + "/test/final" - cacheRepo = host + "/test/cache" - remoteOpts = []remote.Option{remote.WithAuth(authn.Anonymous)} - - srcDigest = pushRandomImage(ctx, srcRepo) - - srcStore := artifact.NewOCIStore(srcRepo, "app", remoteOpts...) - Expect(srcStore.Attach(ctx, srcDigest, attestation.DSSEMediaType, []byte(`{"v":1}`), "checksum-v1", "", "")).To(Succeed()) - }) - - AfterEach(func() { - server.Close() - }) - - It("should copy the SBOM into the final repo", func(ctx SpecContext) { - copyImageByDigest(ctx, srcRepo, finalRepo, srcDigest) - - step := &sbomStep{} - Expect(step.PropagateArtifacts(ctx, "app", stageDescFor(srcRepo, srcDigest), stageDescFor(finalRepo, srcDigest), nil)).To(Succeed()) - - finalStore := artifact.NewOCIStore(finalRepo, "app", remoteOpts...) - content, err := finalStore.GetAttachedContent(ctx, srcDigest, attestation.DSSEMediaType, nil) - Expect(err).To(Succeed()) - Expect(content).To(MatchJSON(`{"v":1}`)) - }) - - It("should copy the SBOM into cache repos", func(ctx SpecContext) { - copyImageByDigest(ctx, srcRepo, cacheRepo, srcDigest) - - step := &sbomStep{} - caches := []storage.StagesStorage{ - cacheStorage(storage.LocalStorageAddress), - cacheStorage(srcRepo), - cacheStorage(cacheRepo), - } - Expect(step.PropagateArtifacts(ctx, "app", stageDescFor(srcRepo, srcDigest), nil, caches)).To(Succeed()) - - cacheStore := artifact.NewOCIStore(cacheRepo, "app", remoteOpts...) - content, err := cacheStore.GetAttachedContent(ctx, srcDigest, attestation.DSSEMediaType, nil) - Expect(err).To(Succeed()) - Expect(content).To(MatchJSON(`{"v":1}`)) - }) - - It("should do nothing without a final repo and caches", func(ctx SpecContext) { - step := &sbomStep{} - Expect(step.PropagateArtifacts(ctx, "app", stageDescFor(srcRepo, srcDigest), nil, nil)).To(Succeed()) - }) - - It("should skip the final repo when it matches the stages repo", func(ctx SpecContext) { - step := &sbomStep{} - Expect(step.PropagateArtifacts(ctx, "app", stageDescFor(srcRepo, srcDigest), stageDescFor(srcRepo, srcDigest), nil)).To(Succeed()) - }) - - It("should not fail when a cache repo is unreachable", func(ctx SpecContext) { - step := &sbomStep{} - caches := []storage.StagesStorage{cacheStorage("127.0.0.1:1/unreachable/cache")} - Expect(step.PropagateArtifacts(ctx, "app", stageDescFor(srcRepo, srcDigest), nil, caches)).To(Succeed()) - }) - - It("should fail when the final repo copy fails", func(ctx SpecContext) { - step := &sbomStep{} - err := step.PropagateArtifacts(ctx, "app", stageDescFor(srcRepo, srcDigest), stageDescFor("127.0.0.1:1/unreachable/final", srcDigest), nil) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("copy attached artifacts into final repo")) - }) -}) diff --git a/pkg/build/sbom_step_test.go b/pkg/build/sbom_step_test.go deleted file mode 100644 index 7e3d650295..0000000000 --- a/pkg/build/sbom_step_test.go +++ /dev/null @@ -1,209 +0,0 @@ -package build - -import ( - "bytes" - "context" - "errors" - "path/filepath" - "strings" - - cdx "github.com/CycloneDX/cyclonedx-go" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "go.uber.org/mock/gomock" - - "github.com/werf/logboek" - werfImage "github.com/werf/werf/v2/pkg/image" - "github.com/werf/werf/v2/pkg/logging" - "github.com/werf/werf/v2/pkg/sbom/cyclonedxutil" - "github.com/werf/werf/v2/pkg/sbom/cyclonedxutil/gost" - "github.com/werf/werf/v2/pkg/sbom/gomod" - "github.com/werf/werf/v2/test/mock" -) - -var _ = Describe("SbomStep", func() { - Describe("prepareGostComponents", func() { - It("prints the GOST experimental warning at most once per step instance", func() { - var output bytes.Buffer - ctx := logboek.NewContext(context.Background(), logboek.NewLogger(&output, &output)) - - step := &sbomStep{} - mergeOpts := cyclonedxutil.MergeOpts{Gost: gost.Config{AttackSurface: gost.GostValueYes, SecurityFunction: gost.GostValueYes}} - - Expect(step.prepareGostComponents(ctx, &mergeOpts)).To(Succeed()) - Expect(step.prepareGostComponents(ctx, &mergeOpts)).To(Succeed()) - Expect(step.prepareGostComponents(ctx, &mergeOpts)).To(Succeed()) - - Expect(strings.Count(output.String(), "GOST SBOM integration is experimental")).To(Equal(1)) - }) - }) - - Describe("GetImageBOM()", func() { - It("should return error if image info is nil", func(ctx SpecContext) { - step := &sbomStep{} - _, err := step.GetImageBOM(ctx, "app", nil) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("image info is nil")) - }) - - It("should return fatal error if image digest is empty", func(ctx SpecContext) { - step := &sbomStep{} - imgInfo := &werfImage.Info{Name: "app:latest"} - _, err := step.GetImageBOM(ctx, "app", imgInfo) - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, ErrSbomNotRequired)).To(BeFalse()) - }) - }) - - Describe("BOMPatcher (gomod)", func() { - DescribeTable("Apply()", - func( - ctx context.Context, - setupGitRepo func(ctx context.Context, repo *mock.MockGitRepo, commit, imageContext string), - ) { - repo := mock.NewMockGitRepo(gomock.NewController(GinkgoT())) - commit := "0123456789abcdef0123456789abcdef01234567" - imageContext := "app" - setupGitRepo(ctx, repo, commit, imageContext) - - patcher := gomod.NewBOMPatcher(repo, commit, imageContext) - bom := &cdx.BOM{ - Metadata: &cdx.Metadata{ - Component: &cdx.Component{ - Name: "app", - }, - }, - } - - res, err := patcher.Apply(ctx, bom) - Expect(err).ToNot(HaveOccurred()) - Expect(res).ToNot(BeNil()) - }, - Entry( - "[go.mod]: should skip version resolution when go.mod is missing", - logging.WithLogger(context.Background()), - func(ctx context.Context, repo *mock.MockGitRepo, commit, imageContext string) { - repo.EXPECT().IsCommitFileExist(ctx, commit, filepath.Join(imageContext, "go.mod")).Return(false, nil) - }, - ), - Entry( - "[go.mod]: should use tag version when tag matches commit", - logging.WithLogger(context.Background()), - func(ctx context.Context, repo *mock.MockGitRepo, commit, imageContext string) { - goModPath := filepath.Join(imageContext, "go.mod") - repo.EXPECT().IsCommitFileExist(ctx, commit, goModPath).Return(true, nil) - repo.EXPECT().ReadCommitFile(ctx, commit, goModPath).Return([]byte("module example.com/app\n"), nil) - repo.EXPECT().TagsList(ctx).Return([]string{"v1.2.3"}, nil) - repo.EXPECT().TagCommit(ctx, "v1.2.3").Return(commit, nil) - }, - ), - Entry( - "[go.mod]: should fallback to pseudo version when tag mismatch", - logging.WithLogger(context.Background()), - func(ctx context.Context, repo *mock.MockGitRepo, commit, imageContext string) { - goModPath := filepath.Join(imageContext, "go.mod") - repo.EXPECT().IsCommitFileExist(ctx, commit, goModPath).Return(true, nil) - repo.EXPECT().ReadCommitFile(ctx, commit, goModPath).Return([]byte("module example.com/app\n"), nil) - repo.EXPECT().TagsList(ctx).Return([]string{"v1.2.3"}, nil) - repo.EXPECT().TagCommit(ctx, "v1.2.3").Return("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", nil) - }, - ), - ) - }) - - Describe("isTrustedBuilderImage()", func() { - DescribeTable("should detect trusted builder images", - func(labels map[string]string, expected bool) { - Expect(isTrustedBuilderImage(labels)).To(Equal(expected)) - }, - Entry("nil labels", nil, false), - Entry("empty labels", map[string]string{}, false), - Entry("label set to false", map[string]string{werfImage.DeckhouseInternalBuilderLabel: "false"}, false), - Entry("label set to true", map[string]string{werfImage.DeckhouseInternalBuilderLabel: "true"}, true), - Entry("other labels without builder", map[string]string{"foo": "bar", "baz": "qux"}, false), - Entry("other labels with builder true", map[string]string{"foo": "bar", werfImage.DeckhouseInternalBuilderLabel: "true", "baz": "qux"}, true), - ) - }) - - Describe("GetImageBOM() with trusted builder image", func() { - It("should return hard error for builder image from different namespace", func(ctx SpecContext) { - step := &sbomStep{} - - imageInfo := &werfImage.Info{ - Name: "docker.io/namespace/repo:builder-tag", - Repository: "docker.io/namespace/repo", - Labels: map[string]string{ - werfImage.DeckhouseInternalBuilderLabel: "true", - }, - } - - _, err := step.GetImageBOM(ctx, "builder-image", imageInfo) - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, ErrSbomNotRequired)).To(BeFalse()) - Expect(err.Error()).To(ContainSubstring("the image is a builder image but SBOM is required")) - }) - - It("should return ErrSbomNotRequired for golang builder image from container-factory", func(ctx SpecContext) { - step := &sbomStep{} - - imageInfo := &werfImage.Info{ - Name: "registry.deckhouse.io/container-factory/builder/golang-alpine:1.25", - Repository: "registry.deckhouse.io/container-factory/builder/golang-alpine", - Labels: map[string]string{ - werfImage.DeckhouseInternalBuilderLabel: "true", - }, - } - - _, err := step.GetImageBOM(logging.WithLogger(ctx), "builder-image", imageInfo) - Expect(err).To(MatchError(ErrSbomNotRequired)) - }) - - It("should return ErrSbomNotRequired for alpine builder image from container-factory", func(ctx SpecContext) { - step := &sbomStep{} - - imageInfo := &werfImage.Info{ - Name: "registry.deckhouse.io/container-factory/builder/alpine:3.22", - Repository: "registry.deckhouse.io/container-factory/builder/alpine", - Labels: map[string]string{ - werfImage.DeckhouseInternalBuilderLabel: "true", - }, - } - - _, err := step.GetImageBOM(ctx, "builder-image", imageInfo) - Expect(err).To(MatchError(ErrSbomNotRequired)) - }) - - It("should return hard error for other builder image from container-factory", func(ctx SpecContext) { - step := &sbomStep{} - - imageInfo := &werfImage.Info{ - Name: "registry.deckhouse.io/container-factory/builder/scratch", - Repository: "registry.deckhouse.io/container-factory/builder/scratch", - Labels: map[string]string{ - werfImage.DeckhouseInternalBuilderLabel: "true", - }, - } - - _, err := step.GetImageBOM(ctx, "builder-image", imageInfo) - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, ErrSbomNotRequired)).To(BeFalse()) - Expect(err.Error()).To(ContainSubstring("the image is a builder image but SBOM is required")) - }) - - It("should return actionable error for non-builder image when SBOM pull fails", func(ctx SpecContext) { - step := &sbomStep{} - - imageInfo := &werfImage.Info{ - Name: "docker.io/namespace/repo:some-tag", - Repository: "docker.io/namespace/repo", - Labels: map[string]string{}, - } - - _, err := step.GetImageBOM(ctx, "app", imageInfo) - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, ErrSbomNotRequired)).To(BeFalse()) - Expect(err.Error()).NotTo(ContainSubstring(werfImage.DeckhouseInternalBuilderLabel)) - Expect(err.Error()).To(ContainSubstring("rebuild it with SBOM generation enabled")) - }) - }) -}) diff --git a/pkg/build/stage/artifact_test.go b/pkg/build/stage/artifact_test.go new file mode 100644 index 0000000000..3b76726b1b --- /dev/null +++ b/pkg/build/stage/artifact_test.go @@ -0,0 +1,140 @@ +package stage + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/sigstore/sigstore/pkg/signature" + "go.uber.org/mock/gomock" + + "github.com/werf/werf/v2/pkg/build/signing" + "github.com/werf/werf/v2/pkg/image" + "github.com/werf/werf/v2/test/mock" +) + +var _ = Describe("artifact stages", func() { + It("stores artifact identity and lifecycle flags", func() { + base := NewBaseStage(Sbom, &BaseStageOptions{}) + metadata := &ArtifactStageMetadata{ + Kind: ArtifactKindSbom, + ParentDigest: "sha256:parent", + TargetPlatform: "linux/amd64", + Mutable: true, + Buildable: false, + } + + base.SetArtifactMetadata(metadata) + + Expect(base.GetArtifactMetadata()).To(BeIdenticalTo(metadata)) + Expect(base.GetArtifactMetadata().Kind).To(Equal(ArtifactKindSbom)) + Expect(base.GetArtifactMetadata().ParentDigest).To(Equal("sha256:parent")) + Expect(base.GetArtifactMetadata().TargetPlatform).To(Equal("linux/amd64")) + Expect(base.GetArtifactMetadata().Mutable).To(BeTrue()) + Expect(base.GetArtifactMetadata().Buildable).To(BeFalse()) + }) + + It("publishes SBOM through its stage publisher without mutating image content", func(ctx SpecContext) { + ctrl := gomock.NewController(GinkgoT()) + parentImage := mock.NewMockLegacyImageInterface(ctrl) + parentDesc := &image.StageDesc{Info: &image.Info{Repository: "registry.example/app", RepoDigest: "registry.example/app@sha256:parent"}} + parentImage.EXPECT().GetStageDesc().Return(parentDesc) + publisherCalls := 0 + artifactStage := NewSbomStage(SbomStageOptions{ + BaseStageOptions: &BaseStageOptions{ImageName: "app", TargetPlatform: "linux/amd64"}, + Dependency: "scanner-input", + Publisher: func(_ context.Context, gotDesc *image.StageDesc, imageName, platform string) error { + publisherCalls++ + Expect(gotDesc).To(BeIdenticalTo(parentDesc)) + Expect(imageName).To(Equal("app")) + Expect(platform).To(Equal("linux/amd64")) + return nil + }, + }) + parent := NewStageImage(NewContainerBackendStub(), "", parentImage) + stageImage := NewStageImage(NewContainerBackendStub(), "", mock.NewMockLegacyImageInterface(ctrl)) + + Expect(artifactStage.MutateArtifact(ctx, parent, stageImage)).To(Succeed()) + Expect(publisherCalls).To(Equal(1)) + Expect(artifactStage.GetArtifactMetadata().ParentDigest).To(Equal("sha256:parent")) + }) + + It("publishes VEX through an explicit descriptor without requiring an image stage", func(ctx SpecContext) { + parentDesc := &image.StageDesc{Info: &image.Info{Repository: "registry.example/app", RepoDigest: "registry.example/app@sha256:index"}} + publisherCalls := 0 + artifactStage := NewVexStage(VexStageOptions{ + VexJSON: []byte(`{"statements":[]}`), + BaseStageOptions: &BaseStageOptions{ImageName: "app"}, + Publisher: func(_ context.Context, gotDesc *image.StageDesc, imageName, platform string, content []byte, _ signature.Signer, identity string) error { + publisherCalls++ + Expect(gotDesc).To(BeIdenticalTo(parentDesc)) + Expect(imageName).To(Equal("app")) + Expect(platform).To(BeEmpty()) + Expect(content).To(MatchJSON(`{"statements":[]}`)) + Expect(identity).To(BeEmpty()) + return nil + }, + }) + + Expect(artifactStage.MutateArtifactWithDescriptor(ctx, parentDesc)).To(Succeed()) + Expect(publisherCalls).To(Equal(1)) + Expect(artifactStage.GetArtifactMetadata().ParentDigest).To(Equal("sha256:index")) + }) + + It("publishes VEX through its stage publisher without requiring image mutation", func(ctx SpecContext) { + ctrl := gomock.NewController(GinkgoT()) + parentImage := mock.NewMockLegacyImageInterface(ctrl) + parentDesc := &image.StageDesc{Info: &image.Info{Repository: "registry.example/app", RepoDigest: "registry.example/app@sha256:parent"}} + parentImage.EXPECT().GetStageDesc().Return(parentDesc) + publisherCalls := 0 + artifactStage := NewVexStage(VexStageOptions{ + VexJSON: []byte(`{"statements":[]}`), + BaseStageOptions: &BaseStageOptions{ImageName: "app"}, + Publisher: func(_ context.Context, gotDesc *image.StageDesc, imageName, platform string, content []byte, _ signature.Signer, identity string) error { + publisherCalls++ + Expect(gotDesc).To(BeIdenticalTo(parentDesc)) + Expect(imageName).To(Equal("app")) + Expect(platform).To(BeEmpty()) + Expect(content).To(MatchJSON(`{"statements":[]}`)) + Expect(identity).To(BeEmpty()) + return nil + }, + }) + parent := NewStageImage(NewContainerBackendStub(), "", parentImage) + stageImage := NewStageImage(NewContainerBackendStub(), "", mock.NewMockLegacyImageInterface(ctrl)) + + Expect(artifactStage.MutateArtifact(ctx, parent, stageImage)).To(Succeed()) + Expect(publisherCalls).To(Equal(1)) + Expect(artifactStage.GetArtifactMetadata().ParentDigest).To(Equal("sha256:parent")) + }) + + DescribeTable("is mutable, non-buildable, and artifact-only", + func(artifactStage ArtifactStage, stageLifecycle Interface) { + Expect(stageLifecycle.IsMutable()).To(BeTrue()) + Expect(stageLifecycle.IsBuildable()).To(BeFalse()) + Expect(artifactStage).NotTo(BeNil()) + }, + Entry("SBOM", GenerateSbomStage(&BaseStageOptions{TargetPlatform: "linux/amd64"}, signing.SbomSigningOptions{}, "dependency", func(context.Context, *image.StageDesc, string, string) error { + return nil + }), GenerateSbomStage(&BaseStageOptions{TargetPlatform: "linux/amd64"}, signing.SbomSigningOptions{}, "dependency", func(context.Context, *image.StageDesc, string, string) error { + return nil + })), + Entry("VEX", GenerateVexStage([]byte(`{"statements":[]}`), &BaseStageOptions{TargetPlatform: "linux/amd64"}, signing.VexSigningOptions{}), GenerateVexStage([]byte(`{"statements":[]}`), &BaseStageOptions{TargetPlatform: "linux/amd64"}, signing.VexSigningOptions{})), + ) + + It("includes the parent descriptor in artifact stage dependencies", func(ctx SpecContext) { + sbom := GenerateSbomStage(&BaseStageOptions{TargetPlatform: "linux/amd64"}, signing.SbomSigningOptions{}, "dependency", func(context.Context, *image.StageDesc, string, string) error { + return nil + }) + withoutParent, err := sbom.GetDependencies(ctx, nil, nil, nil, nil, nil) + Expect(err).To(Succeed()) + + ctrl := gomock.NewController(GinkgoT()) + parentImage := mock.NewMockLegacyImageInterface(ctrl) + parentImage.EXPECT().GetStageDesc().Return(&image.StageDesc{Info: &image.Info{RepoDigest: "repo@sha256:parent"}}) + parent := NewStageImage(NewContainerBackendStub(), "", parentImage) + withParent, err := sbom.GetDependencies(ctx, nil, nil, nil, parent, nil) + Expect(err).To(Succeed()) + Expect(withParent).NotTo(Equal(withoutParent)) + }) +}) diff --git a/pkg/build/stage/base.go b/pkg/build/stage/base.go index af2e0380cd..1fd4d9b4bb 100644 --- a/pkg/build/stage/base.go +++ b/pkg/build/stage/base.go @@ -39,6 +39,8 @@ const ( Dockerfile StageName = "dockerfile" ImageSpec StageName = "imageSpec" Sign StageName = "sign" + Sbom StageName = "sbom" + Vex StageName = "vex" VerityAnnotation StageName = "verityAnnotation" ) @@ -106,6 +108,7 @@ type BaseStage struct { networkOverride string needsNetwork bool meta *StageMeta + artifactMetadata *ArtifactStageMetadata isContentAnchor bool } @@ -117,6 +120,29 @@ func (s *BaseStage) SetContentAnchor(v bool) { s.isContentAnchor = v } +type ArtifactKind string + +const ( + ArtifactKindSbom ArtifactKind = "sbom" + ArtifactKindVex ArtifactKind = "vex" +) + +type ArtifactStageMetadata struct { + Kind ArtifactKind + ParentDigest string + TargetPlatform string + Mutable bool + Buildable bool +} + +func (s *BaseStage) SetArtifactMetadata(metadata *ArtifactStageMetadata) { + s.artifactMetadata = metadata +} + +func (s *BaseStage) GetArtifactMetadata() *ArtifactStageMetadata { + return s.artifactMetadata +} + type StageMeta struct { Rebuilt bool BaseImagePulled bool diff --git a/pkg/build/stage/interface.go b/pkg/build/stage/interface.go index 233f58b73a..a7677ba722 100644 --- a/pkg/build/stage/interface.go +++ b/pkg/build/stage/interface.go @@ -8,6 +8,14 @@ import ( "github.com/werf/werf/v2/pkg/image" ) +type ArtifactStage interface { + MutateArtifact(ctx context.Context, prevBuiltImage, stageImage *StageImage) error +} + +type ImageStage interface { + MutateImage(ctx context.Context, registry ImageMutatorPusher, prevBuiltImage, stageImage *StageImage) error +} + type Interface interface { Name() StageName LogDetailedName() string @@ -38,8 +46,6 @@ type Interface interface { SetGitMappings([]*GitMapping) GetGitMappings() []*GitMapping - MutateImage(ctx context.Context, registry ImageMutatorPusher, prevBuiltImage, stageImage *StageImage) error - SelectSuitableStageDesc(context.Context, Conveyor, image.StageDescSet) (*image.StageDesc, error) HasPrevStage() bool diff --git a/pkg/build/stage/sbom.go b/pkg/build/stage/sbom.go new file mode 100644 index 0000000000..71d5a9138c --- /dev/null +++ b/pkg/build/stage/sbom.go @@ -0,0 +1,130 @@ +package stage + +import ( + "context" + "fmt" + + "github.com/werf/common-go/pkg/util" + "github.com/werf/werf/v2/pkg/build/signing" + "github.com/werf/werf/v2/pkg/container_backend" + "github.com/werf/werf/v2/pkg/image" +) + +type SbomStagePublisher func(ctx context.Context, parentDesc *image.StageDesc, imageName, targetPlatform string) error + +type SbomStageOptions struct { + BaseStageOptions *BaseStageOptions + SigningOptions signing.SbomSigningOptions + Dependency string + Publisher SbomStagePublisher +} + +type SbomStage struct { + *BaseStage + + publisher SbomStagePublisher + dependency string + signerIdentity string +} + +func GenerateSbomStage(baseStageOptions *BaseStageOptions, sbomSigningOptions signing.SbomSigningOptions, dependency string, publisher SbomStagePublisher) *SbomStage { + return NewSbomStage(SbomStageOptions{ + BaseStageOptions: baseStageOptions, + SigningOptions: sbomSigningOptions, + Dependency: dependency, + Publisher: publisher, + }) +} + +func NewSbomStage(options SbomStageOptions) *SbomStage { + return newSbomStage(options.BaseStageOptions, options.SigningOptions, options.Dependency, options.Publisher) +} + +func newSbomStage(baseStageOptions *BaseStageOptions, sbomSigningOptions signing.SbomSigningOptions, dependency string, publisher SbomStagePublisher) *SbomStage { + var signerIdentity string + if sbomSigningOptions.Enabled { + signerIdentity = sbomSigningOptions.Signer().Fingerprint() + } + + stage := &SbomStage{ + BaseStage: NewBaseStage(Sbom, baseStageOptions), + publisher: publisher, + dependency: dependency, + signerIdentity: signerIdentity, + } + stage.SetArtifactMetadata(&ArtifactStageMetadata{ + Kind: ArtifactKindSbom, + TargetPlatform: baseStageOptions.TargetPlatform, + Mutable: true, + Buildable: false, + }) + return stage +} + +var ( + _ Interface = (*SbomStage)(nil) + _ ArtifactStage = (*SbomStage)(nil) +) + +func (s *SbomStage) IsBuildable() bool { + return false +} + +func (s *SbomStage) IsMutable() bool { + return true +} + +func (s *SbomStage) PrepareImage(_ context.Context, _ Conveyor, _ container_backend.ContainerBackend, _, _ *StageImage, _ container_backend.BuildContextArchiver) error { + return nil +} + +func (s *SbomStage) GetDependencies(_ context.Context, _ Conveyor, _ container_backend.ContainerBackend, _, prevBuiltImage *StageImage, _ container_backend.BuildContextArchiver) (string, error) { + parentDigest := "" + if prevBuiltImage != nil && prevBuiltImage.Image != nil { + if stageDesc := prevBuiltImage.Image.GetStageDesc(); stageDesc != nil && stageDesc.Info != nil { + parentDigest = stageDesc.Info.GetDigest() + } + } + + return util.Sha256Hash( + sbomArtifactFormatVersion, + "inputs", s.dependency, + "parent", parentDigest, + "signer", s.signerIdentity, + "platform", s.TargetPlatform(), + ), nil +} + +func (s *SbomStage) GetContentDependencies(ctx context.Context, c Conveyor, buildContextArchive container_backend.BuildContextArchiver) (string, error) { + return s.GetDependencies(ctx, c, nil, nil, nil, buildContextArchive) +} + +func (s *SbomStage) MutateArtifact(ctx context.Context, prevBuiltImage, stageImage *StageImage) error { + if s.publisher == nil { + return fmt.Errorf("SBOM stage publisher is unavailable") + } + if prevBuiltImage == nil || prevBuiltImage.Image == nil { + return fmt.Errorf("SBOM stage parent image is unavailable") + } + if stageImage == nil || stageImage.Image == nil { + return fmt.Errorf("SBOM stage image is unavailable") + } + + parentDesc := prevBuiltImage.Image.GetStageDesc() + if parentDesc == nil || parentDesc.Info == nil { + return fmt.Errorf("SBOM stage parent descriptor is unavailable") + } + if parentDesc.Info.Repository == "" { + return fmt.Errorf("SBOM stage parent descriptor repository is empty") + } + if parentDesc.Info.GetDigest() == "" { + return fmt.Errorf("SBOM stage parent descriptor digest is empty") + } + + metadata := s.GetArtifactMetadata() + metadata.ParentDigest = parentDesc.Info.GetDigest() + + return s.publisher(ctx, parentDesc, s.ImageName(), s.TargetPlatform()) +} + +const sbomArtifactFormatVersion = "2" diff --git a/pkg/build/stage/sbom_test.go b/pkg/build/stage/sbom_test.go new file mode 100644 index 0000000000..90582c8eaf --- /dev/null +++ b/pkg/build/stage/sbom_test.go @@ -0,0 +1,64 @@ +package stage + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" + + "github.com/werf/werf/v2/pkg/build/signing" + "github.com/werf/werf/v2/pkg/image" + "github.com/werf/werf/v2/test/mock" +) + +var _ = Describe("SbomStage dependencies", func() { + It("changes when the target platform changes", func(ctx SpecContext) { + newStage := func(platform string) *SbomStage { + return GenerateSbomStage(&BaseStageOptions{TargetPlatform: platform}, signing.SbomSigningOptions{}, "scanner-input", func(context.Context, *image.StageDesc, string, string) error { + return nil + }) + } + + amd, err := newStage("linux/amd64").GetDependencies(ctx, nil, nil, nil, nil, nil) + Expect(err).To(Succeed()) + arm, err := newStage("linux/arm64").GetDependencies(ctx, nil, nil, nil, nil, nil) + Expect(err).To(Succeed()) + + Expect(amd).NotTo(Equal(arm)) + content, err := newStage("linux/amd64").GetContentDependencies(ctx, nil, nil) + Expect(err).To(Succeed()) + Expect(content).To(Equal(amd)) + }) + + It("changes when the effective SBOM inputs change", func(ctx SpecContext) { + newDependencies := func(inputs string) string { + stage := GenerateSbomStage(&BaseStageOptions{TargetPlatform: "linux/amd64"}, signing.SbomSigningOptions{}, inputs, func(context.Context, *image.StageDesc, string, string) error { + return nil + }) + dependencies, err := stage.GetDependencies(ctx, nil, nil, nil, nil, nil) + Expect(err).To(Succeed()) + return dependencies + } + + Expect(newDependencies("scanner=v1;merge=v1;gost=yes")).NotTo(Equal(newDependencies("scanner=v2;merge=v1;gost=yes"))) + Expect(newDependencies("scanner=v1;merge=v1;gost=yes")).NotTo(Equal(newDependencies("scanner=v1;merge=v2;gost=yes"))) + }) + + It("changes when the parent manifest digest changes", func(ctx SpecContext) { + newDependencies := func(digest string) string { + ctrl := gomock.NewController(GinkgoT()) + parentImage := mock.NewMockLegacyImageInterface(ctrl) + parentImage.EXPECT().GetStageDesc().Return(&image.StageDesc{Info: &image.Info{RepoDigest: "repo@" + digest}}) + parent := NewStageImage(NewContainerBackendStub(), "", parentImage) + stage := GenerateSbomStage(&BaseStageOptions{TargetPlatform: "linux/amd64"}, signing.SbomSigningOptions{}, "scanner-input", func(context.Context, *image.StageDesc, string, string) error { + return nil + }) + dependencies, err := stage.GetDependencies(ctx, nil, nil, nil, parent, nil) + Expect(err).To(Succeed()) + return dependencies + } + + Expect(newDependencies("sha256:one")).NotTo(Equal(newDependencies("sha256:two"))) + }) +}) diff --git a/pkg/build/stage/vex.go b/pkg/build/stage/vex.go new file mode 100644 index 0000000000..a37bc4eccc --- /dev/null +++ b/pkg/build/stage/vex.go @@ -0,0 +1,147 @@ +package stage + +import ( + "context" + "fmt" + "strings" + + "github.com/sigstore/sigstore/pkg/signature" + + "github.com/werf/common-go/pkg/util" + "github.com/werf/werf/v2/pkg/build/signing" + "github.com/werf/werf/v2/pkg/container_backend" + "github.com/werf/werf/v2/pkg/image" +) + +type VexStagePublisher func(ctx context.Context, parentDesc *image.StageDesc, imageName, targetPlatform string, vexJSON []byte, signer signature.Signer, signerIdentity string) error + +type VexStageOptions struct { + VexJSON []byte + BaseStageOptions *BaseStageOptions + SigningOptions signing.VexSigningOptions + Publisher VexStagePublisher +} + +type VexStage struct { + *BaseStage + + vexJSON []byte + signer signature.Signer + signerIdentity string + publisher VexStagePublisher +} + +func GenerateVexStage(vexJSON []byte, baseStageOptions *BaseStageOptions, vexSigningOptions signing.VexSigningOptions) *VexStage { + return NewVexStage(VexStageOptions{ + VexJSON: vexJSON, + BaseStageOptions: baseStageOptions, + SigningOptions: vexSigningOptions, + }) +} + +func NewVexStage(options VexStageOptions) *VexStage { + return newVexStage(options.VexJSON, options.BaseStageOptions, options.SigningOptions, options.Publisher) +} + +func newVexStage(vexJSON []byte, baseStageOptions *BaseStageOptions, vexSigningOptions signing.VexSigningOptions, publisher VexStagePublisher) *VexStage { + var signer signature.Signer + var signerIdentity string + if vexSigningOptions.Enabled { + signer = vexSigningOptions.Signer().SignerVerifier() + signerIdentity = vexSigningOptions.Signer().Fingerprint() + } + + stage := &VexStage{ + BaseStage: NewBaseStage(Vex, baseStageOptions), + vexJSON: vexJSON, + signer: signer, + signerIdentity: signerIdentity, + publisher: publisher, + } + stage.SetArtifactMetadata(&ArtifactStageMetadata{ + Kind: ArtifactKindVex, + TargetPlatform: baseStageOptions.TargetPlatform, + Mutable: true, + Buildable: false, + }) + return stage +} + +var ( + _ Interface = (*VexStage)(nil) + _ ArtifactStage = (*VexStage)(nil) +) + +func (s *VexStage) IsBuildable() bool { + return false +} + +func (s *VexStage) IsMutable() bool { + return true +} + +func (s *VexStage) PrepareImage(_ context.Context, _ Conveyor, _ container_backend.ContainerBackend, _, _ *StageImage, _ container_backend.BuildContextArchiver) error { + return nil +} + +func (s *VexStage) GetDependencies(_ context.Context, _ Conveyor, _ container_backend.ContainerBackend, _, prevBuiltImage *StageImage, _ container_backend.BuildContextArchiver) (string, error) { + parentDigest := "" + if prevBuiltImage != nil && prevBuiltImage.Image != nil { + if stageDesc := prevBuiltImage.Image.GetStageDesc(); stageDesc != nil && stageDesc.Info != nil { + parentDigest = stageDesc.Info.GetDigest() + } + } + + return CalculateVexStageChecksum(s.vexJSON, parentDigest, s.signerIdentity), nil +} + +func (s *VexStage) GetContentDependencies(ctx context.Context, c Conveyor, buildContextArchive container_backend.BuildContextArchiver) (string, error) { + return s.GetDependencies(ctx, c, nil, nil, nil, buildContextArchive) +} + +func (s *VexStage) MutateArtifact(ctx context.Context, prevBuiltImage, stageImage *StageImage) error { + if prevBuiltImage == nil || prevBuiltImage.Image == nil { + return fmt.Errorf("VEX stage parent image is unavailable") + } + if stageImage == nil || stageImage.Image == nil { + return fmt.Errorf("VEX stage image is unavailable") + } + + return s.MutateArtifactWithDescriptor(ctx, prevBuiltImage.Image.GetStageDesc()) +} + +func (s *VexStage) MutateArtifactWithDescriptor(ctx context.Context, parentDesc *image.StageDesc) error { + if parentDesc == nil || parentDesc.Info == nil { + return fmt.Errorf("VEX stage parent descriptor is unavailable") + } + if parentDesc.Info.Repository == "" { + return fmt.Errorf("VEX stage parent descriptor repository is empty") + } + + parentDigest := parentDesc.Info.GetDigest() + if parentDigest == "" { + return fmt.Errorf("VEX stage parent descriptor digest is empty") + } + + metadata := s.GetArtifactMetadata() + metadata.ParentDigest = parentDigest + + if s.publisher == nil { + return fmt.Errorf("VEX stage publisher is unavailable") + } + + return s.publisher(ctx, parentDesc, s.ImageName(), s.TargetPlatform(), s.vexJSON, s.signer, s.signerIdentity) +} + +const vexStageArtifactFormatVersion = "2" + +// CalculateVexStageChecksum returns the cache identity for a VEX artifact. +func CalculateVexStageChecksum(vexJSON []byte, parentDigest, signerIdentity string) string { + parts := []string{ + vexStageArtifactFormatVersion, + util.Sha256Hash(string(vexJSON)), + parentDigest, + signerIdentity, + } + return util.Sha256Hash(strings.Join(parts, "-")) +} diff --git a/pkg/build/stage/vex_test.go b/pkg/build/stage/vex_test.go new file mode 100644 index 0000000000..514508687a --- /dev/null +++ b/pkg/build/stage/vex_test.go @@ -0,0 +1,22 @@ +package stage + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/werf/werf/v2/pkg/build/signing" +) + +var _ = Describe("VexStage dependencies", func() { + It("changes when the VEX document changes", func(ctx SpecContext) { + first, err := GenerateVexStage([]byte(`{"statements":[]}`), &BaseStageOptions{TargetPlatform: ""}, signing.VexSigningOptions{}).GetDependencies(ctx, nil, nil, nil, nil, nil) + Expect(err).To(Succeed()) + second, err := GenerateVexStage([]byte(`{"statements":[{"status":"not_affected"}]}`), &BaseStageOptions{TargetPlatform: ""}, signing.VexSigningOptions{}).GetDependencies(ctx, nil, nil, nil, nil, nil) + Expect(err).To(Succeed()) + + Expect(first).NotTo(Equal(second)) + content, err := GenerateVexStage([]byte(`{"statements":[]}`), &BaseStageOptions{TargetPlatform: ""}, signing.VexSigningOptions{}).GetContentDependencies(ctx, nil, nil) + Expect(err).To(Succeed()) + Expect(content).To(Equal(first)) + }) +}) diff --git a/pkg/build/stages_iterator.go b/pkg/build/stages_iterator.go index f3766cb0b3..a057477a77 100644 --- a/pkg/build/stages_iterator.go +++ b/pkg/build/stages_iterator.go @@ -68,6 +68,13 @@ func (iterator *StagesIterator) OnImageStage(ctx context.Context, img *build_ima iterator.PrevStage = stg if !isEmpty { + artifactStage, isArtifactStage := stg.(interface { + GetArtifactMetadata() *stage.ArtifactStageMetadata + }) + if isArtifactStage && artifactStage.GetArtifactMetadata() != nil { + return nil + } + iterator.PrevNonEmptyStage = stg if iterator.PrevNonEmptyStage.GetStageImage().Image.GetStageDesc() != nil { diff --git a/pkg/build/vex_processor.go b/pkg/build/vex_processor.go new file mode 100644 index 0000000000..019d1ed7e7 --- /dev/null +++ b/pkg/build/vex_processor.go @@ -0,0 +1,46 @@ +package build + +import ( + "context" + "fmt" + + "github.com/sigstore/sigstore/pkg/signature" + + "github.com/werf/logboek" + "github.com/werf/werf/v2/pkg/attestation" + "github.com/werf/werf/v2/pkg/build/stage" + "github.com/werf/werf/v2/pkg/image" + "github.com/werf/werf/v2/pkg/storage" + "github.com/werf/werf/v2/pkg/storage/manager" +) + +type vexProcessor struct { + stagesStorage storage.StagesStorage + storageManager manager.StorageManagerInterface +} + +func newVexProcessor(stagesStorage storage.StagesStorage, storageManager manager.StorageManagerInterface) *vexProcessor { + return &vexProcessor{stagesStorage: stagesStorage, storageManager: storageManager} +} + +func (processor *vexProcessor) Converge(ctx context.Context, vexJSON []byte, stageDesc *image.StageDesc, werfImgName, targetPlatform string, signer signature.Signer, signerIdentity string) error { + parentDigest := stageDesc.Info.GetDigest() + + checksum := stage.CalculateVexStageChecksum(vexJSON, parentDigest, signerIdentity) + + desc, found, err := processor.storageManager.FindAttachedArtifact(ctx, processor.stagesStorage, parentDigest, werfImgName, attestation.PredicateKindOpenVEX) + if err != nil { + return fmt.Errorf("check VEX publish needed: %w", err) + } + needed := !found || desc.Annotations[image.WerfChecksumAnnotation] != checksum + if !needed { + logboek.Context(ctx).Default().LogF("image %s: VEX artifact is up to date — skipping publish\n", werfImgName) + return nil + } + + return logboek.Context(ctx).Default().LogProcess("image %s: Published VEX artifact", werfImgName).DoError(func() error { + return processor.storageManager.PublishAttestation(ctx, processor.stagesStorage, attestation.PredicateKindOpenVEX, vexJSON, parentDigest, werfImgName, attestation.PublishAttestationOptions{ + Signer: signer, Checksum: checksum, TargetPlatform: targetPlatform, + }) + }) +} diff --git a/pkg/build/vex_step.go b/pkg/build/vex_step.go deleted file mode 100644 index e3bf51cc7e..0000000000 --- a/pkg/build/vex_step.go +++ /dev/null @@ -1,76 +0,0 @@ -package build - -import ( - "context" - "fmt" - "strings" - - "github.com/sigstore/sigstore/pkg/signature" - - "github.com/werf/common-go/pkg/util" - "github.com/werf/logboek" - "github.com/werf/werf/v2/pkg/attestation" - "github.com/werf/werf/v2/pkg/image" - "github.com/werf/werf/v2/pkg/oci/artifact" - vexImage "github.com/werf/werf/v2/pkg/vex/image" -) - -type vexStep struct{} - -func newVexStep() *vexStep { - return &vexStep{} -} - -func (step *vexStep) Converge(ctx context.Context, vexJSON []byte, stageDesc *image.StageDesc, werfImgName, targetPlatform string, signer signature.Signer, signerIdentity string) error { - repo := stageDesc.Info.Repository - parentDigest := stageDesc.Info.GetDigest() - - checksum := calculateVEXChecksum(vexJSON, parentDigest, signerIdentity) - - store := artifact.NewOCIStore(repo, werfImgName) - - needed, err := checkVEXPublishNeeded(ctx, store, parentDigest, checksum) - if err != nil { - return fmt.Errorf("check VEX publish needed: %w", err) - } - if !needed { - logboek.Context(ctx).Default().LogF("image %s: VEX artifact is up to date — skipping publish\n", werfImgName) - return nil - } - - return logboek.Context(ctx).Default().LogProcess("image %s: Published VEX artifact", werfImgName).DoError(func() error { - return vexImage.PushVEX(ctx, vexJSON, repo, parentDigest, werfImgName, checksum, targetPlatform, signer) - }) -} - -const vexArtifactFormatVersion = "2" - -// calculateVEXChecksum builds the cache identity of the VEX artifact: document -// content and parent digest (FR-011 of 013-vex-lifecycle), the bump-able artifact -// format version, and the signer public-key fingerprint — so enabling signing, -// rotating the key, or bumping the format each republish the artifact. -func calculateVEXChecksum(vexJSON []byte, parentDigest, signerIdentity string) string { - parts := []string{ - vexArtifactFormatVersion, - util.Sha256Hash(string(vexJSON)), - parentDigest, - signerIdentity, - } - return util.Sha256Hash(strings.Join(parts, "-")) -} - -// checkVEXPublishNeeded returns true if the VEX artifact should be published -// (no existing VEX artifact of either format or its checksum annotation differs -// from the current checksum), and false if publishing can be skipped. A legacy -// annotation-less entry never matches the current checksum formula, so it always -// triggers a republish regardless of its actual kind. -func checkVEXPublishNeeded(ctx context.Context, store artifact.Store, parentDigest, checksum string) (bool, error) { - desc, found, err := attestation.FindAttachedArtifact(ctx, store, parentDigest, attestation.PredicateKindOpenVEX) - if err != nil { - return false, fmt.Errorf("check VEX cache: %w", err) - } - if found && desc.Annotations[image.WerfChecksumAnnotation] == checksum { - return false, nil - } - return true, nil -} diff --git a/pkg/build/vex_step_test.go b/pkg/build/vex_step_test.go deleted file mode 100644 index 5d72568d1f..0000000000 --- a/pkg/build/vex_step_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package build - -import ( - v1 "github.com/google/go-containerregistry/pkg/v1" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "go.uber.org/mock/gomock" - - "github.com/werf/werf/v2/pkg/attestation" - "github.com/werf/werf/v2/pkg/image" - "github.com/werf/werf/v2/pkg/vex" - "github.com/werf/werf/v2/test/mock" -) - -var _ = Describe("VexStep", func() { - Describe("checkVEXPublishNeeded", func() { - const ( - parentDigest = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" - matchingChecksum = "abc123" - differentChecksum = "xyz789" - ) - - It("should skip VEX publish when a bundle artifact exists with matching checksum", func(ctx SpecContext) { - ctrl := gomock.NewController(GinkgoT()) - defer ctrl.Finish() - - mockStore := mock.NewMockStore(ctrl) - mockStore.EXPECT().GetAttached(ctx, parentDigest, attestation.BundleMediaType, vex.VEXPredicateTypes).Return( - v1.Descriptor{ - Annotations: map[string]string{ - image.WerfChecksumAnnotation: matchingChecksum, - }, - }, true, nil, - ) - - needed, err := checkVEXPublishNeeded(ctx, mockStore, parentDigest, matchingChecksum) - Expect(err).ToNot(HaveOccurred()) - Expect(needed).To(BeFalse()) - }) - - It("should skip VEX publish when a bare-DSSE artifact exists with matching checksum", func(ctx SpecContext) { - ctrl := gomock.NewController(GinkgoT()) - defer ctrl.Finish() - - mockStore := mock.NewMockStore(ctrl) - mockStore.EXPECT().GetAttached(ctx, parentDigest, attestation.BundleMediaType, vex.VEXPredicateTypes).Return( - v1.Descriptor{}, false, nil, - ) - mockStore.EXPECT().GetAttached(ctx, parentDigest, vex.DSSEMediaType, vex.VEXPredicateTypes).Return( - v1.Descriptor{ - Annotations: map[string]string{ - image.WerfChecksumAnnotation: matchingChecksum, - }, - }, true, nil, - ) - - needed, err := checkVEXPublishNeeded(ctx, mockStore, parentDigest, matchingChecksum) - Expect(err).ToNot(HaveOccurred()) - Expect(needed).To(BeFalse()) - }) - - It("should proceed with VEX publish when checksum differs", func(ctx SpecContext) { - ctrl := gomock.NewController(GinkgoT()) - defer ctrl.Finish() - - mockStore := mock.NewMockStore(ctrl) - mockStore.EXPECT().GetAttached(ctx, parentDigest, attestation.BundleMediaType, vex.VEXPredicateTypes).Return( - v1.Descriptor{}, false, nil, - ) - mockStore.EXPECT().GetAttached(ctx, parentDigest, vex.DSSEMediaType, vex.VEXPredicateTypes).Return( - v1.Descriptor{ - Annotations: map[string]string{ - image.WerfChecksumAnnotation: differentChecksum, - }, - }, true, nil, - ) - - needed, err := checkVEXPublishNeeded(ctx, mockStore, parentDigest, matchingChecksum) - Expect(err).ToNot(HaveOccurred()) - Expect(needed).To(BeTrue()) - }) - - It("should proceed with VEX publish when no artifact exists", func(ctx SpecContext) { - ctrl := gomock.NewController(GinkgoT()) - defer ctrl.Finish() - - mockStore := mock.NewMockStore(ctrl) - mockStore.EXPECT().GetAttached(ctx, parentDigest, attestation.BundleMediaType, vex.VEXPredicateTypes).Return( - v1.Descriptor{}, false, nil, - ) - mockStore.EXPECT().GetAttached(ctx, parentDigest, vex.DSSEMediaType, vex.VEXPredicateTypes).Return( - v1.Descriptor{}, false, nil, - ) - - needed, err := checkVEXPublishNeeded(ctx, mockStore, parentDigest, matchingChecksum) - Expect(err).ToNot(HaveOccurred()) - Expect(needed).To(BeTrue()) - }) - }) - - Describe("calculateVEXChecksum", func() { - const parentDigest = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" - - vexJSON := []byte(`{"@context":"https://openvex.dev/ns/v0.2.0","statements":[]}`) - - It("is stable for identical inputs", func() { - Expect(calculateVEXChecksum(vexJSON, parentDigest, "")).To(Equal(calculateVEXChecksum(vexJSON, parentDigest, ""))) - }) - - DescribeTable("changes when any identity component changes", - func(otherJSON []byte, otherDigest, otherIdentity string) { - base := calculateVEXChecksum(vexJSON, parentDigest, "") - Expect(calculateVEXChecksum(otherJSON, otherDigest, otherIdentity)).ToNot(Equal(base)) - }, - Entry("document content changed", []byte(`{"@context":"https://openvex.dev/ns/v0.2.0","statements":[{}]}`), parentDigest, ""), - Entry("image digest changed", vexJSON, "sha256:1111111111111111111111111111111111111111111111111111111111111111", ""), - Entry("signing enabled (fingerprint added)", vexJSON, parentDigest, "fingerprint-a"), - ) - - It("changes when the signing key is rotated", func() { - Expect(calculateVEXChecksum(vexJSON, parentDigest, "fingerprint-a")).ToNot(Equal(calculateVEXChecksum(vexJSON, parentDigest, "fingerprint-b"))) - }) - }) -}) diff --git a/pkg/cleaning/cleanup.go b/pkg/cleaning/cleanup.go index fcc6c22722..3d271ce4ef 100644 --- a/pkg/cleaning/cleanup.go +++ b/pkg/cleaning/cleanup.go @@ -1052,6 +1052,15 @@ func (m *cleanupManager) cleanupOrphanedArtifacts(ctx context.Context) error { } } + for _, cacheStagesStorage := range m.StorageManager.GetCacheStagesStorageList() { + if cacheStagesStorage == nil || cacheStagesStorage.Address() == storage.LocalStorageAddress { + continue + } + if err := deleteOrphanedArtifacts(ctx, cacheStagesStorage, m.DryRun); err != nil { + return fmt.Errorf("delete orphaned artifacts from cache repo %s: %w", cacheStagesStorage.String(), err) + } + } + return nil } diff --git a/pkg/cleaning/cleanup_test.go b/pkg/cleaning/cleanup_test.go index 68b0d9a757..cce3a9063e 100644 --- a/pkg/cleaning/cleanup_test.go +++ b/pkg/cleaning/cleanup_test.go @@ -103,6 +103,20 @@ var _ = Describe("cleanupManager.cleanupOrphanedArtifacts", func() { Expect(sm.stages.deletedArtifacts).To(Equal([]string{"repo:sha256-abc123"})) }) + It("cleans propagated cache repositories", func() { + sm := newFakeStorageManager() + cache := mock.NewMockStagesStorage(gomock.NewController(GinkgoT())) + cache.EXPECT().Address().Return("registry.example.com/cache").AnyTimes() + cache.EXPECT().String().Return("registry.example.com/cache").AnyTimes() + cache.EXPECT().GetOrphanedArtifactNames(gomock.Any()).Return([]string{"cache:sha256-abc123"}, nil) + cache.EXPECT().DeleteArtifact(gomock.Any(), "cache:sha256-abc123").Return(nil) + sm.caches = []storage.StagesStorage{cache} + + m := &cleanupManager{StorageManager: sm} + + Expect(m.cleanupOrphanedArtifacts(context.Background())).To(Succeed()) + }) + It("reports which repo failed when the final repo cannot be cleaned", func() { sm := newFakeStorageManager() @@ -188,6 +202,7 @@ type fakeStorageManager struct { stages *fakePrimaryStagesStorage meta *fakePrimaryStagesStorage final storage.StagesStorage + caches []storage.StagesStorage stageDescSet image.StageDescSet finalStageDescSet image.StageDescSet @@ -229,6 +244,10 @@ func (f *fakeStorageManager) GetFinalStagesStorage() storage.StagesStorage { return f.final } +func (f *fakeStorageManager) GetCacheStagesStorageList() []storage.StagesStorage { + return f.caches +} + func (f *fakeStorageManager) ForEachRejectedStage(ctx context.Context, stageIDs []image.StageID, cb func(ctx context.Context, stageID image.StageID) error) error { for _, id := range stageIDs { if err := cb(ctx, id); err != nil { diff --git a/pkg/docker_registry/api.go b/pkg/docker_registry/api.go index 952796e41a..ed5c69bf2c 100644 --- a/pkg/docker_registry/api.go +++ b/pkg/docker_registry/api.go @@ -237,6 +237,9 @@ func (api *api) getRepoImageByDesc(ctx context.Context, originalTag string, desc if err != nil { return nil, fmt.Errorf("error getting image %s descriptor: %w", subref, err) } + if desc.Platform != nil { + subInfo.Platform = desc.Platform.String() + } repoImage.Index = append(repoImage.Index, subInfo) } } else { diff --git a/pkg/image/info.go b/pkg/image/info.go index 496b37d575..695eeaaf26 100644 --- a/pkg/image/info.go +++ b/pkg/image/info.go @@ -31,8 +31,9 @@ type Info struct { CreatedAtUnixNano int64 `json:"createdAtUnixNano"` Volumes map[string]struct{} `json:"volumes"` - IsIndex bool - Index []*Info + IsIndex bool + Platform string `json:"platform,omitempty"` + Index []*Info } func (info *Info) GetDigest() string { @@ -74,7 +75,8 @@ func (info *Info) GetCopy() *Info { CreatedAtUnixNano: info.CreatedAtUnixNano, Volumes: util.CopyMap(info.Volumes), - IsIndex: info.IsIndex, + IsIndex: info.IsIndex, + Platform: info.Platform, } for _, i := range info.Index { diff --git a/pkg/storage/local_stages_storage.go b/pkg/storage/local_stages_storage.go index 736f337d1d..584ce9806c 100644 --- a/pkg/storage/local_stages_storage.go +++ b/pkg/storage/local_stages_storage.go @@ -10,6 +10,7 @@ import ( "github.com/werf/common-go/pkg/util" "github.com/werf/logboek" + "github.com/werf/werf/v2/pkg/attestation" "github.com/werf/werf/v2/pkg/container_backend" "github.com/werf/werf/v2/pkg/docker_registry" "github.com/werf/werf/v2/pkg/docker_registry/api" @@ -262,6 +263,26 @@ func (storage *LocalStagesStorage) RmImageMetadata(ctx context.Context, projectN return nil } +func (storage *LocalStagesStorage) ListAttachedArtifacts(_ context.Context, _ string) ([]v1.Descriptor, error) { + return nil, fmt.Errorf("local stages storage does not support artifact operations") +} + +func (storage *LocalStagesStorage) FindAttachedArtifact(_ context.Context, _, _ string, _ attestation.PredicateKind) (v1.Descriptor, bool, error) { + return v1.Descriptor{}, false, fmt.Errorf("local stages storage does not support artifact operations") +} + +func (storage *LocalStagesStorage) PublishAttestation(_ context.Context, _ attestation.PredicateKind, _ []byte, _, _ string, _ attestation.PublishAttestationOptions) error { + return fmt.Errorf("local stages storage does not support artifact operations") +} + +func (storage *LocalStagesStorage) PublishArtifact(_ context.Context, _, _ string, _ []byte, _, _, _, _ string) error { + return fmt.Errorf("local stages storage does not support artifact operations") +} + +func (storage *LocalStagesStorage) CopyAttachedArtifacts(_ context.Context, _, _, _, _ string) error { + return fmt.Errorf("local stages storage does not support artifact operations") +} + func (storage *LocalStagesStorage) GetOrphanedArtifactNames(_ context.Context) ([]string, error) { return nil, nil } diff --git a/pkg/storage/local_stages_storage_test.go b/pkg/storage/local_stages_storage_test.go index 32d6ff151b..496eceefbc 100644 --- a/pkg/storage/local_stages_storage_test.go +++ b/pkg/storage/local_stages_storage_test.go @@ -43,6 +43,15 @@ func (b *localMutationBackendStub) Tag(ctx context.Context, ref, newRef string, } var _ = Describe("LocalStagesStorage", func() { + It("rejects OCI artifact operations explicitly", func(ctx SpecContext) { + storage := NewLocalStagesStorage(nil) + + _, err := storage.ListAttachedArtifacts(ctx, "sha256:parent") + Expect(err).To(MatchError("local stages storage does not support artifact operations")) + Expect(storage.PublishArtifact(ctx, "sha256:parent", "application/test", []byte("payload"), "image", "checksum", "", "")).To(MatchError("local stages storage does not support artifact operations")) + Expect(storage.CopyAttachedArtifacts(ctx, "source", "sha256:source", "destination", "sha256:destination")).To(MatchError("local stages storage does not support artifact operations")) + }) + It("tags the mutated local image under the destination reference", func(ctx SpecContext) { logCtx := logboek.NewContext(ctx, logboek.NewLogger(io.Discard, io.Discard)) diff --git a/pkg/storage/manager/artifact_operations_test.go b/pkg/storage/manager/artifact_operations_test.go new file mode 100644 index 0000000000..ca890e72b3 --- /dev/null +++ b/pkg/storage/manager/artifact_operations_test.go @@ -0,0 +1,159 @@ +package manager + +import ( + "context" + "errors" + + v1 "github.com/google/go-containerregistry/pkg/v1" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/werf/werf/v2/pkg/attestation" + "github.com/werf/werf/v2/pkg/image" + "github.com/werf/werf/v2/pkg/storage" +) + +type artifactOperationsStorage struct { + storage.StagesStorage + address string + + listedParent string + publishedParent string + copiedSourceRepo string + copiedSourceDigest string + copiedDestRepo string + copiedDestDigest string + resolvedProject string + resolvedStage image.StageID + resolveResult *image.StageDesc + operationError error + foundArtifact v1.Descriptor + foundArtifactPresent bool + foundArtifactKind attestation.PredicateKind + foundArtifactName string + publishedKind attestation.PredicateKind + publishedImageName string +} + +func (s *artifactOperationsStorage) Address() string { return s.address } +func (s *artifactOperationsStorage) String() string { return s.address } + +func (s *artifactOperationsStorage) ListAttachedArtifacts(_ context.Context, parentDigest string) ([]v1.Descriptor, error) { + s.listedParent = parentDigest + return []v1.Descriptor{{Digest: v1.Hash{Algorithm: "sha256", Hex: "artifact"}}}, s.operationError +} + +func (s *artifactOperationsStorage) PublishArtifact(_ context.Context, parentDigest, _ string, _ []byte, _, _, _, _ string) error { + s.publishedParent = parentDigest + return s.operationError +} + +func (s *artifactOperationsStorage) CopyAttachedArtifacts(_ context.Context, sourceRepository, sourceDigest, destinationRepository, destinationDigest string) error { + s.copiedSourceRepo = sourceRepository + s.copiedSourceDigest = sourceDigest + s.copiedDestRepo = destinationRepository + s.copiedDestDigest = destinationDigest + return s.operationError +} + +func (s *artifactOperationsStorage) FindAttachedArtifact(_ context.Context, _, imageName string, kind attestation.PredicateKind) (v1.Descriptor, bool, error) { + s.foundArtifactName = imageName + s.foundArtifactKind = kind + return s.foundArtifact, s.foundArtifactPresent, s.operationError +} + +func (s *artifactOperationsStorage) PublishAttestation(_ context.Context, kind attestation.PredicateKind, _ []byte, _, imageName string, _ attestation.PublishAttestationOptions) error { + s.publishedKind = kind + s.publishedImageName = imageName + return s.operationError +} + +func (s *artifactOperationsStorage) GetStageDesc(_ context.Context, projectName string, stageID image.StageID) (*image.StageDesc, error) { + s.resolvedProject = projectName + s.resolvedStage = stageID + return s.resolveResult, s.operationError +} + +var _ = Describe("StorageManager artifact operations", func() { + It("routes listing, publication, resolution, and copying through the selected storages", func(ctx SpecContext) { + source := &artifactOperationsStorage{address: "registry.example/source"} + destination := &artifactOperationsStorage{address: "registry.example/destination"} + stageID := image.StageID{Digest: "stage-digest", CreationTs: 42} + resolved := &image.StageDesc{StageID: &stageID} + destination.resolveResult = resolved + manager := &StorageManager{ProjectName: "project"} + + artifacts, err := manager.ListAttachedArtifacts(ctx, source, "sha256:parent") + Expect(err).NotTo(HaveOccurred()) + Expect(artifacts).To(HaveLen(1)) + Expect(source.listedParent).To(Equal("sha256:parent")) + + Expect(manager.PublishArtifact(ctx, destination, "sha256:parent", "application/test", []byte("payload"), "image", "checksum", "", "")).To(Succeed()) + Expect(destination.publishedParent).To(Equal("sha256:parent")) + + result, err := manager.ResolveStageDescriptor(ctx, destination, stageID) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(BeIdenticalTo(resolved)) + Expect(destination.resolvedProject).To(Equal("project")) + Expect(destination.resolvedStage).To(Equal(stageID)) + + Expect(manager.CopyAttachedArtifacts(ctx, source, "sha256:source", destination, "sha256:destination")).To(Succeed()) + Expect(destination.copiedSourceRepo).To(Equal(source.address)) + Expect(destination.copiedSourceDigest).To(Equal("sha256:source")) + Expect(destination.copiedDestRepo).To(Equal(destination.address)) + Expect(destination.copiedDestDigest).To(Equal("sha256:destination")) + }) + + It("routes attestation lookup and publication through the selected storage", func(ctx SpecContext) { + stages := &artifactOperationsStorage{address: "registry.example/repository", foundArtifactPresent: true, foundArtifact: v1.Descriptor{Digest: v1.Hash{Algorithm: "sha256", Hex: "artifact"}}} + manager := &StorageManager{} + + descriptor, found, err := manager.FindAttachedArtifact(ctx, stages, "sha256:parent", "app", attestation.PredicateKindOpenVEX) + Expect(err).NotTo(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(descriptor.Digest.Hex).To(Equal("artifact")) + Expect(stages.foundArtifactName).To(Equal("app")) + Expect(stages.foundArtifactKind).To(Equal(attestation.PredicateKindOpenVEX)) + + Expect(manager.PublishAttestation(ctx, stages, attestation.PredicateKindOpenVEX, []byte("{}"), "sha256:parent", "app", attestation.PublishAttestationOptions{})).To(Succeed()) + Expect(stages.publishedKind).To(Equal(attestation.PredicateKindOpenVEX)) + Expect(stages.publishedImageName).To(Equal("app")) + }) + + It("skips copying between identical repository addresses", func(ctx SpecContext) { + source := &artifactOperationsStorage{address: "registry.example/repository"} + destination := &artifactOperationsStorage{address: source.address} + manager := &StorageManager{} + + Expect(manager.CopyAttachedArtifacts(ctx, source, "sha256:source", destination, "sha256:destination")).To(Succeed()) + Expect(destination.copiedSourceRepo).To(BeEmpty()) + }) + + It("rejects publication and copying through local storage", func(ctx SpecContext) { + local := storage.NewLocalStagesStorage(nil) + manager := &StorageManager{} + + Expect(manager.PublishArtifact(ctx, local, "sha256:parent", "application/test", []byte("payload"), "image", "checksum", "", "")).To(MatchError(ContainSubstring("local stages storage"))) + Expect(manager.CopyAttachedArtifacts(ctx, local, "sha256:source", &artifactOperationsStorage{address: "registry.example/destination"}, "sha256:destination")).To(MatchError(ContainSubstring("local stages storage"))) + }) + + It("rejects incomplete attestation operations", func(ctx SpecContext) { + stages := &artifactOperationsStorage{address: "registry.example/repository"} + manager := &StorageManager{} + + _, _, err := manager.FindAttachedArtifact(ctx, stages, "", "app", attestation.PredicateKindOpenVEX) + Expect(err).To(MatchError("find attached artifact: parent digest is empty")) + Expect(manager.PublishAttestation(ctx, stages, attestation.PredicateKindOpenVEX, []byte("{}"), "", "app", attestation.PublishAttestationOptions{})).To(MatchError("publish attestation: parent digest is empty")) + }) + + It("wraps backend errors with the routed operation", func(ctx SpecContext) { + backendError := errors.New("backend unavailable") + source := &artifactOperationsStorage{address: "registry.example/source", operationError: backendError} + manager := &StorageManager{} + + _, err := manager.ListAttachedArtifacts(ctx, source, "sha256:parent") + Expect(err).To(MatchError(ContainSubstring("list attached artifacts from registry.example/source"))) + err = manager.PublishArtifact(ctx, source, "sha256:parent", "application/test", nil, "image", "checksum", "", "") + Expect(err).To(MatchError(ContainSubstring("publish artifact to registry.example/source"))) + }) +}) diff --git a/pkg/storage/manager/storage_manager.go b/pkg/storage/manager/storage_manager.go index 81f28be9d9..88389f5559 100644 --- a/pkg/storage/manager/storage_manager.go +++ b/pkg/storage/manager/storage_manager.go @@ -10,17 +10,18 @@ import ( "time" "github.com/cenkalti/backoff/v5" + v1 "github.com/google/go-containerregistry/pkg/v1" "gopkg.in/yaml.v2" "github.com/werf/lockgate" "github.com/werf/logboek" "github.com/werf/logboek/pkg/style" "github.com/werf/logboek/pkg/types" + "github.com/werf/werf/v2/pkg/attestation" "github.com/werf/werf/v2/pkg/build/stage" "github.com/werf/werf/v2/pkg/container_backend" "github.com/werf/werf/v2/pkg/docker_registry" "github.com/werf/werf/v2/pkg/image" - "github.com/werf/werf/v2/pkg/oci/artifact" "github.com/werf/werf/v2/pkg/storage" "github.com/werf/werf/v2/pkg/storage/lrumeta" "github.com/werf/werf/v2/pkg/util/parallel" @@ -79,6 +80,13 @@ type StorageManagerInterface interface { CopyStageIntoCacheStorages(ctx context.Context, stageID image.StageID, cacheStagesStorages []storage.StagesStorage, opts CopyStageIntoStorageOptions) error CopyStageIntoFinalStorage(ctx context.Context, stageID image.StageID, finalStagesStorage storage.StagesStorage, opts CopyStageIntoStorageOptions) (*image.StageDesc, error) + ListAttachedArtifacts(ctx context.Context, stagesStorage storage.StagesStorage, parentDigest string) ([]v1.Descriptor, error) + FindAttachedArtifact(ctx context.Context, stagesStorage storage.StagesStorage, parentDigest, imageName string, kind attestation.PredicateKind) (v1.Descriptor, bool, error) + PublishAttestation(ctx context.Context, stagesStorage storage.StagesStorage, kind attestation.PredicateKind, payload []byte, parentDigest, imageName string, options attestation.PublishAttestationOptions) error + PublishArtifact(ctx context.Context, stagesStorage storage.StagesStorage, parentDigest, artifactType string, payload []byte, imageName, checksum, targetPlatform, predicateType string) error + ResolveStageDescriptor(ctx context.Context, stagesStorage storage.StagesStorage, stageID image.StageID) (*image.StageDesc, error) + CopyAttachedArtifacts(ctx context.Context, sourceStorage storage.StagesStorage, sourceDigest string, destinationStorage storage.StagesStorage, destinationDigest string) error + ForEachDeleteStage(ctx context.Context, options ForEachDeleteStageOptions, stageDescSet image.StageDescSet, f func(ctx context.Context, stageDesc *image.StageDesc, err error) error) error ForEachDeleteFinalStage(ctx context.Context, options ForEachDeleteStageOptions, stageDescSet image.StageDescSet, f func(ctx context.Context, stageDesc *image.StageDesc, err error) error) error ForEachRejectedStage(ctx context.Context, stageIDs []image.StageID, f func(ctx context.Context, stageID image.StageID) error) error @@ -218,6 +226,107 @@ func (m *StorageManager) GetCacheStagesStorageList() []storage.StagesStorage { return m.CacheStagesStorageList } +func (m *StorageManager) ListAttachedArtifacts(ctx context.Context, stagesStorage storage.StagesStorage, parentDigest string) ([]v1.Descriptor, error) { + if stagesStorage == nil { + return nil, fmt.Errorf("list attached artifacts: stages storage is nil") + } + if parentDigest == "" { + return nil, fmt.Errorf("list attached artifacts: parent digest is empty") + } + + artifacts, err := stagesStorage.ListAttachedArtifacts(ctx, parentDigest) + if err != nil { + return nil, fmt.Errorf("list attached artifacts from %s: %w", stagesStorage.String(), err) + } + return artifacts, nil +} + +func (m *StorageManager) FindAttachedArtifact(ctx context.Context, stagesStorage storage.StagesStorage, parentDigest, imageName string, kind attestation.PredicateKind) (v1.Descriptor, bool, error) { + if stagesStorage == nil { + return v1.Descriptor{}, false, fmt.Errorf("find attached artifact: stages storage is nil") + } + if stagesStorage.Address() == storage.LocalStorageAddress { + return v1.Descriptor{}, false, fmt.Errorf("find attached artifact: local stages storage does not support artifact operations") + } + if parentDigest == "" { + return v1.Descriptor{}, false, fmt.Errorf("find attached artifact: parent digest is empty") + } + if imageName == "" { + return v1.Descriptor{}, false, fmt.Errorf("find attached artifact: image name is empty") + } + + descriptor, found, err := stagesStorage.FindAttachedArtifact(ctx, parentDigest, imageName, kind) + if err != nil { + return v1.Descriptor{}, false, fmt.Errorf("find attached %s artifact from %s: %w", kind.Name, stagesStorage.String(), err) + } + return descriptor, found, nil +} + +func (m *StorageManager) PublishAttestation(ctx context.Context, stagesStorage storage.StagesStorage, kind attestation.PredicateKind, payload []byte, parentDigest, imageName string, options attestation.PublishAttestationOptions) error { + if stagesStorage == nil { + return fmt.Errorf("publish attestation: stages storage is nil") + } + if stagesStorage.Address() == storage.LocalStorageAddress { + return fmt.Errorf("publish attestation: local stages storage does not support artifact operations") + } + if parentDigest == "" { + return fmt.Errorf("publish attestation: parent digest is empty") + } + if imageName == "" { + return fmt.Errorf("publish attestation: image name is empty") + } + if err := stagesStorage.PublishAttestation(ctx, kind, payload, parentDigest, imageName, options); err != nil { + return fmt.Errorf("publish attestation to %s: %w", stagesStorage.String(), err) + } + return nil +} + +func (m *StorageManager) PublishArtifact(ctx context.Context, stagesStorage storage.StagesStorage, parentDigest, artifactType string, payload []byte, imageName, checksum, targetPlatform, predicateType string) error { + if stagesStorage == nil { + return fmt.Errorf("publish artifact: stages storage is nil") + } + if stagesStorage.Address() == storage.LocalStorageAddress { + return fmt.Errorf("publish artifact: local stages storage does not support artifact operations") + } + if parentDigest == "" { + return fmt.Errorf("publish artifact: parent digest is empty") + } + + if err := stagesStorage.PublishArtifact(ctx, parentDigest, artifactType, payload, imageName, checksum, targetPlatform, predicateType); err != nil { + return fmt.Errorf("publish artifact to %s: %w", stagesStorage.String(), err) + } + return nil +} + +func (m *StorageManager) ResolveStageDescriptor(ctx context.Context, stagesStorage storage.StagesStorage, stageID image.StageID) (*image.StageDesc, error) { + if stagesStorage == nil { + return nil, fmt.Errorf("resolve stage descriptor: stages storage is nil") + } + + desc, err := stagesStorage.GetStageDesc(ctx, m.ProjectName, stageID) + if err != nil { + return nil, fmt.Errorf("resolve stage %s descriptor from %s: %w", stageID.String(), stagesStorage.String(), err) + } + return desc, nil +} + +func (m *StorageManager) CopyAttachedArtifacts(ctx context.Context, sourceStorage storage.StagesStorage, sourceDigest string, destinationStorage storage.StagesStorage, destinationDigest string) error { + if sourceStorage == nil || destinationStorage == nil { + return fmt.Errorf("copy attached artifacts: source and destination storage are required") + } + if sourceStorage.Address() == storage.LocalStorageAddress || destinationStorage.Address() == storage.LocalStorageAddress { + return fmt.Errorf("copy attached artifacts: local stages storage does not support artifact operations") + } + if sourceStorage.Address() == destinationStorage.Address() { + return nil + } + + if err := destinationStorage.CopyAttachedArtifacts(ctx, sourceStorage.Address(), sourceDigest, destinationStorage.Address(), destinationDigest); err != nil { + return fmt.Errorf("copy attached artifacts from %s to %s: %w", sourceStorage.String(), destinationStorage.String(), err) + } + return nil +} + func (m *StorageManager) GetServiceValuesRepo() string { if m.FinalStagesStorage != nil { return m.FinalStagesStorage.String() @@ -811,7 +920,7 @@ func (m *StorageManager) CopySuitableStageDescByDigest(ctx context.Context, stag return nil, fmt.Errorf("unable to get stage %s description from %s: %w", stageDesc.StageID.String(), destinationStagesStorage.String(), err) } else { if sourceStagesStorage.Address() != storage.LocalStorageAddress && destinationStagesStorage.Address() != storage.LocalStorageAddress { - if err := artifact.CopyAttachedArtifacts(ctx, sourceStagesStorage.Address(), stageDesc.Info.GetDigest(), destinationStagesStorage.Address(), destinationStageDesc.Info.GetDigest()); err != nil { + if err := destinationStagesStorage.CopyAttachedArtifacts(ctx, sourceStagesStorage.Address(), stageDesc.Info.GetDigest(), destinationStagesStorage.Address(), destinationStageDesc.Info.GetDigest()); err != nil { return nil, fmt.Errorf("unable to copy artifacts attached to stage %s: %w", stageDesc.StageID.String(), err) } } diff --git a/pkg/storage/meta_repo_marker_test.go b/pkg/storage/meta_repo_marker_test.go index 439fd05a6d..757fb294f2 100644 --- a/pkg/storage/meta_repo_marker_test.go +++ b/pkg/storage/meta_repo_marker_test.go @@ -34,6 +34,7 @@ var markerUnguardedMethods = []string{ "CheckStageCustomTag", "ConstructStageImageName", "CopyFromStorage", + "CopyAttachedArtifacts", "CreateRepo", "DeleteArtifact", "DeleteRejectedStageImage", @@ -44,6 +45,7 @@ var markerUnguardedMethods = []string{ "ExportStage", "FetchImage", "FilterStageDescSetAndProcessRelatedData", + "FindAttachedArtifact", "GetAllAndGroupImageMetadataByImageName", "GetLastCleanupRecord", "GetManagedImages", @@ -56,9 +58,12 @@ var markerUnguardedMethods = []string{ "GetStagesIDsByDigest", "IsImageMetadataExist", "IsManagedImageExist", + "ListAttachedArtifacts", "MutateAndPushImage", "PostManifest", "PostMultiplatformImage", + "PublishArtifact", + "PublishAttestation", "RejectStage", "ShouldFetchImage", "StoreImage", diff --git a/pkg/storage/repo_stages_storage.go b/pkg/storage/repo_stages_storage.go index 6c2b1a9eca..8789a2b997 100644 --- a/pkg/storage/repo_stages_storage.go +++ b/pkg/storage/repo_stages_storage.go @@ -14,6 +14,7 @@ import ( "github.com/werf/common-go/pkg/util" "github.com/werf/logboek" + "github.com/werf/werf/v2/pkg/attestation" "github.com/werf/werf/v2/pkg/container_backend" "github.com/werf/werf/v2/pkg/docker_registry" "github.com/werf/werf/v2/pkg/docker_registry/api" @@ -849,6 +850,35 @@ func (storage *RepoStagesStorage) Address() string { return storage.RepoAddress } +func (storage *RepoStagesStorage) ListAttachedArtifacts(ctx context.Context, parentDigest string) ([]v1.Descriptor, error) { + index, err := artifact.PullFallbackIndex(ctx, storage.RepoAddress, parentDigest) + if err != nil { + return nil, fmt.Errorf("pull artifact index: %w", err) + } + manifest, err := index.IndexManifest() + if err != nil { + return nil, fmt.Errorf("read artifact index: %w", err) + } + return manifest.Manifests, nil +} + +func (storage *RepoStagesStorage) FindAttachedArtifact(ctx context.Context, parentDigest, imageName string, kind attestation.PredicateKind) (v1.Descriptor, bool, error) { + store := artifact.NewOCIStore(storage.RepoAddress, imageName) + return attestation.FindAttachedArtifact(ctx, store, parentDigest, kind) +} + +func (storage *RepoStagesStorage) PublishAttestation(ctx context.Context, kind attestation.PredicateKind, payload []byte, parentDigest, imageName string, options attestation.PublishAttestationOptions) error { + return attestation.PublishAttestation(ctx, kind, payload, storage.RepoAddress, parentDigest, imageName, options) +} + +func (storage *RepoStagesStorage) PublishArtifact(ctx context.Context, parentDigest, artifactType string, payload []byte, imageName, checksum, targetPlatform, predicateType string) error { + return artifact.NewOCIStore(storage.RepoAddress, imageName).Attach(ctx, parentDigest, artifactType, payload, checksum, targetPlatform, predicateType) +} + +func (storage *RepoStagesStorage) CopyAttachedArtifacts(ctx context.Context, sourceRepository, sourceDigest, destinationRepository, destinationDigest string) error { + return artifact.CopyAttachedArtifacts(ctx, sourceRepository, sourceDigest, destinationRepository, destinationDigest) +} + func (storage *RepoStagesStorage) GetOrphanedArtifactNames(ctx context.Context) ([]string, error) { tags, err := storage.Tags(ctx, storage.RepoAddress) if err != nil { @@ -974,7 +1004,7 @@ func (storage *RepoStagesStorage) CopyFromStorage(ctx context.Context, src Stage return nil, fmt.Errorf("unable to get stage %s description: %w", stageID, err) } - if err := artifact.CopyAttachedArtifacts(ctx, src.Address(), desc.Info.GetDigest(), storage.RepoAddress, desc.Info.GetDigest()); err != nil { + if err := storage.CopyAttachedArtifacts(ctx, src.Address(), desc.Info.GetDigest(), storage.RepoAddress, desc.Info.GetDigest()); err != nil { return nil, fmt.Errorf("unable to copy artifacts attached to stage %s: %w", stageID, err) } diff --git a/pkg/storage/repo_stages_storage_test.go b/pkg/storage/repo_stages_storage_test.go index 99ddd85ab3..c18880a4de 100644 --- a/pkg/storage/repo_stages_storage_test.go +++ b/pkg/storage/repo_stages_storage_test.go @@ -58,6 +58,11 @@ func (r *pushImageRegistryStub) MutateAndPushImage(ctx context.Context, _, desti return nil } +var ( + _ StagesStorage = (*RepoStagesStorage)(nil) + _ StagesStorage = (*LocalStagesStorage)(nil) +) + var _ = Describe("RepoStagesStorage", func() { It("pushes a manifest-only image to the registry in PostManifest", func(ctx SpecContext) { registry := &pushImageRegistryStub{} diff --git a/pkg/storage/stages_storage.go b/pkg/storage/stages_storage.go index 632e1b6750..5336e12c32 100644 --- a/pkg/storage/stages_storage.go +++ b/pkg/storage/stages_storage.go @@ -7,6 +7,7 @@ import ( v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/werf/werf/v2/pkg/attestation" "github.com/werf/werf/v2/pkg/container_backend" "github.com/werf/werf/v2/pkg/image" ) @@ -82,6 +83,12 @@ type StagesStorage interface { GetOrphanedArtifactNames(ctx context.Context) ([]string, error) DeleteArtifact(ctx context.Context, imageName string) error + ListAttachedArtifacts(ctx context.Context, parentDigest string) ([]v1.Descriptor, error) + FindAttachedArtifact(ctx context.Context, parentDigest, imageName string, kind attestation.PredicateKind) (v1.Descriptor, bool, error) + PublishAttestation(ctx context.Context, kind attestation.PredicateKind, payload []byte, parentDigest, imageName string, options attestation.PublishAttestationOptions) error + PublishArtifact(ctx context.Context, parentDigest, artifactType string, payload []byte, imageName, checksum, targetPlatform, predicateType string) error + CopyAttachedArtifacts(ctx context.Context, sourceRepository, sourceDigest, destinationRepository, destinationDigest string) error + PutImageMetadata(ctx context.Context, projectName, imageNameOrManagedImageName, commit, stageID string) error RmImageMetadata(ctx context.Context, projectName, imageNameOrManagedImageNameOrImageMetadataID, commit, stageID string) error IsImageMetadataExist(ctx context.Context, projectName, imageNameOrManagedImageName, commit, stageID string, opts ...Option) (bool, error) diff --git a/specs/020-sbom-vex-build-stages/checklists/requirements.md b/specs/020-sbom-vex-build-stages/checklists/requirements.md new file mode 100644 index 0000000000..2f9c9b1b72 --- /dev/null +++ b/specs/020-sbom-vex-build-stages/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: SBOM and VEX as Build Stages + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-09-01 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Reviewed against the attached SBOM/VEX build-stages plan and the existing SBOM/VEX storage specifications. +- The specification intentionally retains storage-contract terms such as fallback tags, image descriptors, and OCI artifacts because they are externally observable compatibility requirements for this feature. +- Clarifications were recorded for VEX descriptor placement, secondary-repository propagation, and the required early error when SBOM/VEX is enabled without a registry destination. diff --git a/specs/020-sbom-vex-build-stages/data-model.md b/specs/020-sbom-vex-build-stages/data-model.md new file mode 100644 index 0000000000..b8eedc80c6 --- /dev/null +++ b/specs/020-sbom-vex-build-stages/data-model.md @@ -0,0 +1,63 @@ +# Data Model: SBOM and VEX Build Stages + +## Artifact stage + +An internal build-stage operation associated with the final image digest (and, for SBOM, a target platform). `SbomStage` and `VexStage` are the sole owners of their respective generation, cache, signing, and publication behavior; no `sbomStep` or `vexStep` compatibility layer remains. These stages are OCI-artifact stages, not image-content stages. + +| Field | Description | +|---|---| +| Stage name | Stable stage identifier for cache/logging and stage selection. | +| Final image descriptor | The final image manifest or image index whose digest is the artifact subject. | +| Target platform | Required for platform-specific SBOM; empty for image-level multi-platform VEX. | +| Artifact kind | CycloneDX SBOM or OpenVEX. | +| Generation inputs | Scanner/merge inputs for SBOM, document content for VEX, format version, and signer identity. | +| Mutable/buildable flags | Non-buildable and mutable, matching registry-only stages such as signing, but unlike signing the output is an associated OCI artifact rather than a manifest mutation. | +| Storage abstraction | `StorageManager` routes all registry operations initiated by `MutateArtifact` to primary, secondary, cache, or final `storage.StagesStorage`. | + +Validation rules: + +- The final image descriptor/digest must be available before `MutateArtifact` runs. +- The stage must use `GetDependencies`/`GetContentDependencies` for checksum calculation according to the `SignStage` convention. +- The stage must use `MutateArtifact` for registry access through `StorageManager`; repository selection is performed by the manager. +- `MutateImage` is not used by these artifact stages. +- SBOM for a multi-platform image must use the corresponding platform manifest. +- VEX must use the platform manifest for single-platform images and the top-level index for multi-platform images. +- An enabled artifact stage requires registry-backed storage. + +## Artifact identity + +The cache identity stored with the existing fallback artifact index and calculated directly by the owning artifact stage. It identifies the associated final image digest, not a synthetic artifact image. + +| Field | Description | +|---|---| +| Kind and format | Artifact predicate/media type and format version. | +| Parent digest | Digest of the descriptor described by the artifact. | +| Effective inputs | Scanner, merge, GOST, VEX document content, and platform inputs as applicable. | +| Signer identity | Signing fingerprint, or empty for unsigned artifacts. | +| Artifact checksum | Stable checksum used to detect reusable attached artifacts. | + +An identity is reusable only when all effective inputs and the parent descriptor identity match. Repeated publication with the same identity is idempotent. + +## Artifact destination + +A repository and the image descriptor published there. + +| Field | Description | +|---|---| +| Repository address | Primary, final, cache, or secondary repository. | +| Image digest | Destination digest, resolved after image copy. | +| Artifact set | All attached SBOM/VEX artifacts applicable to that descriptor. | +| Failure policy | Fatal for final publication; best effort for cache mirrors. | + +Propagation skips equal source/destination addresses and does not duplicate an already-present artifact identity. + +## Relationships and transitions + +```text +content stage -> artifact stage -> image publication +secondary stage + artifacts -> primary stage + artifacts +primary image + artifacts -> final image + artifacts +primary image + artifacts -> cache image + artifacts +``` + +The artifact stage does not become an image layer and does not operate on an image filesystem. It publishes separate OCI artifacts whose subjects are the final image descriptors. All registry interaction is performed through `StorageManager` and its primary/secondary/cache/final `storage.StagesStorage` backends; existing fallback-tag indexes remain the source of truth and remain readable by current consumers. diff --git a/specs/020-sbom-vex-build-stages/inventory.md b/specs/020-sbom-vex-build-stages/inventory.md new file mode 100644 index 0000000000..8bd5e1cde5 --- /dev/null +++ b/specs/020-sbom-vex-build-stages/inventory.md @@ -0,0 +1,16 @@ +# Existing integration inventory + +- `pkg/build/build_phase.go`: `BeforeImages` initializes storage; `AfterImages` publishes primary/final images and then runs SBOM and VEX convergence. `convergePlatformImageSbom` currently owns SBOM propagation. `convergeImageVex` publishes VEX but has no propagation path. `findAndFetchStageFromSecondaryStagesStorage` copies restored stages into primary and then cache storage. +- `pkg/build/sbom_step.go`: `ConvergeWithMerge` computes the stable checksum, checks the fallback artifact index, generates and pushes CycloneDX, and `PropagateArtifacts` copies attached artifacts to final/cache destinations. +- `pkg/build/vex_step.go`: `Converge` computes VEX checksum, checks the fallback artifact index, and pushes OpenVEX. It requires a non-nil stage descriptor. +- `pkg/build/stage/base.go` and `sign.go`: stages expose buildable/mutable flags, dependencies, image preparation, and registry-side mutation. Signing is the existing non-buildable mutable registry-stage pattern. +- `pkg/oci/artifact/copy.go`: `CopyAttachedArtifacts` already copies all typed fallback-index artifacts by payload, supports differing source/destination digests, skips equivalent identities, and treats a missing source index as a no-op. +- `pkg/storage/manager`: final and cache stage-copy operations are separate; cache errors are logged as warnings and final errors are returned. Secondary restoration uses `CopySuitableStageDescByDigest` and then copies the restored stage into caches. +- `pkg/cleaning/cleanup.go`: orphan artifact indexes are cleaned independently for primary, final, and configured cache repositories. +- Existing tests: Ginkgo/Gomega tests cover SBOM/VEX convergence guards, fallback-index convergence, artifact copy idempotency, differing destination digests, concurrent attachment, and orphan cleanup. Existing e2e suites are under `test/e2e/sbom` and `test/e2e/vex`. + +## Foundation decisions applied + +- Reuse `artifact.CopyAttachedArtifacts` as the shared kind-neutral propagation primitive rather than introducing another artifact store. +- Keep final propagation errors fatal and cache propagation best effort. +- Perform local-only validation before storage initialization whenever SBOM or an image-level VEX document is enabled. diff --git a/specs/020-sbom-vex-build-stages/plan.md b/specs/020-sbom-vex-build-stages/plan.md new file mode 100644 index 0000000000..4a13628146 --- /dev/null +++ b/specs/020-sbom-vex-build-stages/plan.md @@ -0,0 +1,199 @@ +# Implementation Plan: SBOM and VEX Build Stages + +**Branch**: `020-sbom-vex-build-stages` | **Date**: 2026-09-01 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/020-sbom-vex-build-stages/spec.md` + +## Summary + +Move SBOM and VEX generation out of the `BuildPhase.AfterImages` post-build pass and replace the `sbomStep` and `vexStep` implementations with registry-backed, non-buildable mutable stages modeled after `pkg/build/stage/sign.go`. The new `SbomStage` and `VexStage` become the sole owners of SBOM/VEX cache identity, generation, signing, attestation publication, and fallback-index interaction. Unlike ordinary image stages, they are associated with the final image digest, operate on the associated OCI artifact rather than on the image filesystem or image layers, and perform all registry operations through a dedicated artifact-stage API, `MutateArtifact`, which uses `StorageManager` to route them to the appropriate primary, secondary, cache, or final `StagesStorage`. + +Artifact publication will use explicit source and destination image descriptors. SBOM remains platform-specific; VEX is attached once at the top-level image index for multi-platform images and to the image manifest for single-platform images. A shared idempotent propagation operation will cover primary-to-final, primary-to-cache, and secondary-to-primary copies, resolving the destination digest and preserving fatal final-repository versus best-effort cache error policies. + +## Technical Context + +**Language/Version**: Go 1.24.10 + +**Primary Dependencies**: +- Existing build stage interfaces and lifecycle in `pkg/build/stage`, `pkg/build`, and `pkg/build/conveyor.go`. +- Existing SBOM domain primitives in `pkg/sbom/...`, to be moved into `SbomStage`. +- Existing VEX domain primitives in `pkg/vex/...`, to be moved into `VexStage`. +- Existing `pkg/build/sbom_step.go` and `pkg/build/vex_step.go` are transitional sources only and must be removed after their logic is migrated. +- Existing OCI artifact and fallback-index operations in `pkg/oci/artifact` and `pkg/attestation`. +- Existing registry/storage copy operations in `pkg/storage`, `pkg/storage/manager`, and `pkg/docker_registry`. +- `StorageManager` is the required registry boundary for `SbomStage`, `VexStage`, and propagation. It owns the primary, secondary, cache, and final `StagesStorage` instances and must route each operation to the correct repository abstraction. `StagesStorage` may be extended with minimal OCI-artifact primitives, but stages must not select repositories or call concrete registry clients directly. +- Existing signing options in `pkg/build/signing`. +- Ginkgo + Gomega test framework and existing e2e fixtures. + +**Storage**: OCI registry for image manifests/indexes and fallback-tag artifact indexes, accessed through `StorageManager`, which routes operations to primary, secondary, cache, and final `storage.StagesStorage` instances; local Buildah/container storage remains supported when artifacts are disabled. + +**Testing**: Co-located Ginkgo/Gomega unit tests, existing `test/e2e/sbom` and `test/e2e/vex` suites, and legacy integration tests. + +**Target Platform**: Linux amd64/arm64; single- and multi-platform image builds. + +**Project Type**: Go CLI with a staged image build conveyor. + +**Performance Goals**: Remove duplicate post-build SBOM/VEX passes, preserve stage cache hits, avoid duplicate artifact copies, and retain existing parallel image processing. + +**Constraints**: +- Preserve fallback-tag artifact storage and existing artifact readers. +- Do not represent artifacts as image layers or migrate to OCI Referrers. +- Do not add dependencies or change repository flag semantics. +- Registry destination validation must happen before image building when SBOM/VEX is enabled. +- Final-repository artifact failures are fatal; cache-repository failures remain best effort. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.* + +- **Simplicity over abstraction**: PASS. Use two explicit stages, `SbomStage` and `VexStage`, instead of retaining parallel step and stage abstractions. Add one focused shared propagation path rather than duplicating repository-copy logic. +- **Stage distinction**: PASS. The plan explicitly requires that SBOM/VEX stages are final-image-digest-associated OCI-artifact stages, not ordinary image-mutating stages, and that registry access goes through `StorageManager` and its repository-specific `storage.StagesStorage` abstractions. +- **Go idioms and errors**: PASS. New public methods, if required, take `context.Context` first; errors wrap operation context; stage-specific helpers remain private where possible. +- **Minimal public surface**: PASS. Artifact stages and propagation contracts are internal to `pkg/build`; no new CLI flags or external API are planned. +- **Testing**: PASS. Tests remain alongside source and use Ginkgo/Gomega. E2E coverage extends existing SBOM/VEX suites rather than introducing a parallel harness. +- **Dependencies**: PASS. No external dependency changes. +- **Build boundaries**: PASS. Business logic remains under `pkg/build`, `pkg/oci`, `pkg/storage`, and related packages; no `pkg` dependency on `cmd`. +- **Verification commands**: PASS. Implementation must use `task format`, `task build`, lint prerequisites/lint, unit, scoped e2e, and integration commands. No raw Go tooling. +- **Generated/workflow files**: PASS. No `CHANGELOG.md`, release notes, or CLI reference changes are planned. + +No constitution violations require justification. + +## Research Summary + +Detailed findings are in [research.md](./research.md). Key decisions: + +1. Replace `sbomStep` and `vexStep` completely with mutable, non-buildable `SbomStage` and `VexStage` implementations attached to the image lifecycle. +2. Use explicit manifest/index subjects and resolve destination subjects after image copies. +3. Share idempotent propagation for SBOM and VEX across final, cache, and secondary-to-primary paths. +4. Preserve current checksum inputs and fallback artifact indexes during the migration, but implement them in the corresponding stages. +5. Validate registry-backed storage before stage execution when either feature is enabled. + +## Design + +### Stage integration + +- Extend `pkg/build/stage` with stage names and constructors for `SbomStage` and `VexStage`, following the shape of `SignStage`. +- Move all behavior currently owned by `sbomStep` into `SbomStage`; remove `sbom_step.go` and its step-specific tests once callers are migrated. +- Move all behavior currently owned by `vexStep` into `VexStage`; remove `vex_step.go` and its step-specific tests once callers are migrated. +- Artifact stages must not mutate, rebuild, fetch, or store the image filesystem. Their `PrepareImage` path is a no-op. +- Add a dedicated stage lifecycle method `MutateArtifact` for stages that work with registry-backed OCI artifacts without mutating the image. `SbomStage` and `VexStage` must implement and use `MutateArtifact` for generation, cache checks, signing, and publication through `StorageManager`. +- `MutateImage` remains the method for stages that mutate/publish an image, such as `SignStage`; it must not be used as the OCI-artifact operation for `SbomStage` or `VexStage`. +- The stage's subject is the final image digest: for single-platform images this is the published image manifest digest; for multi-platform images SBOM uses each final platform manifest digest and VEX uses the final top-level image index digest. The artifact stage must never be treated as an image layer or as a replacement image. +- All registry reads, writes, copies, metadata operations, and artifact-related repository interaction from `SbomStage` and `VexStage` must be performed from `MutateArtifact` through `StorageManager`. The manager selects primary, secondary, cache, or final `StagesStorage` according to the operation; direct registry client access and direct repository selection from the stages are prohibited. +- Extend `StorageManager` with the minimal artifact-oriented operations required by the stages and propagation. Implement the corresponding `StagesStorage` primitives only where needed to preserve fallback-index behavior: find/list attached artifacts, publish an OCI artifact for a final image digest, and copy attached artifacts between destination image descriptors. Implement these methods for every supported registry-backed storage implementation and keep local storage behavior explicit. +- Preserve the existing stage checksum convention from `SignStage`: implement `GetDependencies` by assembling all effective inputs and returning `util.Sha256Hash(args...)`; implement `GetContentDependencies` consistently for the stage lifecycle. SBOM dependencies include final image identity, scanner, merge/GOST, signer, format version, and target platform. VEX dependencies include final image identity, document content, signer, and format version. The checksum must be calculated from stage inputs, while the artifact subject remains the final image digest. +- Register the stages after the content-producing stage and before the lifecycle completes for applicable images. The registration must work for Stapel and Dockerfile image paths and for restored stages. +- Preserve stage cache behavior: a suitable artifact-bearing stage can be selected from primary/secondary storage; changed effective inputs produce a different stage identity. + +### Artifact subjects and platform behavior + +- Single-platform SBOM and VEX target the actual published image manifest digest. +- Multi-platform SBOM processing runs once per platform image and targets that platform manifest digest. +- Multi-platform VEX processing runs once for the image set and targets the top-level image index digest. +- Do not use the index digest as a platform SBOM subject or duplicate image-level VEX onto platform manifests. +- Keep existing signing behavior and include signer identity in cache identity. The signing, checksum, and cache logic must live in the corresponding artifact stage, not in a retained step wrapper. Registry publication must use the dedicated `MutateArtifact` convention, intentionally separate from `MutateImage`, because SBOM/VEX do not mutate the image itself. + +### Publication and propagation + +- Consolidate artifact copying behind a kind-neutral `StorageManager` operation; it routes through the source/destination repository `StagesStorage` instances and copies every attached supported artifact from a source descriptor to a destination descriptor. This propagation helper is the only shared artifact operation; generation remains owned independently by `SbomStage` and `VexStage`. +- The propagation contract must carry the final image digest/descriptor explicitly and must never attach an artifact to the digest of the artifact stage itself. +- Use it after primary-to-final and primary-to-cache image copies, and when a suitable stage is copied from `--secondary-repo` into primary storage. +- Resolve the destination image descriptor/digest rather than assuming source and destination digests match. +- Skip local storage and identical repository addresses. Deduplicate by existing artifact identity/fallback index semantics. +- Return final-repository propagation errors to fail the build. Log cache-repository propagation failures and continue according to current best-effort behavior. +- Preserve concurrent fallback-index convergence guarantees and existing cleanup behavior. + +### `AfterImages` simplification + +- Remove SBOM/VEX generation calls from the post-build `AfterImages` path once stage execution provides equivalent coverage. +- Retain image metadata publication, final image copying, custom tag publication, telemetry, and report creation in `AfterImages`. +- Avoid retaining a second fallback/post-build convergence path that could regenerate or duplicate artifacts. + +### Early validation + +- Add validation in the earliest build phase before image work starts: if SBOM or VEX is enabled and stage storage is local-only, return an actionable registry-destination error. +- Keep builds with both features disabled unchanged. + +## Project Structure + +### Documentation + +```text +specs/020-sbom-vex-build-stages/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +└── contracts/ # no external API contract required +``` + +### Expected implementation areas + +```text +pkg/build/stage/ +├── base.go # stage names and shared lifecycle metadata +├── sbom.go # SbomStage: generation, cache, signing, and publication +├── vex.go # VexStage: generation, cache, signing, and publication +└── sign.go # existing registry-side stage pattern + +pkg/build/ +├── build_phase.go # stage registration, subject selection, propagation orchestration +└── ... + +pkg/build/sbom_step.go and pkg/build/vex_step.go are removed after migration; their behavior is not retained behind compatibility wrappers. + +pkg/storage/ # StagesStorage interface and backend implementations for artifact stages +pkg/storage/manager/ # StorageManager routing across primary/secondary/cache/final and artifact propagation +pkg/oci/artifact/ # OCI artifact encoding and fallback-index support called by storage backends + +Tests remain co-located under pkg/build and pkg/build/stage, with scenario coverage in: +test/e2e/sbom/ +test/e2e/vex/ +``` + +The exact internal helper split may vary, but the architectural boundary is fixed: no `sbomStep` or `vexStep` types/files remain after implementation, and `SbomStage`/`VexStage` are the sole lifecycle owners. No new package is required. + +## Implementation Phases + +### Phase 0: Research + +Completed in [research.md](./research.md). No unresolved clarification remains. Existing checksum, platform, fallback-index, secondary-repository, and error-policy behavior was identified for reuse. + +### Phase 1: Design + +Completed in [data-model.md](./data-model.md) and [quickstart.md](./quickstart.md). No external interface contract is required because this is an internal build-pipeline change with unchanged CLI syntax and repository option semantics. + +### Phase 2: Implementation preparation + +The subsequent `/speckit-tasks` workflow should decompose at least these work items: + +1. Add the dedicated `MutateArtifact` stage API and implement `SbomStage` and `VexStage`, including `GetDependencies`/`GetContentDependencies` checksum conventions, lifecycle integration, final image-digest association, OCI-artifact handling, cache checks, signing, publication through `MutateArtifact`, and `StorageManager` routing. +2. Migrate all SBOM behavior from `sbomStep` into `SbomStage`, then delete the step implementation and update callers/tests. +3. Migrate all VEX behavior from `vexStep` into `VexStage`, then delete the step implementation and update callers/tests. +4. Extend `StorageManager` with the minimal OCI-artifact operations, add any required `StagesStorage` backend primitives, and implement manager-routed artifact propagation for final, cache, and secondary-to-primary copies. +5. Move registry validation before image building and remove duplicate `AfterImages` convergence. +6. Add/adjust unit tests for stage flags, dependency identities, subjects, propagation, idempotency, and failure policies. +7. Extend e2e coverage for repository combinations, secondary restore, multi-platform placement, caching, and local-only rejection. +8. Verify cleanup/orphan behavior and unchanged builds without SBOM/VEX. + +## Validation Plan + +Use the repository-required sequence after implementation: + +```text +task format +task build +task deps:install:golangci-lint +task lint +task test:unit +task test:e2e paths="./test/e2e/sbom/..." labelFilter="sbom" +task test:e2e paths="./test/e2e/vex/..." labelFilter="vex" +task test:integration +``` + +While iterating, use scoped `task lint:golangci-lint` and `task test:unit` paths for changed packages. Validate both positive and negative cases: successful registry-backed publication, local-only early failure, final failure, cache best effort, secondary restoration, repeated idempotent propagation, and correct platform subjects. + +## Complexity Tracking + +No constitution violations or new architectural projects are proposed. The only additional internal abstraction is a shared `StorageManager`-routed artifact propagation operation because SBOM-only propagation cannot satisfy VEX and secondary-to-primary requirements without duplication. The explicit distinction between ordinary image stages and OCI-artifact stages is required by the feature and is not an optional abstraction. diff --git a/specs/020-sbom-vex-build-stages/quickstart.md b/specs/020-sbom-vex-build-stages/quickstart.md new file mode 100644 index 0000000000..ef3277343b --- /dev/null +++ b/specs/020-sbom-vex-build-stages/quickstart.md @@ -0,0 +1,56 @@ +# Quickstart Validation: SBOM and VEX Build Stages + +## Prerequisites + +- Linux test environment with Docker, kind, and a writable OCI registry already configured. +- A fixture containing at least one final image and a VEX document. +- `--repo` configured for every build that enables SBOM or VEX. + +## Unit validation + +Run the focused build/stage and artifact tests first: + +```text +task test:unit paths="./pkg/build/..." +task test:unit paths="./pkg/oci/artifact/..." +task test:unit paths="./pkg/vex/..." +``` + +Expected results: + +- `SbomStage` and `VexStage` calculate stable dependencies and remain non-buildable/mutable; the old `sbomStep` and `vexStep` implementations no longer exist. +- Both stages preserve the `SignStage` checksum convention through `GetDependencies`/`GetContentDependencies` and `util.Sha256Hash(args...)`. +- Both stages are associated with the final image digest, operate only on separate OCI artifacts, and perform registry interaction from dedicated `MutateArtifact` through `StorageManager`; they do not use `MutateImage`. The manager routes requests to primary, secondary, cache, or final `storage.StagesStorage` as appropriate. +- Single-platform SBOM/VEX subjects resolve to the final image manifest digest. +- Multi-platform SBOM subjects resolve to each final platform manifest digest and VEX resolves to the final top-level index digest. +- Propagation skips identical repositories, deduplicates existing identities, and distinguishes final errors from cache warnings. +- Local-only artifact-enabled builds fail before any image stage is executed. + +## End-to-end validation + +Run the SBOM suite with the feature label/path: + +```text +task test:e2e paths="./test/e2e/sbom/..." labelFilter="sbom" +``` + +Cover these repository combinations: + +1. Primary only with SBOM and VEX. +2. Primary plus final repository. +3. Primary plus one or more cache repositories. +4. Primary plus final and cache repositories. +5. Secondary repository restore into primary storage. +6. Identical primary/final/cache addresses. +7. Two-platform image. +8. Unavailable final repository, unavailable cache repository, and local-only artifact-enabled build. + +For each successful case, retrieve artifact descriptors by the actual final image digest from every repository containing the image. Verify that repeated builds do not add duplicate fallback-index entries and that stage code did not create or modify an image layer. Registry access used by the stages must be observable through `MutateArtifact`, `StorageManager` routing, and the selected `StagesStorage` test double/backend rather than a direct registry client. + +Then run the repository integration suite: + +```text +task test:integration +``` + +Expected outcome: existing builds without SBOM/VEX retain their behavior, and existing cleanup removes orphan fallback artifact indexes in all propagated repositories. diff --git a/specs/020-sbom-vex-build-stages/research.md b/specs/020-sbom-vex-build-stages/research.md new file mode 100644 index 0000000000..c9419d7899 --- /dev/null +++ b/specs/020-sbom-vex-build-stages/research.md @@ -0,0 +1,84 @@ +# Research: SBOM and VEX Build Stages + +## Decision: Move artifact convergence into the image/stage lifecycle + +SBOM and VEX will be represented by non-buildable, mutable build stages that run after the image content stage has produced a registry-backed descriptor. The existing `Stage` lifecycle remains the integration point: `GetDependencies`/`GetContentDependencies` determine cache identity using the same `util.Sha256Hash(args...)` convention as `SignStage`, a dedicated `MutateArtifact` method performs OCI-side publication, and the build phase invokes the stage for each applicable image/platform. + +The new stages will reuse low-level primitives from `pkg/sbom/...`, `pkg/vex/...`, `pkg/oci/artifact`, the signer implementations, and fallback-tag storage. The existing `sbomStep` and `vexStep` types are transitional implementations: their behavior will be moved into `SbomStage` and `VexStage`, and the step types/files will be deleted. The stages are intentionally different from ordinary image stages: each stage is associated with the final image digest, operates on a separate OCI artifact, and never changes image layers or filesystem content. They require a dedicated registry-working stage hook: `MutateArtifact` performs artifact registry work without invoking `MutateImage`. `MutateImage` remains reserved for image-manifest/image-content mutations. All registry interaction from `MutateArtifact` must go through `StorageManager`, which owns and routes to the primary, secondary, cache, and final `storage.StagesStorage` abstractions, just as ordinary registry-working build stages use the storage manager path. `BuildPhase.AfterImages` will retain image publication/report work but will no longer perform SBOM/VEX generation. + +### Rationale + +- It removes the current repository-dependent post-build pass from `AfterImages`. +- It makes artifact generation part of the same cacheable lifecycle as the descriptor it describes. +- It preserves existing stage cache and secondary-repository restoration behavior without maintaining duplicate step abstractions. +- It avoids introducing a second artifact storage model or an OCI Referrers migration. + +### Alternatives considered + +- Keep SBOM/VEX in `AfterImages` and improve propagation: rejected because it preserves the extra post-build pass and allows image publication to become detached from artifact processing. +- Encode SBOM/VEX as image layers: rejected by the specification and would change image semantics. +- Migrate to OCI Referrers: rejected as explicitly out of scope and would break the fallback-tag compatibility baseline. + +## Decision: Treat SBOM/VEX stages as final-image-digest-associated OCI-artifact stages + +`SbomStage` and `VexStage` are not image-producing stages in the ordinary sense. They do not create or mutate a container image, add layers, or store a filesystem snapshot. Their output is a separate OCI artifact associated with the final image digest. The stages must use `StorageManager` for registry reads/writes, copying, metadata, and repository operations. `StorageManager` selects the appropriate primary, secondary, cache, or final `storage.StagesStorage`; those backends should expose only the minimal artifact-oriented operations required to find/list attached artifacts, publish an OCI artifact for a final image digest, and copy attached artifacts between destination image descriptors. Direct registry access or repository selection from stage code is not permitted. + +### Rationale + +This preserves the distinction between an image lifecycle and its supply-chain metadata while still making metadata generation deterministic and cacheable as part of the lifecycle. Keeping the standard dependency conventions while adding a distinct `MutateArtifact` hook makes the new stages compatible with the existing scheduler and stage cache without pretending that an OCI artifact is an image mutation. Reusing `StorageManager` and its `StagesStorage` backends keeps registry behavior consistent with existing build stages, centralizes repository routing, and avoids coupling stages to a concrete registry implementation. + +### Alternatives considered + +- Treat the artifact as a synthetic image stage or image layer: rejected because it changes image semantics and can affect image digest/content. +- Let stages call concrete registry clients directly: rejected because it bypasses the established `StagesStorage` abstraction and makes storage backends inconsistent. + +## Decision: Use explicit artifact subjects for single- and multi-platform images + +For a single-platform image, both SBOM and VEX use the published platform manifest descriptor. For a multi-platform image, each SBOM stage uses its platform manifest descriptor, while one VEX stage uses the top-level image index descriptor. Destination propagation resolves the destination descriptor before attaching artifacts. + +### Rationale + +The repository can contain a destination image with a digest different from the source. Resolving the destination subject prevents an artifact from describing the wrong manifest. It also preserves the established OpenVEX image-level behavior. + +### Alternatives considered + +- Attach every artifact to the content-stage digest without resolving final/index descriptors: rejected for final repositories and multi-platform images. +- Attach VEX to every platform manifest: rejected because VEX is image-level. + +## Decision: Share one propagation contract for all artifact kinds + +Introduce one internal propagation operation that accepts source and destination image descriptors and copies all attached SBOM/VEX artifacts idempotently. It is used for primary-to-final, primary-to-cache, and secondary-to-primary copies. Identical repository addresses are skipped. Final-repository errors are fatal; cache errors retain the existing warning/best-effort policy. + +### Rationale + +The existing `sbomStep.PropagateArtifacts` only names SBOM and is called after image publication. A kind-neutral `StorageManager`-routed operation prevents VEX from acquiring different propagation semantics and makes secondary restoration follow the same rules. + +### Alternatives considered + +- Add a second VEX-specific propagation function: rejected because it duplicates destination resolution, deduplication, and error policy. +- Copy artifacts blindly by source digest: rejected because destination image digests may differ. + +## Decision: Keep existing checksum inputs and extend stage dependency identity only where needed + +SBOM keeps its current stable checksum inputs: artifact format, scanner/merge/GOST inputs, signer identity, and target platform, with the image stage digest as the parent identity. VEX keeps document content, parent digest, format version, and signer identity. Stage dependency calculation must include the same effective inputs so a changed input cannot reuse an old artifact stage. + +### Rationale + +Existing checksum logic and tests already encode the required cache behavior. Moving that logic directly into the corresponding stages minimizes behavioral risk while making stage selection aware of artifact configuration and avoids a permanent compatibility wrapper. + +### Alternatives considered + +- Use only the generated artifact bytes as a cache key: rejected because the stage must decide reuse before expensive generation. +- Add a new cache database: rejected as unnecessary and inconsistent with fallback artifact annotations. + +## Decision: Validate registry availability before image building + +When SBOM or VEX is enabled, build initialization validates that the configured stage storage is registry-backed. A local-only build fails before image stages execute with an actionable message requiring `--repo` or disabling artifact generation. + +### Rationale + +Artifacts are OCI registry artifacts and cannot be published to local-only storage. Early validation avoids doing expensive image work that must eventually fail. + +### External dependency assessment + +No new external dependencies are required. Existing registry, attestation, signing, SBOM, VEX, and storage packages are sufficient. diff --git a/specs/020-sbom-vex-build-stages/spec.md b/specs/020-sbom-vex-build-stages/spec.md new file mode 100644 index 0000000000..56e166e471 --- /dev/null +++ b/specs/020-sbom-vex-build-stages/spec.md @@ -0,0 +1,194 @@ +# Feature Specification: SBOM and VEX as Build Stages + +**Feature Branch**: `020-sbom-vex-build-stages` + +**Created**: 2026-09-01 + +**Status**: Draft + +**Input**: User description: "Integrate SBOM and VEX generation into the build-stage lifecycle while preserving OCI artifact storage and ensuring consistent propagation to primary, final, and cache repositories." + +## Project Context + +**Delivery Kit** is a Go CLI tool for full-cycle CI/CD to Kubernetes, built on top of werf with Deckhouse Platform extensions. The feature concerns the build, OCI artifact, registry, SBOM, VEX, and cleanup subsystems: + +- **Build** (`pkg/build/`) — image build lifecycle and stage orchestration +- **SBOM** (`pkg/sbom/`) — SBOM generation, merging, caching, and publication +- **VEX** (`pkg/vex/`) — VEX validation, caching, and publication +- **OCI artifacts** (`pkg/oci/artifact/`) — fallback-tag artifact storage and propagation +- **Registry/storage** (`pkg/storage/`, `pkg/docker_registry/`) — primary, final, and cache repositories +- **Cleanup** (`pkg/cleaning/`) — lifecycle of image and artifact storage + +## Problem Statement + +SBOM and VEX are currently generated in separate post-build passes. Image manifests may be copied to `final-repo` or `cache-repo` before their OCI artifacts are generated, so artifact availability depends on repository options and on whether the image was built locally or restored from cache. SBOM has partial propagation support, while VEX does not consistently follow copied images. + +This creates an unreliable supply-chain record: an image can be available in a destination repository while the SBOM or VEX needed to inspect it is missing there. Multi-platform images add another correctness risk because an artifact can be attached to the wrong descriptor if platform manifests are not resolved explicitly. Stages restored from a `--secondary-repo` can also lose their attached artifacts when copied into primary storage unless artifact propagation is part of the same lifecycle. + +The build lifecycle needs one deterministic artifact flow that preserves the existing fallback-tag storage model without representing artifacts as image layers or changing the user-facing meaning of repository options. Because SBOM and VEX are OCI registry artifacts, enabling either one requires a configured registry destination; local-only builds must fail before image building starts. + +## Clarifications + +### Session 2026-09-01 + +- Q: For multi-platform images, should VEX always be attached to the top-level image index digest, and for single-platform images to the image manifest digest? → A: Option A — multi-platform VEX is attached to the image index digest; single-platform VEX is attached to the image manifest digest. +- Q: Should `--secondary-repo` be included in artifact propagation behavior? → A: Yes — artifacts attached to a suitable stage restored from a secondary repository are copied when that stage is stored in the primary repository. +- Q: What should happen when SBOM or VEX is enabled without any registry destination? → A: Fail early before image building starts with an actionable error requiring a registry destination. + +## User Scenarios & Testing *(mandatory) + +### User Story 1 — Artifacts follow published images (Priority: P1) + +A delivery engineer builds an image with SBOM and/or VEX enabled. The resulting OCI artifacts are available wherever the corresponding image is published: the primary repository, the configured final repository, and the configured cache repositories according to their existing failure policies. When a suitable stage is restored from a `--secondary-repo`, its artifacts follow the stage into primary storage. + +**Why this priority**: Consumers often access the final repository rather than the build repository. Missing attestations make the published image incomplete and undermine vulnerability and compliance workflows. + +**Independent Test**: Build the same fixture with supported combinations of `--repo`, `--final-repo`, `--cache-repo`, and `--secondary-repo`, then retrieve SBOM and VEX by the image digest from every applicable destination. + +**Acceptance Scenarios**: + +1. **Given** an image with SBOM enabled and a VEX document, **when** it is built with a primary registry repository and without final or cache repositories, **then** both artifacts are available in the primary repository and the build behavior remains successful. +2. **Given** an image with SBOM and VEX enabled and `--final-repo`, **when** the build completes, **then** both artifacts are attached to the image manifest published in the final repository. +3. **Given** an image with SBOM and VEX enabled and one or more `--cache-repo` values, **when** the build completes, **then** artifacts are propagated to each cache repository where the corresponding image is stored, subject to existing cache failure policy. +4. **Given** both `--final-repo` and `--cache-repo`, **when** the build completes, **then** the artifacts are available in both destination classes. +5. **Given** primary and final repository addresses are identical, **when** the build completes, **then** the operation succeeds without creating duplicate artifact copies. +6. **Given** a suitable image stage is restored from `--secondary-repo`, **when** it is copied into primary storage, **then** all attached SBOM and VEX artifacts are copied to the corresponding primary image digest. +7. **Given** SBOM or VEX is enabled without any registry destination, **when** the build command starts, **then** it fails before image building with an actionable error requiring a registry destination. + +--- + +### User Story 2 — Platform-specific artifacts describe the correct image (Priority: P1) + +A delivery engineer builds a multi-platform image. Each platform-specific SBOM describes and is attached to the corresponding platform manifest. One VEX artifact describes the image at image level and is attached to the image index digest. For a single-platform image, VEX is attached to the image manifest digest. Consumers never receive an artifact silently attached to a different platform. + +**Why this priority**: A platform-mismatched SBOM is a false supply-chain statement and can lead to incorrect vulnerability or compliance decisions. + +**Independent Test**: Build a two-platform fixture, inspect the artifact subjects and platform metadata for both platform manifests, and verify the established VEX placement separately. + +**Acceptance Scenarios**: + +1. **Given** a multi-platform image with SBOM enabled, **when** the build completes, **then** each required platform manifest has the SBOM for that platform and its subject identifies that platform manifest. +2. **Given** platform-specific SBOM artifacts, **when** their metadata is inspected, **then** each artifact identifies the platform that was scanned. +3. **Given** a multi-platform image, **when** a platform-specific SBOM is queried, **then** the index digest is not used in place of the requested platform manifest digest. +4. **Given** a multi-platform image with VEX enabled, **when** the build completes, **then** exactly one VEX artifact is attached to the image index digest and no VEX artifact is attached to an individual platform manifest digest. + +--- + +### User Story 3 — Rebuilds reuse or invalidate artifact results correctly (Priority: P1) + +A delivery engineer repeats a build or changes its artifact-generation inputs. Unchanged inputs reuse the existing artifact; changed image content, scanner or merge inputs, VEX content, target platform, or signing identity produces a new artifact identity when that input affects the result. + +**Why this priority**: Incorrect cache reuse silently publishes stale security metadata, while unnecessary regeneration increases build time and registry usage. + +**Independent Test**: Run repeated builds with unchanged inputs, then change one input at a time and inspect cache decisions and artifact identities. + +**Acceptance Scenarios**: + +1. **Given** unchanged image and artifact-generation inputs, **when** the image is rebuilt, **then** the existing SBOM/VEX artifacts are reused and duplicate entries are not created. +2. **Given** a changed scanner option, merge input, or target platform, **when** the image is rebuilt, **then** the affected SBOM artifact is regenerated or republished with a new identity. +3. **Given** a changed VEX document, **when** the image is rebuilt, **then** the VEX artifact is regenerated or republished with a new identity. +4. **Given** a changed signing identity, **when** the image is rebuilt, **then** the affected signed artifact is republished rather than served from an incompatible cache entry. +5. **Given** an image restored from cache before the local build executes, **when** artifact processing runs, **then** the same cache and propagation rules apply as for an image built during the current run. + +--- + +### User Story 4 — Registry failures have predictable consequences (Priority: P2) + +A delivery engineer receives a clear result when artifact publication or propagation encounters a registry error. Final-repository failures do not leave a falsely successful release, while cache-repository failures follow the existing best-effort policy. A local-only build with SBOM or VEX enabled is rejected before any image build work begins. + +**Why this priority**: Operators need to distinguish a missing release artifact from an optional cache mirror problem. + +**Independent Test**: Run builds against an unavailable final repository, an unavailable cache repository, a missing secondary source artifact, and no registry destination, and verify the result and diagnostic behavior. + +**Acceptance Scenarios**: + +1. **Given** a failure while publishing or propagating an artifact to the final repository, **when** the build runs, **then** the build fails with an actionable error. +2. **Given** an unavailable cache repository, **when** the build runs, **then** the build follows the existing cache best-effort policy and reports the skipped propagation clearly. +3. **Given** a missing source artifact during propagation from a secondary repository, **when** the operation runs, **then** it does not silently claim that the destination contains the artifact. +4. **Given** a local-only build with SBOM or VEX enabled, **when** the build command starts, **then** it fails before image building and reports that a registry destination is required. +5. **Given** concurrent artifact attachments for one image digest, **when** all operations complete, **then** existing fallback-index convergence guarantees retain every artifact entry. + +### Edge Cases + +- A registry returns an absent fallback index because the image has no artifacts yet; the operation treats this according to the existing empty-index behavior. +- A final, cache, or primary repository contains an image whose digest differs from the source repository digest; the artifact is attached to the destination image digest, not the source digest. +- A suitable stage is found in a secondary repository but its attached artifact is missing; the primary copy must not be reported as artifact-complete. +- A multi-platform image has only one platform available in a destination; artifacts are propagated only for manifests that are actually present there. +- A configured cache or secondary repository is the same address as the primary repository; no redundant copy is performed. +- An artifact is already present in a destination; repeating the operation is idempotent and does not accumulate duplicate entries. +- Cleanup removes an image while its fallback artifact index remains; the existing orphan cleanup policy removes the resulting orphaned artifact index. +- SBOM or VEX is enabled without a registry destination; the build fails before image building with a clear requirement to configure a registry, rather than silently skipping artifact publication. +- A VEX document is image-level: for a multi-platform image it is attached only to the image index digest, and for a single-platform image it is attached to the image manifest digest; SBOMs use per-platform semantics. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The build MUST process enabled SBOM and VEX generation as part of the same deterministic image publication lifecycle, rather than as independent repository-dependent post-build operations. +- **FR-002**: The build MUST preserve OCI artifact semantics: SBOM and VEX MUST remain separate OCI artifacts and MUST NOT be represented as image layers, filesystem content, or fake container images. +- **FR-003**: For every artifact operation, the build MUST explicitly identify the image descriptor that the artifact describes and the destinations to which the artifact may be propagated. +- **FR-004**: For a single-platform image, an artifact MUST be attached to the digest of the image manifest actually published in that repository. +- **FR-005**: For a multi-platform image, platform-specific SBOMs MUST be attached to their corresponding platform manifest digests; an index digest MUST NOT substitute for a platform manifest digest. +- **FR-006**: VEX MUST be attached to the image manifest digest for a single-platform image and to the top-level image index digest for a multi-platform image; a multi-platform VEX MUST NOT be duplicated onto platform manifest digests. +- **FR-007**: The build MUST propagate SBOM and VEX artifacts from primary storage to the final repository and to configured cache repositories using one shared, idempotent propagation contract. +- **FR-007a**: When a suitable stage is restored from `--secondary-repo` and copied into primary storage, the build MUST propagate all attached SBOM and VEX artifacts to the corresponding primary image digest using the same shared, idempotent propagation contract. +- **FR-008**: Propagation MUST attach an artifact to the digest of the corresponding destination image, including when the source and destination image digests differ. +- **FR-009**: Propagation MUST skip identical source/destination addresses and MUST NOT create duplicate entries for an artifact already present with the same identity. +- **FR-010**: Final-repository publication or propagation failures MUST fail the build; cache-repository failures MUST retain the existing best-effort behavior and be distinguishable in build output. +- **FR-010a**: If SBOM or VEX is enabled and no registry destination is configured, the build MUST fail before image building starts with an actionable error requiring a registry destination. +- **FR-011**: Artifact cache identity MUST include every effective input that can change the corresponding artifact, including image dependency identity, scanner and merge inputs, VEX document content, target platform where applicable, artifact format version, and signer identity where applicable. +- **FR-012**: An unchanged set of effective inputs MUST reuse the existing artifact; changing an effective input MUST prevent a false cache hit and publish the corresponding new artifact identity. +- **FR-013**: The build MUST apply identical artifact processing rules whether an image was built during the current run or restored from a cache repository. +- **FR-014**: The implementation MUST preserve the current fallback-tag storage model, including per-platform artifact storage and existing artifact-to-image digest relationships. +- **FR-015**: Existing fallback-tag artifacts MUST remain readable, and concurrent attachment behavior MUST retain the existing convergence and deduplication guarantees. +- **FR-016**: Cleanup and purge operations MUST continue to remove orphaned artifact indexes in every repository where artifacts can be propagated, without leaving orphaned SBOM or VEX storage as a consequence of this feature. +- **FR-017**: User-facing meanings of `--repo`, `--final-repo`, `--cache-repo`, and `--secondary-repo` MUST remain unchanged except that SBOM and VEX artifacts consistently follow the corresponding published images. +- **FR-018**: The solution MUST NOT require migration to the OCI Referrers API. + +### Key Entities + +- **Image descriptor**: The published image manifest or image index/ platform manifest identity that determines which image an artifact describes. +- **Artifact stage**: A build-lifecycle operation that generates or publishes an OCI artifact without treating that artifact as a container image. +- **SBOM artifact**: An OCI artifact containing the software inventory for an image or platform manifest. +- **VEX artifact**: An OCI artifact containing vulnerability exploitability assessments; it is attached to the image manifest digest for single-platform images and to the top-level image index digest for multi-platform images. +- **Artifact identity**: The artifact type, checksum, platform where applicable, predicate kind, and signer identity needed to distinguish reusable results. +- **Artifact destination**: A primary, final, or cache repository together with the corresponding published image digest. +- **Secondary artifact source**: A secondary repository containing a suitable image stage and its attached artifacts before the stage is copied into primary storage. +- **Fallback artifact index**: The existing per-digest tag-based index that records artifacts attached to an image digest. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In 100% of test builds covering primary-only, primary-plus-final, primary-plus-cache, primary-plus-final-plus-cache, and secondary-to-primary configurations, every enabled SBOM and VEX artifact is retrievable from each repository where the corresponding image is published. +- **SC-002**: In 100% of two-platform test builds, each platform-specific SBOM has the correct platform subject and metadata, and no platform SBOM is attached only to the top-level index digest. +- **SC-003**: Repeating an unchanged build produces no duplicate artifact entries and records an artifact cache hit for every unchanged artifact. +- **SC-004**: Changing each supported artifact-generation input in isolation causes the affected artifact to miss its cache; unchanged artifact types remain reusable when their own inputs are unchanged. +- **SC-005**: A final-repository propagation failure fails the build in every tested case, while an unavailable cache repository follows the existing best-effort outcome in every tested case. +- **SC-005a**: Every tested build with SBOM or VEX enabled and no registry destination fails before image building starts and reports that a registry destination is required. +- **SC-006**: Repeating propagation against the same destination is idempotent and leaves exactly one entry for each artifact identity, including under concurrent attachment tests. +- **SC-007**: Images restored from cache and images built locally produce equivalent artifact availability and placement for the same effective inputs. +- **SC-008**: Existing fallback-tag artifacts remain readable and existing cleanup tests continue to remove orphaned artifact indexes from primary and propagated repositories. +- **SC-009**: Existing builds without SBOM/VEX configuration and existing user-facing repository options, including `--secondary-repo`, continue to complete without behavior changes unrelated to artifact propagation. + +## Assumptions + +- The current fallback-tag storage model remains the compatibility baseline; no OCI Referrers API migration is needed for this feature. +- SBOM artifacts are platform-specific for multi-platform images. VEX is image-level: it is attached to the image index digest for multi-platform images and to the image manifest digest for single-platform images. +- Registry-level image copies may preserve a digest, while backend-mediated copies may produce a different destination digest; artifact propagation therefore resolves the destination subject explicitly. +- Final repositories are release destinations and are subject to fatal propagation errors; cache repositories remain optional mirrors governed by existing best-effort policy. +- `--secondary-repo` remains a source for restoring suitable stages; artifacts follow a stage when it is copied from secondary storage into primary storage. +- Existing SBOM generation, VEX generation, signing, checksum, fallback-index, and cleanup components are reused unless implementation proves a focused change necessary. +- A repository that is unavailable or has no corresponding image cannot receive an artifact; the resulting behavior follows the destination's established error policy. +- A registry destination is required whenever SBOM or VEX is enabled; local-only artifact publication is not a supported mode. +- No new user-facing flags or configuration syntax are required. + +## Out of Scope + +- Migration from fallback tags to the OCI Referrers API. +- Changing the fallback-tag schema or abandoning per-platform artifact storage. +- Embedding SBOM or VEX data into the image filesystem or image layers. +- Combining all platform SBOMs into a single index-level SBOM. +- Rewriting scanner, CycloneDX, DSSE, signing, or fallback-index subsystems without demonstrated necessity. +- Changing the public semantics of `--repo`, `--final-repo`, `--cache-repo`, or `--secondary-repo` beyond correcting artifact propagation. +- Introducing separate user-configurable cleanup policies for this feature. diff --git a/specs/020-sbom-vex-build-stages/tasks.md b/specs/020-sbom-vex-build-stages/tasks.md new file mode 100644 index 0000000000..0a5c05c846 --- /dev/null +++ b/specs/020-sbom-vex-build-stages/tasks.md @@ -0,0 +1,256 @@ +# Tasks: SBOM and VEX as Build Stages + +**Input**: Design documents from `specs/020-sbom-vex-build-stages/` + +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md` + +**Tests**: Included because the feature specification requires independent testing for every user story. New tests must use co-located Ginkgo/Gomega suites and existing e2e fixtures. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Establish the implementation baseline without changing user-facing CLI semantics. + +- [X] T001 Inventory existing stage, SBOM, VEX, artifact, storage-copy, and cleanup call paths in `pkg/build/`, `pkg/build/stage/`, `pkg/oci/artifact/`, `pkg/storage/`, `pkg/storage/manager/`, and `pkg/cleaning/`, recording the concrete integration points in `specs/020-sbom-vex-build-stages/` +- [X] T002 [P] Inspect existing SBOM and VEX unit/e2e fixture conventions in `pkg/build/`, `test/e2e/sbom/`, and `test/e2e/vex/` and identify reusable helpers without adding a second test harness +- [X] T003 [P] Confirm the existing fallback-tag artifact index, `StagesStorage` implementations, and cleanup compatibility expectations in `pkg/oci/artifact/`, `pkg/storage/`, and `pkg/cleaning/` before modifying propagation code + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Define the minimal internal contracts shared by both artifact stages, including the dedicated artifact lifecycle hook and storage abstraction boundary. + +**Checkpoint**: Stage metadata, final-image subject flow, `MutateArtifact` lifecycle hook, checksum contract, storage artifact operations, propagation shape, and early registry validation are understood before story implementation begins. + +- [X] T004 Define internal artifact-stage metadata and stage-name constants in `pkg/build/stage/base.go`, including final image descriptor, artifact kind, target platform, mutable flag, and non-buildable flag +- [X] T005 Define the kind-neutral artifact propagation operation and explicit source/destination final-image descriptor flow in `pkg/build/` using existing `pkg/oci/artifact/` primitives, without introducing a public API or new dependency +- [X] T006 Locate the earliest common build initialization path and specify the registry-backed-storage validation seam in `pkg/build/build_phase.go` for both SBOM-enabled and VEX-enabled builds +- [X] T007 Extend `StorageManager` with minimal artifact listing, publication, destination-descriptor resolution, and copy operations in `pkg/storage/manager/`, routing each operation to the correct primary, secondary, cache, or final `storage.StagesStorage` instance +- [X] T008 Add the dedicated `MutateArtifact` lifecycle hook and scheduler/conveyor dispatch contract, keeping `MutateImage` reserved for image-manifest mutations, in `pkg/build/stage/`, `pkg/build/conveyor.go`, and `pkg/build/` +- [X] T009 [P] Define the artifact-stage checksum contract using `GetDependencies`, `GetContentDependencies`, and `util.Sha256Hash(args...)` in `pkg/build/stage/`, following `SignStage` +- [X] T010 [P] Add shared test fixtures or helper functions needed to construct final manifest/index descriptors and fallback artifact indexes in co-located files under `pkg/build/` and `pkg/oci/artifact/` +- [X] T011 [P] Define the minimal OCI-artifact primitives on `storage.StagesStorage` in `pkg/storage/stages_storage.go` for listing attached artifacts, publishing an artifact for a final image digest, and copying attached artifacts between destination descriptors; keep repository selection outside the backend +- [X] T012 [P] Implement the new `StagesStorage` artifact primitives for every registry-backed storage implementation under `pkg/storage/` and keep local-storage behavior explicit and unsupported for artifact publication +- [X] T013 [P] Add Ginkgo/Gomega contract tests for `StagesStorage` artifact primitives, `StorageManager` repository routing, and `MutateArtifact` dispatch, verifying stage code imports neither concrete registry clients nor repository-selection logic in `pkg/storage/stages_storage_test.go`, `pkg/storage/manager/`, `pkg/build/stage/`, and `pkg/build/` + +--- + +## Phase 3: User Story 1 - Artifacts follow published images (Priority: P1) 🎯 MVP + +**Goal**: Replace transitional SBOM/VEX steps with lifecycle-owned OCI-artifact stages and make all attached artifacts follow images into primary, final, cache, and secondary-restored destinations through `StorageManager` routing. + +**Independent Test**: Build the existing fixture with primary-only, final, cache, combined final/cache, identical-address, and secondary-repository configurations; retrieve both artifact kinds by each destination image digest. + +### Tests for User Story 1 + +- [X] T014 [US1] Add Ginkgo/Gomega unit coverage for artifact-stage mutability, non-buildability, final-image descriptor association, no filesystem mutation, dedicated `MutateArtifact` dispatch, and stage lifecycle behavior in `pkg/build/stage/artifact_test.go` +- [X] T015 [US1] Add Ginkgo/Gomega unit coverage for shared propagation, destination digest resolution, identical-repository skipping, and artifact identity deduplication in `pkg/build/artifact_propagation_test.go` +- [X] T016 [US1] Add Ginkgo/Gomega unit coverage for secondary-to-primary restoration and missing-source-artifact handling in `pkg/build/artifact_propagation_test.go` +- [X] T017 [US1] Extend the SBOM e2e suite in `test/e2e/sbom/` for primary-only, final, cache, combined final/cache, identical-address, and secondary-repository artifact availability scenarios +- [X] T018 [US1] Add Ginkgo/Gomega migration coverage proving all SBOM callers use `SbomStage` and all VEX callers use `VexStage`, with no `sbomStep` or `vexStep` references remaining in `pkg/build/` +- [X] T019 [US1] Add Ginkgo/Gomega tests proving `SbomStage` and `VexStage` route registry reads, writes, copies, metadata, and artifact operations through `StorageManager`, with the manager selecting the appropriate `storage.StagesStorage`, in `pkg/build/stage/` and `pkg/storage/manager/` + +### Implementation for User Story 1 + +- [X] T020 [P] [US1] Implement the registry-only mutable, non-buildable `SbomStage` in `pkg/build/stage/sbom.go`, including final-image-digest association and SBOM generation, cache identity, signing, attestation publication, and fallback-index interaction through `StorageManager` +- [X] T021 [P] [US1] Implement the registry-only mutable, non-buildable `VexStage` in `pkg/build/stage/vex.go`, including final-image-digest association and VEX generation, cache identity, signing, attestation publication, and fallback-index interaction through `StorageManager` +- [X] T022 [US1] Migrate all SBOM behavior and callers from `sbomStep` into `SbomStage` in `pkg/build/`, preserving existing generation, checksum, signing, publication, and fallback-index behavior while routing repository operations through `StorageManager` +- [X] T023 [US1] Migrate all VEX behavior and callers from `vexStep` into `VexStage` in `pkg/build/`, preserving existing generation, checksum, signing, publication, and fallback-index behavior while routing repository operations through `StorageManager` +- [X] T024 [US1] Ensure `PrepareImage` is a no-op, `MutateArtifact` operates only on the associated OCI artifact through `StorageManager`, and the artifact stages do not implement or invoke `MutateImage`; do not fetch, rebuild, store, or mutate image filesystem/layers in `pkg/build/stage/sbom.go` and `pkg/build/stage/vex.go +- [X] T025 [US1] Register `SbomStage` and `VexStage` after the content-producing stage for Stapel, Dockerfile, and restored-stage image paths in `pkg/build/build_phase.go` +- [X] T026 [US1] Execute artifact publication through stage `MutateArtifact` without changing image filesystem or layer content, and remove duplicate SBOM/VEX generation from `BuildPhase.AfterImages` while retaining unrelated publication/report work in `pkg/build/build_phase.go +- [X] T027 [US1] Delete transitional `pkg/build/sbom_step.go`, `pkg/build/vex_step.go`, and their step-specific tests after all callers and migration tests use `SbomStage` and `VexStage` +- [X] T028 [US1] Implement shared idempotent artifact propagation through `StorageManager`, with manager-routed source/destination backends, destination descriptor resolution, identical-address skipping, fallback-index deduplication, and all-artifact copying in `pkg/build/artifact_propagation.go` and `pkg/storage/manager/` +- [X] T029 [US1] Connect primary-to-final and primary-to-cache image-copy paths to the `StorageManager`-routed propagation operation while preserving fatal final errors and best-effort cache warnings in `pkg/build/` and `pkg/storage/manager/` +- [X] T030 [US1] Connect secondary-stage restoration into primary storage to the same `StorageManager`-routed propagation operation, including explicit handling when a source artifact is absent, in `pkg/storage/manager/` and `pkg/build/` + +**Checkpoint**: User Story 1 is independently functional; `SbomStage` and `VexStage` are the sole lifecycle owners, operate on final-image-associated OCI artifacts, and artifacts follow every applicable published image. + +--- + +## Phase 4: User Story 2 - Platform-specific artifacts describe the correct image (Priority: P1) + +**Goal**: Attach per-platform SBOMs to final platform manifests and attach exactly one image-level VEX to the correct single-platform manifest or multi-platform index. + +**Independent Test**: Build a two-platform fixture, inspect each artifact subject and platform metadata, and verify that multi-platform VEX appears only on the final image index while single-platform VEX uses the final manifest. + +### Tests for User Story 2 + +- [X] T031 [P] [US2] Add Ginkgo/Gomega unit tests for single-platform and multi-platform final-image subject selection in `pkg/build/artifact_subject_test.go` +- [X] T032 [P] [US2] Add Ginkgo/Gomega unit tests proving platform SBOM metadata and final parent digest are distinct per platform in `pkg/build/stage/sbom_test.go` +- [X] T033 [US2] Move or rename platform-subject tests from transitional `pkg/build/sbom_step_test.go` into stage-owned tests and ensure the final suite contains no step-specific test dependency +- [X] T034 [US2] Extend `test/e2e/sbom/` with two-platform subject and metadata assertions for each final platform manifest +- [X] T035 [US2] Extend `test/e2e/vex/` with single-platform final-manifest placement and multi-platform final-index-only placement assertions +- [X] T036 [US2] Add storage-backed tests for destination platform/index descriptor resolution when the copied image digest differs from the source in `pkg/build/artifact_propagation_test.go` + +### Implementation for User Story 2 + +- [X] T037 [US2] Implement explicit final-image artifact subject resolution for published manifest and index descriptors in `pkg/build/artifact_subject.go` +- [X] T038 [US2] Pass the final target platform and resolved final platform manifest descriptor through `SbomStage` creation and publication in `pkg/build/stage/sbom.go` and `pkg/build/build_phase.go +- [X] T039 [US2] Make `VexStage` registration run once per multi-platform image set with the final top-level index subject, and use the final image manifest subject for single-platform builds in `pkg/build/stage/vex.go` and `pkg/build/build_phase.go` +- [X] T040 [US2] Ensure `StorageManager`-routed propagation resolves the corresponding destination platform manifest or image index before attaching artifacts, including destinations with differing source digests, in `pkg/build/artifact_propagation.go`, `pkg/storage/manager/`, and `pkg/storage/` + +**Checkpoint**: User Story 2 is independently testable and no artifact can silently use an index subject for a platform SBOM or duplicate multi-platform VEX onto platform manifests. + +--- + +## Phase 5: User Story 3 - Rebuilds reuse or invalidate artifact results correctly (Priority: P1) + +**Goal**: Preserve valid artifact cache hits while preventing stale reuse when image, scanner, merge/GOST, VEX document, platform, format, or signing inputs change; cache ownership lives in the artifact stages. + +**Independent Test**: Repeat unchanged builds and then change each effective artifact input one at a time; inspect cache decisions, artifact identities, and duplicate fallback-index entries. + +### Tests for User Story 3 + +- [X] T041 [P] [US3] Add Ginkgo/Gomega tests for `SbomStage` dependency identity across final image digest, scanner, merge/GOST, format, signer, and target-platform inputs in `pkg/build/stage/sbom_test.go` +- [X] T042 [P] [US3] Add Ginkgo/Gomega tests for `VexStage` dependency identity across final parent digest, document content, format, and signer inputs in `pkg/build/stage/vex_test.go` +- [X] T043 [US3] Add Ginkgo/Gomega tests for repeated idempotent publication and cache-restored artifact processing through `StorageManager` in `pkg/build/artifact_propagation_test.go` +- [X] T044 [US3] Extend `test/e2e/sbom/` and `test/e2e/vex/` with unchanged rebuild, changed-input, signing-identity, and restored-cache scenarios +- [X] T045 [US3] Remove or migrate any remaining cache-identity assertions from deleted `pkg/build/sbom_step_test.go` and `pkg/build/vex_step_test.go` into stage-owned tests + +### Implementation for User Story 3 + +- [X] T046 [US3] Include all effective SBOM inputs and the final parent image identity in `SbomStage` dependency calculation while preserving existing checksum semantics in `pkg/build/stage/sbom.go` +- [X] T047 [US3] Include VEX document content, final parent descriptor identity, format version, and signer identity in `VexStage` dependency calculation in `pkg/build/stage/vex.go` +- [X] T048 [US3] Select reusable artifact-bearing stages from primary and secondary storage through `StorageManager` using the complete dependency identity, and apply identical processing to locally built and cache-restored images in `pkg/build/` and `pkg/storage/manager/` +- [X] T049 [US3] Preserve fallback-index convergence and prevent duplicate entries during repeated or concurrent artifact publication in `pkg/oci/artifact/`, `pkg/storage/`, and `pkg/build/artifact_propagation.go` + +**Checkpoint**: User Story 3 is independently testable; unchanged inputs reuse artifacts and every effective changed input invalidates only the affected artifact identity. + +--- + +## Phase 6: User Story 4 - Registry failures have predictable consequences (Priority: P2) + +**Goal**: Reject unsupported local-only artifact builds early, fail on final-repository artifact errors, and retain distinguishable best-effort behavior for cache errors. + +**Independent Test**: Exercise unavailable final and cache repositories, missing secondary artifacts, concurrent attachment, and no-registry builds; verify build result and actionable diagnostics. + +### Tests for User Story 4 + +- [X] T050 [P] [US4] Add Ginkgo/Gomega unit tests proving artifact-enabled local-only builds fail before any image stage executes in `pkg/build/build_phase_test.go` +- [X] T051 [P] [US4] Add Ginkgo/Gomega unit tests for fatal final propagation errors and non-fatal, clearly logged cache propagation errors in `pkg/build/artifact_propagation_test.go` +- [X] T052 [P] [US4] Add Ginkgo/Gomega concurrency tests that retain every fallback-index artifact entry during concurrent `StorageManager`-routed attachment in `pkg/oci/artifact/`, `pkg/storage/manager/`, and `pkg/storage/` +- [X] T053 [US4] Extend `test/e2e/sbom/` and `test/e2e/vex/` for unavailable final/cache repositories, local-only rejection, and missing secondary source artifact behavior +- [X] T054 [US4] Extend cleanup coverage in `pkg/cleaning/` and relevant e2e fixtures to verify orphan fallback artifact indexes are removed from primary and propagated repositories + +### Implementation for User Story 4 + +- [X] T055 [US4] Add earliest-phase registry-backed-storage validation for enabled SBOM/VEX with an actionable `--repo` or disable-artifacts message in `pkg/build/build_phase.go` +- [X] T056 [US4] Enforce fatal final-repository publication/propagation errors and best-effort cache-repository warnings through one shared `StorageManager`-routed error-policy path in `pkg/build/artifact_propagation.go` and `pkg/storage/manager/` +- [X] T057 [US4] Ensure missing secondary source artifacts return an incomplete/error result rather than claiming artifact-complete restoration in `pkg/build/` and `pkg/storage/manager/` +- [X] T058 [US4] Verify artifact propagation does not bypass existing cleanup and purge behavior, updating only the necessary repository traversal in `pkg/cleaning/` + +**Checkpoint**: User Story 4 is independently testable; registry failures and local-only configuration produce predictable results without changing repository flag semantics. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Validate the complete implementation against the revised stage-ownership and storage-abstraction boundaries. + +- [X] T059 [P] Review `pkg/build/`, `pkg/build/stage/`, `pkg/storage/`, `pkg/storage/manager/`, `pkg/oci/artifact/`, and `pkg/cleaning/` for unnecessary public surface, direct registry-client access from stages, duplicate convergence paths, unwrapped errors, and comments that do not explain non-obvious logic +- [X] T060 [P] Verify no `sbomStep` or `vexStep` types, constructors, callers, or compatibility wrappers remain in `pkg/build/`, verify no step-specific tests remain, and verify `SbomStage`/`VexStage` are the sole lifecycle owners +- [X] T061 [P] Verify all stage registry interaction goes through `StorageManager`, the manager routes to all supported registry-backed `storage.StagesStorage` implementations, and local storage rejects artifact publication explicitly in `pkg/storage/` and `pkg/storage/manager/` +- [X] T062 [P] Verify existing builds with SBOM/VEX disabled and existing `--repo`, `--final-repo`, `--cache-repo`, and `--secondary-repo` semantics in `test/legacy_e2e/` and relevant unit fixtures +- [X] T063 Run formatting with `task format` for authored Go directories +- [X] T064 Run compilation with `task build` +- [X] T065 Install the lint prerequisite with `task deps:install:golangci-lint` and run repository lint with `task lint` +- [X] T066 Run the complete unit suite with `task test:unit` +- [X] T067 Run scoped SBOM e2e coverage with `task test:e2e paths="./test/e2e/sbom/..." labelFilter="sbom"` +- [X] T068 Run scoped VEX e2e coverage with `task test:e2e paths="./test/e2e/vex/..." labelFilter="vex"` +- [X] T069 Run legacy integration coverage with `task test:integration` +- [X] T070 Confirm authored-file whitespace and generated-file scope with `git diff --check` limited to changed authored files, without modifying `CHANGELOG.md` or generated CLI reference files + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 1 (Setup)**: No implementation dependency; establishes the current call-path and storage baseline. +- **Phase 2 (Foundational)**: Depends on Phase 1 and blocks story implementation. `MutateArtifact` dispatch, checksum conventions, `StorageManager` routing, `StagesStorage` primitives, and backend implementations must be available before artifact stages can publish or propagate OCI artifacts. +- **Phase 3 (US1)**: Depends on Phase 2 and is the MVP increment. It includes full migration from `sbomStep`/`vexStep`, final-image-digest association, `MutateArtifact` integration, manager-routed storage, and deletion of transitional files. +- **Phase 4 (US2)**: Depends on US1's artifact stages and propagation path because it specializes final subject selection; platform tests must be owned by the new stages before old step tests are deleted. +- **Phase 5 (US3)**: Depends on US1 and US2 stage identity/subject contracts so cache identity includes the correct final parent descriptor. +- **Phase 6 (US4)**: Depends on the shared `StorageManager`-routed propagation operation from US1; its validation work can proceed in parallel with US2/US3 after the shared path exists. +- **Phase 7 (Polish)**: Depends on all desired stories being complete, including removal of transitional files and references and validation of every storage implementation. + +### User Story Dependencies + +- **US1 (P1)**: Starts after Phase 2; no dependency on another user story. MVP. +- **US2 (P1)**: Depends on US1's `SbomStage`/`VexStage` lifecycle and `StorageManager`-routed propagation implementation. +- **US3 (P1)**: Depends on US1's stages and US2's explicit final-image subject rules. +- **US4 (P2)**: Depends on US1's propagation/error path; early-validation tests can proceed independently of US2 and US3. + +### Parallel Opportunities + +- Phase 1 tasks T002 and T003 can run in parallel after T001's baseline inventory. +- In Phase 2, T009–T013 can proceed in parallel once the required lifecycle and storage method shapes are agreed; backend implementations must converge on the same interface, while `MutateArtifact` dispatch remains a prerequisite for the stages. +- Within US1, T020 and T021 are parallel stage files; T012, T013, and T019 are separate test concerns. T022 and T023 are parallel migrations when their callers are disjoint. +- Within US2, T031/T032 and T034–T036 are parallel test work; subject-selection and VEX-placement implementation can proceed in separate files. +- Within US3, T041 and T042 are parallel stage-owned identity tests; T044 can proceed independently once stage contracts are stable. +- Within US4, T050–T052 are parallel test tasks, and T054 can proceed independently in cleanup files. +- After Phase 2, separate contributors can work on stage migration, storage backends, propagation, and validation tests, but deletion of transitional files (T027) must wait for all callers/tests to migrate. +- Polish review and regression checks (T059–T062) can run in parallel before the sequential repository-wide validation commands T063–T070. + +--- + +## Parallel Example: User Story 1 + +```text +# After Phase 2, start independent stage, migration, storage, and test work: +Task: T012 — stage lifecycle tests in pkg/build/stage/artifact_test.go +Task: T013 — propagation tests in pkg/build/artifact_propagation_test.go +Task: T016 — migration coverage in pkg/build/ +Task: T017 — StorageManager routing tests in pkg/build/stage/ and pkg/storage/manager/ +Task: T020 — SbomStage in pkg/build/stage/sbom.go +Task: T021 — VexStage in pkg/build/stage/vex.go +Task: T015 — repository propagation scenarios in test/e2e/sbom/ + +# Integrate after the stage and storage contracts are stable: +Task: T022 — migrate SBOM behavior and callers +Task: T023 — migrate VEX behavior and callers +Task: T024 — enforce final-image OCI-artifact-only behavior +Task: T025 — register stages in pkg/build/build_phase.go +Task: T026 — remove duplicate AfterImages convergence +Task: T027 — delete transitional step files and tests +Task: T028 — implement shared StorageManager-routed propagation +``` + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1 baseline inspection. +2. Complete Phase 2 `MutateArtifact`, checksum, `StorageManager`, final-image subject, propagation, and validation contracts. +3. Implement or verify `SbomStage` and `VexStage` as final-image-associated OCI-artifact stages. +4. Migrate all behavior and callers from `sbomStep`/`vexStep` through `MutateArtifact` and `StorageManager`. +5. Register the stages, connect primary/final/cache/secondary propagation, and delete transitional files. +6. Run US1 unit and SBOM e2e tests independently. +7. Stop for validation/demo before adding platform-specific and cache-invalidation refinements. + +### Incremental Delivery + +1. Deliver US1 as the first usable increment: lifecycle-owned artifact stages with complete `StorageManager`-routed repository propagation. +2. Add US2: correct final manifest/index subjects and platform placement, with stage-owned tests. +3. Add US3: complete stage dependency identity and cache reuse/invalidation. +4. Add US4: early validation and explicit final/cache failure behavior. +5. Run the full Polish phase and repository-required validation sequence. + +### Traceability + +- **FR-001–FR-003**: T020–T030, T037–T040 +- **FR-004–FR-006**: T031–T040 +- **FR-007–FR-010**: T007, T011–T013, T015–T030, T050, T054–T057 +- **FR-011–FR-013**: T041–T049 +- **FR-014–FR-016**: T003, T049, T054, T058 +- **FR-017–FR-018**: T060–T062 and all implementation tasks; no CLI flag changes or OCI Referrers migration +- **Stage ownership and storage boundary**: T008–T013, T016–T027, T059–T061 + +## Notes + +- Completed tasks retain `[X]`; pending tasks use `[ ]`. Every task has a sequential ID and story-phase tasks include exactly one `[US#]` label. +- No external API contracts were provided in `contracts/`; the artifact methods are internal `StorageManager` and `StagesStorage` contracts. +- The revised plan requires `SbomStage` and `VexStage` to be the sole lifecycle owners, associated with final image descriptors, and restricted to OCI-artifact operations through `MutateArtifact` and `StorageManager`; `MutateImage` remains reserved for image mutations. +- `sbom_step.go`, `vex_step.go`, their step-specific callers, and their tests must not remain after migration. +- No new dependencies, CLI flags, image layers, or OCI Referrers migration are planned. diff --git a/test/e2e/sbom/artifact_failures_test.go b/test/e2e/sbom/artifact_failures_test.go new file mode 100644 index 0000000000..19f992a14a --- /dev/null +++ b/test/e2e/sbom/artifact_failures_test.go @@ -0,0 +1,121 @@ +package e2e_build_test + +import ( + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/werf/werf/v2/pkg/oci/artifact" + "github.com/werf/werf/v2/test/pkg/report" + "github.com/werf/werf/v2/test/pkg/suite_init" + "github.com/werf/werf/v2/test/pkg/werf" +) + +var _ = Describe("SBOM artifact repository failures", Label("e2e", "sbom", "artifact-failures"), func() { + It("publishes SBOM artifacts into a separate cache repository namespace", func(ctx SpecContext) { + setupSbomBuildEnv(setupEnvOptions{ContainerBackendMode: "vanilla-docker"}) + cacheRepo := suite_init.TestRepo(SuiteData.ProjectName + "-cache") + SuiteData.InitTestRepo(ctx, "repo_sbom_cache_repo", "inject/ospm_basic") + testRepoPath := SuiteData.GetTestRepoPath("repo_sbom_cache_repo") + builderEnv := buildTrustedBuilderBase(ctx, testRepoPath, "sbom-cache-repo-builder") + project := werf.NewProject(SuiteData.WerfBinPath, testRepoPath) + reportProject := report.NewProjectWithReport(project) + _, buildReport := reportProject.BuildWithReport(ctx, SuiteData.GetBuildReportPath("sbom_cache_repo.json"), &werf.WithReportOptions{ + CommonOptions: werf.CommonOptions{ExtraArgs: []string{"--cache-repo", cacheRepo}, Envs: builderEnv}, + }) + + record, found := buildReport.Images["app"] + Expect(found).To(BeTrue()) + Expect(record.DockerImageDigest).NotTo(BeEmpty()) + cacheOut := project.SbomGet(ctx, &werf.SbomGetOptions{CommonOptions: werf.CommonOptions{ + ExtraArgs: []string{"--repo", cacheRepo, "--digest", record.DockerImageDigest}, Envs: builderEnv, + }}) + Expect(cacheOut).To(ContainSubstring("curl")) + }) + + It("continues successfully when the cache repository is unavailable", func(ctx SpecContext) { + setupSbomBuildEnv(setupEnvOptions{ContainerBackendMode: "vanilla-docker"}) + SuiteData.InitTestRepo(ctx, "repo_sbom_unavailable_cache", "inject/ospm_basic") + testRepoPath := SuiteData.GetTestRepoPath("repo_sbom_unavailable_cache") + builderEnv := buildTrustedBuilderBase(ctx, testRepoPath, "sbom-unavailable-cache-builder") + project := werf.NewProject(SuiteData.WerfBinPath, testRepoPath) + reportProject := report.NewProjectWithReport(project) + _, buildReport := reportProject.BuildWithReport(ctx, SuiteData.GetBuildReportPath("sbom_unavailable_cache.json"), &werf.WithReportOptions{ + CommonOptions: werf.CommonOptions{ExtraArgs: []string{"--cache-repo", "127.0.0.1:1/unreachable/cache"}, Envs: builderEnv}, + }) + + record, found := buildReport.Images["app"] + Expect(found).To(BeTrue()) + primaryOut := project.SbomGet(ctx, &werf.SbomGetOptions{CommonOptions: werf.CommonOptions{ + ExtraArgs: []string{"--repo", suite_init.TestRepo(SuiteData.ProjectName), "--digest", record.DockerImageDigest}, Envs: builderEnv, + }}) + Expect(primaryOut).To(ContainSubstring("curl")) + }) + It("rejects artifact generation without a registry before image work", func(ctx SpecContext) { + setupSbomBuildEnv(setupEnvOptions{ContainerBackendMode: "vanilla-docker"}) + SuiteData.Stubs.UnsetEnv("WERF_REPO") + SuiteData.Stubs.UnsetEnv("WERF_FINAL_REPO") + + SuiteData.InitTestRepo(ctx, "repo_sbom_local_only", "inject/ospm_basic") + testRepoPath := SuiteData.GetTestRepoPath("repo_sbom_local_only") + project := werf.NewProject(SuiteData.WerfBinPath, testRepoPath) + + out, err := project.BuildWithErr(ctx, &werf.BuildOptions{CommonOptions: werf.CommonOptions{ + Envs: []string{"BUILDER_BASE_IMAGE=registry.example/builder:latest"}, + }}) + + Expect(err).To(HaveOccurred()) + Expect(out).To(ContainSubstring("requires a container registry")) + Expect(out).NotTo(ContainSubstring("Building stage")) + }) + + It("rejects a secondary image whose artifact fallback index is missing", func(ctx SpecContext) { + setupSbomBuildEnv(setupEnvOptions{ContainerBackendMode: "vanilla-docker"}) + secondaryRepo := suite_init.TestRepo(SuiteData.ProjectName + "-secondary") + primaryRepo := suite_init.TestRepo(SuiteData.ProjectName + "-restored") + SuiteData.InitTestRepo(ctx, "repo_sbom_missing_secondary_artifact", "inject/ospm_basic") + testRepoPath := SuiteData.GetTestRepoPath("repo_sbom_missing_secondary_artifact") + builderEnv := buildTrustedBuilderBase(ctx, testRepoPath, "sbom-secondary-builder") + project := werf.NewProject(SuiteData.WerfBinPath, testRepoPath) + reportProject := report.NewProjectWithReport(project) + _, buildReport := reportProject.BuildWithReport(ctx, SuiteData.GetBuildReportPath("sbom_secondary_source.json"), &werf.WithReportOptions{ + CommonOptions: werf.CommonOptions{ExtraArgs: []string{"--repo", secondaryRepo}, Envs: builderEnv}, + }) + record, found := buildReport.Images["app"] + Expect(found).To(BeTrue()) + + ref, err := name.NewTag(secondaryRepo+":"+artifact.FallbackTag(record.DockerImageDigest), name.Insecure) + Expect(err).NotTo(HaveOccurred()) + fallbackDesc, err := remote.Get(ref, remote.WithContext(ctx), remote.WithAuth(authn.Anonymous)) + Expect(err).NotTo(HaveOccurred()) + fallbackDigest, err := name.NewDigest(secondaryRepo+"@"+fallbackDesc.Digest.String(), name.Insecure) + Expect(err).NotTo(HaveOccurred()) + Expect(remote.Delete(fallbackDigest, remote.WithAuth(authn.Anonymous))).To(Succeed()) + + out, err := project.BuildWithErr(ctx, &werf.BuildOptions{CommonOptions: werf.CommonOptions{ + ExtraArgs: []string{"--repo", primaryRepo, "--secondary-repo", secondaryRepo}, Envs: builderEnv, + }}) + Expect(err).To(HaveOccurred()) + Expect(out).To(ContainSubstring("has incomplete artifacts")) + }) + + It("fails when the final artifact repository is unavailable", func(ctx SpecContext) { + setupSbomBuildEnv(setupEnvOptions{ContainerBackendMode: "vanilla-docker"}) + finalRepo := suite_init.TestRepo(SuiteData.ProjectName + "-unavailable-final") + SuiteData.Stubs.SetEnv("WERF_FINAL_REPO", finalRepo) + + SuiteData.InitTestRepo(ctx, "repo_sbom_unavailable_final", "inject/ospm_basic") + testRepoPath := SuiteData.GetTestRepoPath("repo_sbom_unavailable_final") + builderEnv := buildTrustedBuilderBase(ctx, testRepoPath, "sbom-unavailable-final-builder") + builderEnv = append(builderEnv, "WERF_FINAL_REPO=127.0.0.1:1/unreachable/final") + project := werf.NewProject(SuiteData.WerfBinPath, testRepoPath) + + out, err := project.BuildWithErr(ctx, &werf.BuildOptions{CommonOptions: werf.CommonOptions{Envs: builderEnv}}) + + Expect(err).To(HaveOccurred()) + Expect(out).To(ContainSubstring("unable to init storage manager cache")) + Expect(out).To(ContainSubstring("127.0.0.1:1/unreachable/final")) + }) +}) diff --git a/test/e2e/sbom/repository_matrix_test.go b/test/e2e/sbom/repository_matrix_test.go new file mode 100644 index 0000000000..1a8752f33f --- /dev/null +++ b/test/e2e/sbom/repository_matrix_test.go @@ -0,0 +1,109 @@ +package e2e_build_test + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/werf/werf/v2/test/pkg/report" + "github.com/werf/werf/v2/test/pkg/suite_init" + "github.com/werf/werf/v2/test/pkg/werf" +) + +type sbomRepositoryMatrixCase struct { + name string + finalRepo bool + cacheRepo bool + identical bool +} + +var _ = Describe("SBOM artifact repository matrix", Label("e2e", "sbom", "repository-matrix"), func() { + DescribeTable("makes artifacts available in every image destination", + func(ctx SpecContext, testCase sbomRepositoryMatrixCase) { + setupSbomBuildEnv(setupEnvOptions{ContainerBackendMode: "vanilla-docker"}) + primaryRepo := suite_init.TestRepo(SuiteData.ProjectName + "-matrix-" + testCase.name) + finalRepo := suite_init.TestRepo(SuiteData.ProjectName + "-matrix-" + testCase.name + "-final") + cacheRepo := suite_init.TestRepo(SuiteData.ProjectName + "-matrix-" + testCase.name + "-cache") + SuiteData.Stubs.SetEnv("WERF_REPO", primaryRepo) + + SuiteData.InitTestRepo(ctx, "repo_sbom_repository_matrix_"+testCase.name, "inject/ospm_basic") + testRepoPath := SuiteData.GetTestRepoPath("repo_sbom_repository_matrix_" + testCase.name) + builderEnv := buildTrustedBuilderBase(ctx, testRepoPath, "sbom-matrix-"+testCase.name) + project := werf.NewProject(SuiteData.WerfBinPath, testRepoPath) + + var extraArgs []string + if testCase.finalRepo { + extraArgs = append(extraArgs, "--final-repo", finalRepo) + } + if testCase.cacheRepo { + extraArgs = append(extraArgs, "--cache-repo", cacheRepo) + } + if testCase.identical { + extraArgs = append(extraArgs, "--final-repo", primaryRepo, "--cache-repo", primaryRepo) + } + + _, buildReport := report.NewProjectWithReport(project).BuildWithReport(ctx, + SuiteData.GetBuildReportPath("sbom_repository_matrix_"+testCase.name+".json"), + &werf.WithReportOptions{CommonOptions: werf.CommonOptions{ExtraArgs: extraArgs, Envs: builderEnv}}, + ) + record, found := buildReport.Images["app"] + Expect(found).To(BeTrue(), "expected app image in build report") + Expect(record.DockerImageDigest).NotTo(BeEmpty()) + + destinations := []string{primaryRepo} + if testCase.finalRepo && !testCase.identical { + destinations = append(destinations, finalRepo) + } + if testCase.cacheRepo && !testCase.identical { + destinations = append(destinations, cacheRepo) + } + for _, destination := range destinations { + out := project.SbomGet(ctx, &werf.SbomGetOptions{CommonOptions: werf.CommonOptions{ + ExtraArgs: []string{"--repo", destination, "--digest", record.DockerImageDigest}, + Envs: builderEnv, + }}) + Expect(out).To(ContainSubstring("curl"), "SBOM is unavailable in %s", destination) + } + }, + Entry("primary only", sbomRepositoryMatrixCase{name: "primary"}), + Entry("final repository", sbomRepositoryMatrixCase{name: "final", finalRepo: true}), + Entry("cache repository", sbomRepositoryMatrixCase{name: "cache", cacheRepo: true}), + Entry("combined final and cache repositories", sbomRepositoryMatrixCase{name: "combined", finalRepo: true, cacheRepo: true}), + Entry("identical final and cache addresses", sbomRepositoryMatrixCase{name: "identical", finalRepo: true, cacheRepo: true, identical: true}), + ) + + It("restores an SBOM-bearing image from a secondary repository", func(ctx SpecContext) { + setupSbomBuildEnv(setupEnvOptions{ContainerBackendMode: "vanilla-docker"}) + secondaryRepo := suite_init.TestRepo(SuiteData.ProjectName + "-matrix-secondary") + primaryRepo := suite_init.TestRepo(SuiteData.ProjectName + "-matrix-restored") + SuiteData.InitTestRepo(ctx, "repo_sbom_repository_matrix_secondary", "inject/ospm_basic") + testRepoPath := SuiteData.GetTestRepoPath("repo_sbom_repository_matrix_secondary") + builderEnv := buildTrustedBuilderBase(ctx, testRepoPath, "sbom-matrix-secondary") + project := werf.NewProject(SuiteData.WerfBinPath, testRepoPath) + + reportPath := SuiteData.GetBuildReportPath("sbom_repository_matrix_secondary_source.json") + _, sourceReport := report.NewProjectWithReport(project).BuildWithReport(ctx, reportPath, + &werf.WithReportOptions{CommonOptions: werf.CommonOptions{ExtraArgs: []string{"--repo", secondaryRepo}, Envs: builderEnv}}) + source, found := sourceReport.Images["app"] + Expect(found).To(BeTrue()) + Expect(source.DockerImageDigest).NotTo(BeEmpty()) + + _, restoredReport := report.NewProjectWithReport(project).BuildWithReport(ctx, + SuiteData.GetBuildReportPath("sbom_repository_matrix_secondary_restored.json"), + &werf.WithReportOptions{CommonOptions: werf.CommonOptions{ + ExtraArgs: []string{"--repo", primaryRepo, "--secondary-repo", secondaryRepo}, + Envs: builderEnv, + }}) + restored, found := restoredReport.Images["app"] + Expect(found).To(BeTrue()) + Expect(restored.DockerImageDigest).NotTo(BeEmpty()) + Expect(restored.DockerImageDigest).To(Equal(source.DockerImageDigest), fmt.Sprintf("secondary restore changed image digest from %s", source.DockerImageDigest)) + + out := project.SbomGet(ctx, &werf.SbomGetOptions{CommonOptions: werf.CommonOptions{ + ExtraArgs: []string{"--repo", primaryRepo, "--digest", restored.DockerImageDigest}, + Envs: builderEnv, + }}) + Expect(out).To(ContainSubstring("curl")) + }) +}) diff --git a/test/e2e/vex/vex_test.go b/test/e2e/vex/vex_test.go index 3f5e550501..1247ec3eb9 100644 --- a/test/e2e/vex/vex_test.go +++ b/test/e2e/vex/vex_test.go @@ -6,14 +6,111 @@ import ( "path/filepath" "strings" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/werf/werf/v2/pkg/oci/artifact" + "github.com/werf/werf/v2/test/pkg/report" "github.com/werf/werf/v2/test/pkg/suite_init" "github.com/werf/werf/v2/test/pkg/utils" "github.com/werf/werf/v2/test/pkg/werf" ) +var _ = Describe("VEX artifact repository failures", Label("e2e", "vex", "artifact-failures"), func() { + It("publishes VEX artifacts into a separate cache repository namespace", func(ctx SpecContext) { + setupVexEnv("vanilla-docker") + cacheRepo := suite_init.TestRepo(SuiteData.ProjectName + "-cache") + SuiteData.InitTestRepo(ctx, "repo_vex_cache_repo", "simple") + testRepoPath := SuiteData.GetTestRepoPath("repo_vex_cache_repo") + project := werf.NewProject(SuiteData.WerfBinPath, testRepoPath) + reportPath := filepath.Join(SuiteData.TmpDir, "vex_cache_repo.json") + _, buildReport := report.NewProjectWithReport(project).BuildWithReport(ctx, reportPath, &werf.WithReportOptions{ + CommonOptions: werf.CommonOptions{ExtraArgs: []string{"--cache-repo", cacheRepo}}, + }) + record, found := buildReport.Images["app"] + Expect(found).To(BeTrue()) + + out := project.AttestGet(ctx, &werf.AttestGetOptions{CommonOptions: werf.CommonOptions{ + ExtraArgs: []string{"--type", "openvex", "--repo", cacheRepo, "--digest", record.DockerImageDigest}, + }}) + Expect(out).To(ContainSubstring("CVE-2024-E2E001")) + }) + + It("continues successfully when the cache repository is unavailable", func(ctx SpecContext) { + setupVexEnv("vanilla-docker") + SuiteData.InitTestRepo(ctx, "repo_vex_unavailable_cache", "simple") + testRepoPath := SuiteData.GetTestRepoPath("repo_vex_unavailable_cache") + project := werf.NewProject(SuiteData.WerfBinPath, testRepoPath) + + out, err := project.BuildWithErr(ctx, &werf.BuildOptions{CommonOptions: werf.CommonOptions{ + ExtraArgs: []string{"--cache-repo", "127.0.0.1:1/unreachable/cache"}, + }}) + Expect(err).NotTo(HaveOccurred(), out) + }) + + It("fails when the final VEX repository is unavailable", func(ctx SpecContext) { + setupVexEnv("vanilla-docker") + SuiteData.InitTestRepo(ctx, "repo_vex_unavailable_final", "simple") + testRepoPath := SuiteData.GetTestRepoPath("repo_vex_unavailable_final") + project := werf.NewProject(SuiteData.WerfBinPath, testRepoPath) + + out, err := project.BuildWithErr(ctx, &werf.BuildOptions{CommonOptions: werf.CommonOptions{ + ExtraArgs: []string{"--final-repo", "127.0.0.1:1/unreachable/final"}, + }}) + Expect(err).To(HaveOccurred()) + Expect(out).To(ContainSubstring("unable to init storage manager cache")) + Expect(out).To(ContainSubstring("127.0.0.1:1/unreachable/final")) + }) + + It("rejects a secondary image whose VEX fallback index is missing", func(ctx SpecContext) { + setupVexEnv("vanilla-docker") + secondaryRepo := suite_init.TestRepo(SuiteData.ProjectName + "-secondary") + primaryRepo := suite_init.TestRepo(SuiteData.ProjectName + "-restored") + SuiteData.InitTestRepo(ctx, "repo_vex_missing_secondary_artifact", "simple") + testRepoPath := SuiteData.GetTestRepoPath("repo_vex_missing_secondary_artifact") + project := werf.NewProject(SuiteData.WerfBinPath, testRepoPath) + reportPath := filepath.Join(SuiteData.TmpDir, "vex_secondary_source.json") + _, buildReport := report.NewProjectWithReport(project).BuildWithReport(ctx, reportPath, &werf.WithReportOptions{ + CommonOptions: werf.CommonOptions{ExtraArgs: []string{"--repo", secondaryRepo}}, + }) + record, found := buildReport.Images["app"] + Expect(found).To(BeTrue()) + + ref, err := name.NewTag(secondaryRepo+":"+artifact.FallbackTag(record.DockerImageDigest), name.Insecure) + Expect(err).NotTo(HaveOccurred()) + fallbackDesc, err := remote.Get(ref, remote.WithContext(ctx), remote.WithAuth(authn.Anonymous)) + Expect(err).NotTo(HaveOccurred()) + fallbackDigest, err := name.NewDigest(secondaryRepo+"@"+fallbackDesc.Digest.String(), name.Insecure) + Expect(err).NotTo(HaveOccurred()) + Expect(remote.Delete(fallbackDigest, remote.WithAuth(authn.Anonymous))).To(Succeed()) + + out, err := project.BuildWithErr(ctx, &werf.BuildOptions{CommonOptions: werf.CommonOptions{ + ExtraArgs: []string{"--repo", primaryRepo, "--secondary-repo", secondaryRepo}, + }}) + Expect(err).To(HaveOccurred()) + Expect(out).To(ContainSubstring("has incomplete artifacts")) + }) + + It("rejects VEX generation without a registry before image work", func(ctx SpecContext) { + setupVexEnv("vanilla-docker") + SuiteData.Stubs.UnsetEnv("WERF_REPO") + SuiteData.Stubs.UnsetEnv("WERF_FINAL_REPO") + + SuiteData.InitTestRepo(ctx, "repo_vex_local_only", "simple") + testRepoPath := SuiteData.GetTestRepoPath("repo_vex_local_only") + project := werf.NewProject(SuiteData.WerfBinPath, testRepoPath) + + out, err := project.BuildWithErr(ctx, nil) + + Expect(err).To(HaveOccurred()) + Expect(out).To(ContainSubstring("requires a container registry")) + Expect(out).NotTo(ContainSubstring("Building stage")) + }) +}) + var _ = Describe("VEX lifecycle", Label("e2e", "VEX", "lifecycle", "simple"), func() { DescribeTable("US1: publish VEX artifact during build", Label("publish"), diff --git a/test/mock/bom_patcher.go b/test/mock/bom_patcher.go index ab1c67cc48..ca9e66ae2c 100644 --- a/test/mock/bom_patcher.go +++ b/test/mock/bom_patcher.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: sbom_step.go +// Source: sbom_processor.go // // Generated by this command: // -// mockgen -source sbom_step.go -package mock -destination ../../test/mock/bom_patcher.go -mock_names BOMPatcherInterface=MockBOMPatcher +// mockgen -source sbom_processor.go -package mock -destination ../../test/mock/bom_patcher.go -mock_names BOMPatcherInterface=MockBOMPatcher // // Package mock is a generated GoMock package. diff --git a/test/mock/stages_storage.go b/test/mock/stages_storage.go index e63e986bcf..cce13768c3 100644 --- a/test/mock/stages_storage.go +++ b/test/mock/stages_storage.go @@ -14,6 +14,7 @@ import ( reflect "reflect" v1 "github.com/google/go-containerregistry/pkg/v1" + attestation "github.com/werf/werf/v2/pkg/attestation" container_backend "github.com/werf/werf/v2/pkg/container_backend" image "github.com/werf/werf/v2/pkg/image" storage "github.com/werf/werf/v2/pkg/storage" @@ -114,6 +115,20 @@ func (mr *MockStagesStorageMockRecorder) ConstructStageImageName(projectName, di return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConstructStageImageName", reflect.TypeOf((*MockStagesStorage)(nil).ConstructStageImageName), projectName, digest, creationTs) } +// CopyAttachedArtifacts mocks base method. +func (m *MockStagesStorage) CopyAttachedArtifacts(ctx context.Context, sourceRepository, sourceDigest, destinationRepository, destinationDigest string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CopyAttachedArtifacts", ctx, sourceRepository, sourceDigest, destinationRepository, destinationDigest) + ret0, _ := ret[0].(error) + return ret0 +} + +// CopyAttachedArtifacts indicates an expected call of CopyAttachedArtifacts. +func (mr *MockStagesStorageMockRecorder) CopyAttachedArtifacts(ctx, sourceRepository, sourceDigest, destinationRepository, destinationDigest any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CopyAttachedArtifacts", reflect.TypeOf((*MockStagesStorage)(nil).CopyAttachedArtifacts), ctx, sourceRepository, sourceDigest, destinationRepository, destinationDigest) +} + // CopyFromStorage mocks base method. func (m *MockStagesStorage) CopyFromStorage(ctx context.Context, src storage.StagesStorage, projectName string, stageID image.StageID, opts storage.CopyFromStorageOptions) (*image.StageDesc, error) { m.ctrl.T.Helper() @@ -270,6 +285,22 @@ func (mr *MockStagesStorageMockRecorder) FilterStageDescSetAndProcessRelatedData return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FilterStageDescSetAndProcessRelatedData", reflect.TypeOf((*MockStagesStorage)(nil).FilterStageDescSetAndProcessRelatedData), ctx, stageDescSet, options) } +// FindAttachedArtifact mocks base method. +func (m *MockStagesStorage) FindAttachedArtifact(ctx context.Context, parentDigest, imageName string, kind attestation.PredicateKind) (v1.Descriptor, bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindAttachedArtifact", ctx, parentDigest, imageName, kind) + ret0, _ := ret[0].(v1.Descriptor) + ret1, _ := ret[1].(bool) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// FindAttachedArtifact indicates an expected call of FindAttachedArtifact. +func (mr *MockStagesStorageMockRecorder) FindAttachedArtifact(ctx, parentDigest, imageName, kind any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindAttachedArtifact", reflect.TypeOf((*MockStagesStorage)(nil).FindAttachedArtifact), ctx, parentDigest, imageName, kind) +} + // GetAllAndGroupImageMetadataByImageName mocks base method. func (m *MockStagesStorage) GetAllAndGroupImageMetadataByImageName(ctx context.Context, projectName string, imageNameOrManagedImageList []string, opts ...storage.Option) (map[string]map[string][]string, map[string]map[string][]string, error) { m.ctrl.T.Helper() @@ -461,6 +492,21 @@ func (mr *MockStagesStorageMockRecorder) IsManagedImageExist(ctx, projectName, i return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsManagedImageExist", reflect.TypeOf((*MockStagesStorage)(nil).IsManagedImageExist), varargs...) } +// ListAttachedArtifacts mocks base method. +func (m *MockStagesStorage) ListAttachedArtifacts(ctx context.Context, parentDigest string) ([]v1.Descriptor, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAttachedArtifacts", ctx, parentDigest) + ret0, _ := ret[0].([]v1.Descriptor) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAttachedArtifacts indicates an expected call of ListAttachedArtifacts. +func (mr *MockStagesStorageMockRecorder) ListAttachedArtifacts(ctx, parentDigest any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAttachedArtifacts", reflect.TypeOf((*MockStagesStorage)(nil).ListAttachedArtifacts), ctx, parentDigest) +} + // MutateAndPushImage mocks base method. func (m *MockStagesStorage) MutateAndPushImage(ctx context.Context, src, dest string, newConfig image.SpecConfig, stageImage container_backend.LegacyImageInterface) error { m.ctrl.T.Helper() @@ -517,6 +563,34 @@ func (mr *MockStagesStorageMockRecorder) PostMultiplatformImage(ctx, projectName return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostMultiplatformImage", reflect.TypeOf((*MockStagesStorage)(nil).PostMultiplatformImage), ctx, projectName, tag, allPlatformsImages, platforms) } +// PublishArtifact mocks base method. +func (m *MockStagesStorage) PublishArtifact(ctx context.Context, parentDigest, artifactType string, payload []byte, imageName, checksum, targetPlatform, predicateType string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PublishArtifact", ctx, parentDigest, artifactType, payload, imageName, checksum, targetPlatform, predicateType) + ret0, _ := ret[0].(error) + return ret0 +} + +// PublishArtifact indicates an expected call of PublishArtifact. +func (mr *MockStagesStorageMockRecorder) PublishArtifact(ctx, parentDigest, artifactType, payload, imageName, checksum, targetPlatform, predicateType any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishArtifact", reflect.TypeOf((*MockStagesStorage)(nil).PublishArtifact), ctx, parentDigest, artifactType, payload, imageName, checksum, targetPlatform, predicateType) +} + +// PublishAttestation mocks base method. +func (m *MockStagesStorage) PublishAttestation(ctx context.Context, kind attestation.PredicateKind, payload []byte, parentDigest, imageName string, options attestation.PublishAttestationOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PublishAttestation", ctx, kind, payload, parentDigest, imageName, options) + ret0, _ := ret[0].(error) + return ret0 +} + +// PublishAttestation indicates an expected call of PublishAttestation. +func (mr *MockStagesStorageMockRecorder) PublishAttestation(ctx, kind, payload, parentDigest, imageName, options any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishAttestation", reflect.TypeOf((*MockStagesStorage)(nil).PublishAttestation), ctx, kind, payload, parentDigest, imageName, options) +} + // PutImageMetadata mocks base method. func (m *MockStagesStorage) PutImageMetadata(ctx context.Context, projectName, imageNameOrManagedImageName, commit, stageID string) error { m.ctrl.T.Helper()