diff --git a/cmd/image-processing/main.go b/cmd/image-processing/main.go index 9f6c804b8..96addf309 100644 --- a/cmd/image-processing/main.go +++ b/cmd/image-processing/main.go @@ -54,6 +54,12 @@ type settings struct { secretPath string vulnerabilitySettings resources.VulnerablilityScanParams vulnerabilityCountLimit int + + pushSourceBundle string + sourceBundleImage string + + assembleIndex bool + platformImages []string } var flagValues settings @@ -81,6 +87,12 @@ func initializeFlag() { pflag.StringVar(&flagValues.resultFileImageVulnerabilities, "result-file-image-vulnerabilities", "", "A file to write the image vulnerabilities to") pflag.Var(&flagValues.vulnerabilitySettings, "vuln-settings", "Vulnerability settings json string. One can enable the scan by setting {\"enabled\":true} to this option") pflag.IntVar(&flagValues.vulnerabilityCountLimit, "vuln-count-limit", 50, "vulnerability count limit for the output of vulnerability scan") + + pflag.StringVar(&flagValues.pushSourceBundle, "push-source-bundle", "", "Package this directory as an OCI artifact and push it") + pflag.StringVar(&flagValues.sourceBundleImage, "source-bundle-image", "", "Registry reference for the source bundle OCI artifact") + + pflag.BoolVar(&flagValues.assembleIndex, "assemble-index", false, "Assemble an OCI image index from per-platform images") + pflag.StringArrayVar(&flagValues.platformImages, "platform-image", nil, "Per-platform image reference in os/arch=ref@digest format (repeatable)") } func main() { @@ -128,6 +140,14 @@ func Execute(ctx context.Context) error { flagValues.imageTimestamp = string(data) } + if flagValues.pushSourceBundle != "" { + return runSourceBundlePush(ctx) + } + + if flagValues.assembleIndex { + return runAssembleIndex(ctx) + } + return runImageProcessing(ctx) } @@ -302,3 +322,122 @@ func serializeVulnerabilities(Vulnerabilities []buildapi.Vulnerability) []byte { } return []byte(strings.Join(output, ",")) } + +func runSourceBundlePush(ctx context.Context) error { + if flagValues.sourceBundleImage == "" { + return &ExitError{Code: 100, Message: "the 'source-bundle-image' argument is required when using 'push-source-bundle'"} + } + + bundleRef, err := name.ParseReference(flagValues.sourceBundleImage) + if err != nil { + return fmt.Errorf("failed to parse source bundle image reference: %w", err) + } + + log.Printf("Bundling source directory %q as OCI artifact\n", flagValues.pushSourceBundle) + img, err := image.BundleSourceDirectory(flagValues.pushSourceBundle) + if err != nil { + return fmt.Errorf("failed to bundle source directory: %w", err) + } + + options, _, err := image.GetOptions(ctx, bundleRef, flagValues.insecure, flagValues.secretPath, "Shipwright Build") + if err != nil { + return fmt.Errorf("failed to get registry options: %w", err) + } + + log.Printf("Pushing source bundle to %q\n", bundleRef.String()) + digest, _, err := image.PushImageOrImageIndex(bundleRef, img, nil, options) + if err != nil { + return fmt.Errorf("failed to push source bundle: %w", err) + } + log.Printf("Source bundle %s@%s pushed\n", bundleRef.String(), digest) + + if flagValues.resultFileImageDigest != "" { + if err := os.WriteFile(flagValues.resultFileImageDigest, []byte(digest), 0400); err != nil { + return err + } + } + + return nil +} + +func runAssembleIndex(ctx context.Context) error { + if flagValues.image == "" { + return &ExitError{Code: 100, Message: "the 'image' argument is required when using 'assemble-index'"} + } + if len(flagValues.platformImages) == 0 { + return &ExitError{Code: 100, Message: "at least one 'platform-image' argument is required when using 'assemble-index'"} + } + + outputRef, err := name.ParseReference(flagValues.image) + if err != nil { + return fmt.Errorf("failed to parse output image reference: %w", err) + } + + options, _, err := image.GetOptions(ctx, outputRef, flagValues.insecure, flagValues.secretPath, "Shipwright Build") + if err != nil { + return fmt.Errorf("failed to get registry options: %w", err) + } + + platformEntries, err := ParsePlatformImages(flagValues.platformImages) + if err != nil { + return err + } + + log.Printf("Assembling OCI image index for %d platforms\n", len(platformEntries)) + imageIndex, err := image.AssembleImageIndex(platformEntries, options) + if err != nil { + return fmt.Errorf("failed to assemble image index: %w", err) + } + + log.Printf("Pushing image index to %q\n", outputRef.String()) + digest, size, err := image.PushImageOrImageIndex(outputRef, nil, imageIndex, options) + if err != nil { + return fmt.Errorf("failed to push image index: %w", err) + } + log.Printf("Image index %s@%s pushed\n", outputRef.String(), digest) + + if digest != "" && flagValues.resultFileImageDigest != "" { + if err := os.WriteFile(flagValues.resultFileImageDigest, []byte(digest), 0400); err != nil { + return err + } + } + + if size > 0 && flagValues.resultFileImageSize != "" { + if err := os.WriteFile(flagValues.resultFileImageSize, []byte(strconv.FormatInt(size, 10)), 0400); err != nil { + return err + } + } + + return nil +} + +// ParsePlatformImages parses --platform-image flags in the format "os/arch=ref@digest" +func ParsePlatformImages(entries []string) ([]image.PlatformImageEntry, error) { + var result []image.PlatformImageEntry + for _, entry := range entries { + parts := strings.SplitN(entry, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid platform-image format %q, expected os/arch=ref@digest", entry) + } + + platformStr := parts[0] + imageRef := parts[1] + + platformParts := strings.SplitN(platformStr, "/", 2) + if len(platformParts) != 2 { + return nil, fmt.Errorf("invalid platform format %q, expected os/arch", platformStr) + } + + ref, err := name.ParseReference(imageRef) + if err != nil { + return nil, fmt.Errorf("failed to parse image reference %q: %w", imageRef, err) + } + + result = append(result, image.PlatformImageEntry{ + OS: platformParts[0], + Arch: platformParts[1], + ImageRef: ref, + }) + } + return result, nil +} diff --git a/cmd/image-processing/main_test.go b/cmd/image-processing/main_test.go index 7741a0fe8..3ac404c85 100644 --- a/cmd/image-processing/main_test.go +++ b/cmd/image-processing/main_test.go @@ -14,6 +14,7 @@ import ( "path" "strconv" "strings" + "testing" "time" "github.com/google/go-containerregistry/pkg/crane" @@ -491,6 +492,9 @@ var _ = Describe("Image Processing Resource", Ordered, func() { }) It("should run vulnerability scanning on an image that is already pushed by the strategy", func() { + if testing.Short() { + Skip("skipping network-dependent test in -short mode") + } ignoreVulnerabilities := buildapi.IgnoredHigh vulnOptions := &buildapi.VulnerabilityScanOptions{ Enabled: true, @@ -537,3 +541,180 @@ var _ = Describe("Image Processing Resource", Ordered, func() { }) }) }) + +var _ = Describe("Source Bundle Push", func() { + run := func(args ...string) error { + log.SetOutput(GinkgoWriter) + os.Args = append([]string{"tool"}, args...) + tmp := os.Stderr + defer func() { os.Stderr = tmp }() + os.Stderr = nil + return Execute(context.Background()) + } + + AfterEach(func() { + pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ExitOnError) + }) + + It("should bundle a source directory and push it as an OCI artifact", func() { + logLogger := log.Logger{} + logLogger.SetOutput(GinkgoWriter) + s := httptest.NewServer(registry.New(registry.Logger(&logLogger))) + defer s.Close() + u, err := url.Parse(s.URL) + Expect(err).ToNot(HaveOccurred()) + endpoint := u.Host + + srcDir, err := os.MkdirTemp("", "source-bundle-test") + Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(srcDir) + + Expect(os.WriteFile(fmt.Sprintf("%s/main.go", srcDir), []byte("package main"), 0600)).To(Succeed()) + Expect(os.MkdirAll(fmt.Sprintf("%s/pkg", srcDir), 0750)).To(Succeed()) + Expect(os.WriteFile(fmt.Sprintf("%s/pkg/lib.go", srcDir), []byte("package pkg"), 0600)).To(Succeed()) + + imageRef := fmt.Sprintf("%s/test/source-bundle:latest", endpoint) + digestFile, err := os.CreateTemp("", "digest") + Expect(err).ToNot(HaveOccurred()) + defer os.Remove(digestFile.Name()) + + Expect(run( + "--push-source-bundle", srcDir, + "--source-bundle-image", imageRef, + "--insecure", + "--result-file-image-digest", digestFile.Name(), + )).To(Succeed()) + + ref, err := name.ParseReference(imageRef) + Expect(err).ToNot(HaveOccurred()) + + desc, err := remote.Get(ref) + Expect(err).ToNot(HaveOccurred()) + + img, err := desc.Image() + Expect(err).ToNot(HaveOccurred()) + + layers, err := img.Layers() + Expect(err).ToNot(HaveOccurred()) + Expect(layers).To(HaveLen(1)) + + digestData, err := os.ReadFile(digestFile.Name()) + Expect(err).ToNot(HaveOccurred()) + Expect(string(digestData)).To(HavePrefix("sha256:")) + }) + + It("should fail when --source-bundle-image is missing", func() { + srcDir, err := os.MkdirTemp("", "source-bundle-test") + Expect(err).ToNot(HaveOccurred()) + defer os.RemoveAll(srcDir) + + Expect(run( + "--push-source-bundle", srcDir, + )).To(HaveOccurred()) + }) +}) + +var _ = Describe("Assemble Index", func() { + run := func(args ...string) error { + log.SetOutput(GinkgoWriter) + os.Args = append([]string{"tool"}, args...) + tmp := os.Stderr + defer func() { os.Stderr = tmp }() + os.Stderr = nil + return Execute(context.Background()) + } + + AfterEach(func() { + pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ExitOnError) + }) + + It("should assemble an OCI image index from per-platform images", func() { + logLogger := log.Logger{} + logLogger.SetOutput(GinkgoWriter) + s := httptest.NewServer(registry.New(registry.Logger(&logLogger))) + defer s.Close() + u, err := url.Parse(s.URL) + Expect(err).ToNot(HaveOccurred()) + endpoint := u.Host + + amd64Tag := fmt.Sprintf("%s/test/app-linux-amd64:latest", endpoint) + arm64Tag := fmt.Sprintf("%s/test/app-linux-arm64:latest", endpoint) + + amd64Ref, err := name.ParseReference(amd64Tag) + Expect(err).ToNot(HaveOccurred()) + arm64Ref, err := name.ParseReference(arm64Tag) + Expect(err).ToNot(HaveOccurred()) + + Expect(remote.Write(amd64Ref, empty.Image)).To(Succeed()) + Expect(remote.Write(arm64Ref, empty.Image)).To(Succeed()) + + amd64Digest, err := empty.Image.Digest() + Expect(err).ToNot(HaveOccurred()) + arm64Digest, err := empty.Image.Digest() + Expect(err).ToNot(HaveOccurred()) + + outputRef := fmt.Sprintf("%s/test/app:latest", endpoint) + digestFile, err := os.CreateTemp("", "index-digest") + Expect(err).ToNot(HaveOccurred()) + defer os.Remove(digestFile.Name()) + + Expect(run( + "--assemble-index", + "--image", outputRef, + "--insecure", + "--platform-image", fmt.Sprintf("linux/amd64=%s@%s", amd64Tag, amd64Digest), + "--platform-image", fmt.Sprintf("linux/arm64=%s@%s", arm64Tag, arm64Digest), + "--result-file-image-digest", digestFile.Name(), + )).To(Succeed()) + + digestData, err := os.ReadFile(digestFile.Name()) + Expect(err).ToNot(HaveOccurred()) + Expect(string(digestData)).To(HavePrefix("sha256:")) + + ref, err := name.ParseReference(outputRef) + Expect(err).ToNot(HaveOccurred()) + + desc, err := remote.Get(ref) + Expect(err).ToNot(HaveOccurred()) + idx, err := desc.ImageIndex() + Expect(err).ToNot(HaveOccurred()) + + indexManifest, err := idx.IndexManifest() + Expect(err).ToNot(HaveOccurred()) + Expect(indexManifest.Manifests).To(HaveLen(2)) + }) + + It("should fail when --image is missing", func() { + Expect(run( + "--assemble-index", + "--platform-image", "linux/amd64=localhost:5000/test@sha256:abc", + )).To(HaveOccurred()) + }) + + It("should fail when no --platform-image is provided", func() { + Expect(run( + "--assemble-index", + "--image", "localhost:5000/test:latest", + )).To(HaveOccurred()) + }) +}) + +var _ = Describe("ParsePlatformImages", func() { + It("should parse valid platform image entries", func() { + entries, err := ParsePlatformImages([]string{ + "linux/amd64=registry.example.com/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "linux/arm64=registry.example.com/app@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(entries).To(HaveLen(2)) + Expect(entries[0].OS).To(Equal("linux")) + Expect(entries[0].Arch).To(Equal("amd64")) + Expect(entries[1].OS).To(Equal("linux")) + Expect(entries[1].Arch).To(Equal("arm64")) + }) + + It("should fail when the format is invalid", func() { + _, err := ParsePlatformImages([]string{"not-a-valid-entry"}) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/deploy/crds/shipwright.io_buildruns.yaml b/deploy/crds/shipwright.io_buildruns.yaml index 2e582c0ed..ff4725163 100644 --- a/deploy/crds/shipwright.io_buildruns.yaml +++ b/deploy/crds/shipwright.io_buildruns.yaml @@ -15307,6 +15307,10 @@ spec: reason: type: string type: object + manifestDigest: + description: ManifestDigest is the digest of the OCI image index when + the build produces a manifest list. + type: string output: description: Output holds the results emitted from step definition of an output @@ -15334,6 +15338,65 @@ spec: type: object type: array type: object + platformResults: + description: PlatformResults holds per-platform build results when + a multi-arch build is used. + items: + description: PlatformBuildResult holds observed results for one + OS/architecture in a multi-arch build. + properties: + digest: + description: Digest is the pushed image digest for this platform + variant. + type: string + failureMessage: + description: FailureMessage is set when Status is Failed. + type: string + platform: + description: Platform is the OS and CPU architecture this result + applies to. + properties: + arch: + description: Arch is the CPU architecture of the image platform + (e.g. "amd64", "arm64", "s390x", "ppc64le"). + type: string + os: + description: OS is the operating system of the image platform + (e.g. "linux"). + type: string + required: + - arch + - os + type: object + size: + description: Size is the compressed image size for this platform + variant. + format: int64 + type: integer + status: + description: Status is the lifecycle state of this platform's + build. + type: string + vulnerabilities: + description: Vulnerabilities lists vulnerabilities reported + for this platform's image. + items: + description: Vulnerability defines a vulnerability by its + ID and severity + properties: + id: + type: string + severity: + description: VulnerabilitySeverity is an enum for the + possible values for severity of a vulnerability + type: string + type: object + type: array + required: + - platform + - status + type: object + type: array source: description: Source holds the results emitted from the source step properties: diff --git a/docs/build.md b/docs/build.md index ea11698a3..fe57e9fce 100644 --- a/docs/build.md +++ b/docs/build.md @@ -133,6 +133,7 @@ The `Build` definition supports the following fields: - Use string `BuildTimestamp` to set the image timestamp to the timestamp of the build run. - Use any valid UNIX epoch seconds number as a string to set this as the image timestamp. - `spec.output.vulnerabilityScan` to enable a security vulnerability scan for your generated image. Further options in vulnerability scanning are defined [here](#defining-the-vulnerabilityscan) + - `spec.output.platforms` - Optional list of target OS/architecture combinations for the output image. When non-empty, the build controller orchestrates parallel builds per platform and assembles an OCI image index. See [Defining Multi-Architecture Builds](#defining-multi-architecture-builds) for details. - `spec.env` - Specifies additional environment variables that should be passed to the build container. The available variables depend on the tool that is being used by the chosen build strategy. - `spec.retention.atBuildDeletion` - Defines if all related BuildRuns needs to be deleted when deleting the Build. The default is false. - `spec.retention.ttlAfterFailed` - Specifies the duration for which a failed buildrun can exist. @@ -674,6 +675,74 @@ You can verify which labels were added to the output image that is available on docker inspect us.icr.io/source-to-image-build/nodejs-ex | jq ".[].Config.Labels" ``` +### Defining Multi-Architecture Builds + +The optional `platforms` field on `spec.output` configures building container images for one or more OS and CPU architecture combinations. When non-empty, the build controller orchestrates parallel builds for each platform and assembles the results into an [OCI image index](https://github.com/opencontainers/image-spec/blob/main/image-index.md) (manifest list). A single entry can be used to target one platform on clusters with heterogeneous nodes. + +Each platform entry requires: + +- `os` - The operating system (e.g. `linux`). +- `arch` - The CPU architecture (e.g. `amd64`, `arm64`, `s390x`, `ppc64le`). + +**Note**: Multi-arch builds require the PipelineRun executor to be enabled (`BUILDRUN_EXECUTOR=PipelineRun`). Each platform build is scheduled on a node with the matching `kubernetes.io/os` and `kubernetes.io/arch` labels (via per-TaskRun `nodeSelector`). + +**Note**: When `spec.output.platforms` is non-empty, `spec.nodeSelector` must not contain `kubernetes.io/os` or `kubernetes.io/arch` keys, as the build controller manages architecture-specific scheduling. + +Example of a multi-arch build for x86 and ARM: + +```yaml +apiVersion: shipwright.io/v1beta1 +kind: Build +metadata: + name: multiarch-build +spec: + source: + type: Git + git: + url: https://github.com/shipwright-io/sample-go + contextDir: docker-build + strategy: + name: buildah + kind: ClusterBuildStrategy + output: + image: quay.io/example/app:latest + pushSecret: registry-credentials + platforms: + - os: linux + arch: amd64 + - os: linux + arch: arm64 +``` + +Example of a multi-arch build for all supported Linux architectures: + +```yaml +apiVersion: shipwright.io/v1beta1 +kind: Build +metadata: + name: multiarch-all-linux +spec: + source: + type: Git + git: + url: https://github.com/shipwright-io/sample-go + contextDir: docker-build + strategy: + name: buildah + kind: ClusterBuildStrategy + output: + image: quay.io/example/app:latest + platforms: + - os: linux + arch: amd64 + - os: linux + arch: arm64 + - os: linux + arch: s390x + - os: linux + arch: ppc64le +``` + ### Defining Retention Parameters A `Build` resource can specify how long a completed BuildRun can exist and the number of buildruns that have failed or succeeded that should exist. Instead of manually cleaning up old BuildRuns, retention parameters provide an alternate method for cleaning up BuildRuns automatically. diff --git a/docs/buildrun.md b/docs/buildrun.md index bfbceb762..ab971e8d4 100644 --- a/docs/buildrun.md +++ b/docs/buildrun.md @@ -75,6 +75,7 @@ The `BuildRun` definition supports the following fields: - `spec.output.pushSecret` - Reference an existing secret to get access to the container registry. This secret will be added to the service account along with the ones requested by the `Build`. - `spec.output.timestamp` - Overrides the output timestamp configuration of the referenced build to instruct the build to change the output image creation timestamp to the specified value. When omitted, the respective build strategy tool defines the output image timestamp. - `spec.output.vulnerabilityScan` - Overrides the output vulnerabilityScan configuration of the referenced build to run the vulnerability scan for the generated image. + - `spec.output.platforms` - Overrides the target OS/architecture list from the Build. When non-empty, triggers a multi-arch build. See [Defining Multi-Architecture Builds](build.md#defining-multi-architecture-builds) for details. - `spec.env` - Specifies additional environment variables that should be passed to the build container. Overrides any environment variables that are specified in the `Build` resource. The available variables depend on the tool used by the chosen build strategy. - `spec.stepResources` - Allows overriding resource requirements (CPU, memory) for individual steps defined in the `BuildStrategy` or `ClusterBuildStrategy`. If the referenced `Build` also specifies `spec.strategy.stepResources`, the `BuildRun` values take precedence for the same step. See [Defining Step Resources](#defining-step-resources) for more information. - `spec.nodeSelector` - Specifies a selector which must match a node's labels for the build pod to be scheduled on that node. If nodeSelectors are specified in both a `Build` and `BuildRun`, `BuildRun` values take precedence. @@ -563,6 +564,42 @@ status: **Note**: The vulnerability scan will only run if it is specified in the build or buildrun spec. See [Defining the `vulnerabilityScan`](build.md#defining-the-vulnerabilityscan). +#### Multi-Architecture Build Results + +When a multi-arch build completes, the `BuildRun` status includes per-platform results under `.status.platformResults` and the OCI image index digest under `.status.manifestDigest`. The `.status.output.digest` is set to the manifest list digest so that consumers always see the multi-arch image reference. + +```yaml +# [...] +status: + output: + digest: sha256:ab12cd34... # OCI image index digest + manifestDigest: sha256:ab12cd34... + platformResults: + - platform: + os: linux + arch: amd64 + status: Succeeded + digest: sha256:amd64digest... + size: 52428800 + - platform: + os: linux + arch: arm64 + status: Succeeded + digest: sha256:arm64digest... + size: 48234567 +``` + +Each `platformResults` entry reports: + +- `platform` - The OS and architecture this result applies to. +- `status` - One of `Pending`, `Running`, `Succeeded`, or `Failed`. +- `digest` - The pushed image digest for this platform variant. +- `size` - The compressed image size for this platform variant. +- `failureMessage` - Set when `status` is `Failed`. +- `vulnerabilities` - Per-platform vulnerability scan results (when enabled). + +If any platform fails, the overall `BuildRun` is marked as failed. Per-platform details are still available in `platformResults` for debugging. + ### Build Snapshot For every BuildRun controller reconciliation, the `buildSpec` in the status of the `BuildRun` is updated if an existing owned `TaskRun` is present. During this update, a `Build` resource snapshot is generated and embedded into the `status.buildSpec` path of the `BuildRun`. A `buildSpec` is just a copy of the original `Build` spec, from where the `BuildRun` executed a particular image build. The snapshot approach allows developers to see the original `Build` configuration. diff --git a/pkg/apis/build/v1beta1/buildrun_types.go b/pkg/apis/build/v1beta1/buildrun_types.go index b0a411a4b..75efe5ba4 100644 --- a/pkg/apis/build/v1beta1/buildrun_types.go +++ b/pkg/apis/build/v1beta1/buildrun_types.go @@ -233,6 +233,53 @@ type Output struct { Vulnerabilities []Vulnerability `json:"vulnerabilities,omitempty"` } +// PlatformBuildStatus describes the lifecycle state of a single platform build within a multi-arch BuildRun. +type PlatformBuildStatus string + +const ( + // PlatformBuildStatusPending indicates the platform build has not finished yet. + PlatformBuildStatusPending PlatformBuildStatus = "Pending" + // PlatformBuildStatusRunning indicates the platform build is in progress. + PlatformBuildStatusRunning PlatformBuildStatus = "Running" + // PlatformBuildStatusSucceeded indicates the platform build completed successfully. + PlatformBuildStatusSucceeded PlatformBuildStatus = "Succeeded" + // PlatformBuildStatusFailed indicates the platform build failed. + PlatformBuildStatusFailed PlatformBuildStatus = "Failed" +) + +// PlatformBuildResult holds observed results for one OS/architecture in a multi-arch build. +type PlatformBuildResult struct { + // Platform is the OS and CPU architecture this result applies to. + // + // +required + Platform ImagePlatform `json:"platform"` + + // Status is the lifecycle state of this platform's build. + // + // +required + Status PlatformBuildStatus `json:"status"` + + // FailureMessage is set when Status is Failed. + // + // +optional + FailureMessage string `json:"failureMessage,omitempty"` + + // Digest is the pushed image digest for this platform variant. + // + // +optional + Digest string `json:"digest,omitempty"` + + // Size is the compressed image size for this platform variant. + // + // +optional + Size int64 `json:"size,omitempty"` + + // Vulnerabilities lists vulnerabilities reported for this platform's image. + // + // +optional + Vulnerabilities []Vulnerability `json:"vulnerabilities,omitempty"` +} + // BuildRunStatus defines the observed state of BuildRun type BuildRunStatus struct { // Source holds the results emitted from the source step @@ -271,6 +318,16 @@ type BuildRunStatus struct { // +optional BuildSpec *BuildSpec `json:"buildSpec,omitempty"` + // PlatformResults holds per-platform build results when a multi-arch build is used. + // + // +optional + PlatformResults []PlatformBuildResult `json:"platformResults,omitempty"` + + // ManifestDigest is the digest of the OCI image index when the build produces a manifest list. + // + // +optional + ManifestDigest string `json:"manifestDigest,omitempty"` + // FailureDetails contains error details that are collected and surfaced from TaskRun // +optional FailureDetails *FailureDetails `json:"failureDetails,omitempty"` diff --git a/pkg/apis/build/v1beta1/zz_generated.deepcopy.go b/pkg/apis/build/v1beta1/zz_generated.deepcopy.go index 0fae9eab2..cec5b002f 100644 --- a/pkg/apis/build/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/build/v1beta1/zz_generated.deepcopy.go @@ -373,6 +373,13 @@ func (in *BuildRunStatus) DeepCopyInto(out *BuildRunStatus) { *out = new(BuildSpec) (*in).DeepCopyInto(*out) } + if in.PlatformResults != nil { + in, out := &in.PlatformResults, &out.PlatformResults + *out = make([]PlatformBuildResult, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.FailureDetails != nil { in, out := &in.FailureDetails, &out.FailureDetails *out = new(FailureDetails) @@ -850,6 +857,29 @@ func (in *ImagePlatform) DeepCopy() *ImagePlatform { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PlatformBuildResult) DeepCopyInto(out *PlatformBuildResult) { + *out = *in + in.Platform.DeepCopyInto(&out.Platform) + if in.Vulnerabilities != nil { + in, out := &in.Vulnerabilities, &out.Vulnerabilities + *out = make([]Vulnerability, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlatformBuildResult. +func (in *PlatformBuildResult) DeepCopy() *PlatformBuildResult { + if in == nil { + return nil + } + out := new(PlatformBuildResult) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Image) DeepCopyInto(out *Image) { *out = *in diff --git a/pkg/image/bundle.go b/pkg/image/bundle.go new file mode 100644 index 000000000..a07b8d428 --- /dev/null +++ b/pkg/image/bundle.go @@ -0,0 +1,120 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + + containerreg "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/tarball" +) + +// BundleSourceDirectory packages a directory's contents into a single-layer OCI image +// suitable for distributing source code as an OCI artifact. The directory is archived +// as a gzipped tar and appended as a layer to a scratch image. +func BundleSourceDirectory(dir string) (containerreg.Image, error) { + info, err := os.Stat(dir) + if err != nil { + return nil, fmt.Errorf("source directory does not exist: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("%s is not a directory", dir) + } + + var buf bytes.Buffer + if err := createTarGz(&buf, dir); err != nil { + return nil, fmt.Errorf("creating source tarball: %w", err) + } + + compressed := buf.Bytes() + opener := func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(compressed)), nil + } + + layer, err := tarball.LayerFromOpener(opener) + if err != nil { + return nil, fmt.Errorf("creating layer from tarball: %w", err) + } + + img, err := mutate.AppendLayers(empty.Image, layer) + if err != nil { + return nil, fmt.Errorf("appending layer to empty image: %w", err) + } + + return img, nil +} + +func createTarGz(w io.Writer, srcDir string) error { + gzWriter := gzip.NewWriter(w) + defer gzWriter.Close() + + tarWriter := tar.NewWriter(gzWriter) + defer tarWriter.Close() + + return filepath.Walk(srcDir, func(filePath string, info os.FileInfo, walkErr error) error { + return addFileToTar(tarWriter, srcDir, filePath, info, walkErr) + }) +} + +func addFileToTar(tw *tar.Writer, srcDir, filePath string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + + relPath, err := filepath.Rel(srcDir, filePath) + if err != nil { + return err + } + if relPath == "." { + return nil + } + + // Dereference symlinks so the tar only contains TypeDir and TypeReg + // entries, which is required for compatibility with bundle.Unpack(). + if info.Mode()&os.ModeSymlink != 0 { + info, err = os.Stat(filePath) + if err != nil { + return fmt.Errorf("dereferencing symlink %s: %w", filePath, err) + } + } + + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return fmt.Errorf("creating tar header for %s: %w", relPath, err) + } + header.Name = relPath + + if err := tw.WriteHeader(header); err != nil { + return fmt.Errorf("writing tar header for %s: %w", relPath, err) + } + + if info.IsDir() || !info.Mode().IsRegular() { + return nil + } + + return copyFileToTar(tw, filePath, relPath) +} + +func copyFileToTar(tw *tar.Writer, filePath, relPath string) error { + f, err := os.Open(filePath) // #nosec G304 G122 -- filePath is constructed from filepath.Walk on a controller-owned workspace + if err != nil { + return fmt.Errorf("opening %s: %w", filePath, err) + } + defer f.Close() + + if _, err := io.Copy(tw, f); err != nil { + return fmt.Errorf("writing %s to tar: %w", relPath, err) + } + + return nil +} diff --git a/pkg/image/bundle_test.go b/pkg/image/bundle_test.go new file mode 100644 index 000000000..6c3c5a5df --- /dev/null +++ b/pkg/image/bundle_test.go @@ -0,0 +1,132 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package image_test + +import ( + "archive/tar" + "compress/gzip" + "io" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/shipwright-io/build/pkg/image" +) + +var _ = Describe("BundleSourceDirectory", func() { + + var tmpDir string + + BeforeEach(func() { + var err error + tmpDir, err = os.MkdirTemp("", "bundle-test-") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + _ = os.RemoveAll(tmpDir) + }) + }) + + It("creates a valid OCI image from a directory with files", func() { + Expect(os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main\n"), 0600)).To(Succeed()) + Expect(os.Mkdir(filepath.Join(tmpDir, "pkg"), 0750)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(tmpDir, "pkg", "lib.go"), []byte("package pkg\n"), 0600)).To(Succeed()) + + img, err := image.BundleSourceDirectory(tmpDir) + Expect(err).ToNot(HaveOccurred()) + Expect(img).ToNot(BeNil()) + + layers, err := img.Layers() + Expect(err).ToNot(HaveOccurred()) + Expect(layers).To(HaveLen(1)) + + digest, err := img.Digest() + Expect(err).ToNot(HaveOccurred()) + Expect(digest.String()).ToNot(BeEmpty()) + }) + + It("produces a layer whose compressed content is readable after the function returns", func() { + Expect(os.WriteFile(filepath.Join(tmpDir, "hello.txt"), []byte("hello world\n"), 0600)).To(Succeed()) + + img, err := image.BundleSourceDirectory(tmpDir) + Expect(err).ToNot(HaveOccurred()) + + layers, err := img.Layers() + Expect(err).ToNot(HaveOccurred()) + Expect(layers).To(HaveLen(1)) + + rc, err := layers[0].Compressed() + Expect(err).ToNot(HaveOccurred()) + defer rc.Close() + + gzReader, err := gzip.NewReader(rc) + Expect(err).ToNot(HaveOccurred()) + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + var found bool + for { + hdr, err := tarReader.Next() + if err == io.EOF { + break + } + Expect(err).ToNot(HaveOccurred()) + if hdr.Name == "hello.txt" { + found = true + content, err := io.ReadAll(tarReader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(content)).To(Equal("hello world\n")) + } + } + Expect(found).To(BeTrue(), "expected hello.txt in the tar layer") + }) + + It("dereferences symlinks so the tar only contains regular files", func() { + // Create a regular file and a symlink pointing to it + Expect(os.WriteFile(filepath.Join(tmpDir, "target.txt"), []byte("symlink target\n"), 0600)).To(Succeed()) + Expect(os.Symlink(filepath.Join(tmpDir, "target.txt"), filepath.Join(tmpDir, "link.txt"))).To(Succeed()) + + img, err := image.BundleSourceDirectory(tmpDir) + Expect(err).ToNot(HaveOccurred()) + + layers, err := img.Layers() + Expect(err).ToNot(HaveOccurred()) + Expect(layers).To(HaveLen(1)) + + rc, err := layers[0].Compressed() + Expect(err).ToNot(HaveOccurred()) + defer rc.Close() + + gzReader, err := gzip.NewReader(rc) + Expect(err).ToNot(HaveOccurred()) + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + var foundLink bool + for { + hdr, err := tarReader.Next() + if err == io.EOF { + break + } + Expect(err).ToNot(HaveOccurred()) + if hdr.Name == "link.txt" { + foundLink = true + Expect(hdr.Typeflag).To(Equal(byte(tar.TypeReg)), "symlink should be dereferenced to a regular file") + Expect(hdr.Linkname).To(BeEmpty(), "dereferenced entry should have no Linkname") + content, err := io.ReadAll(tarReader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(content)).To(Equal("symlink target\n")) + } + } + Expect(foundLink).To(BeTrue(), "expected link.txt in the tar layer") + }) + + It("returns an error for a non-existent directory", func() { + _, err := image.BundleSourceDirectory("/nonexistent/path") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("source directory does not exist")) + }) +}) diff --git a/pkg/image/index.go b/pkg/image/index.go new file mode 100644 index 000000000..81b70723b --- /dev/null +++ b/pkg/image/index.go @@ -0,0 +1,55 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package image + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + containerreg "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// PlatformImageEntry associates a platform (os/arch) with a specific image reference. +type PlatformImageEntry struct { + OS string + Arch string + ImageRef name.Reference +} + +// AssembleImageIndex creates an OCI image index (manifest list) from a set of +// per-platform images. Each platform image is pulled from the registry, annotated +// with its platform descriptor, and appended to a new empty index. +func AssembleImageIndex(entries []PlatformImageEntry, options []remote.Option) (containerreg.ImageIndex, error) { + if len(entries) == 0 { + return nil, fmt.Errorf("at least one platform image entry is required") + } + + var idx containerreg.ImageIndex = empty.Index + + var addendums []mutate.IndexAddendum + for _, entry := range entries { + img, err := remote.Image(entry.ImageRef, options...) + if err != nil { + return nil, fmt.Errorf("pulling image for %s/%s from %s: %w", entry.OS, entry.Arch, entry.ImageRef.String(), err) + } + + addendums = append(addendums, mutate.IndexAddendum{ + Add: img, + Descriptor: containerreg.Descriptor{ + Platform: &containerreg.Platform{ + OS: entry.OS, + Architecture: entry.Arch, + }, + }, + }) + } + + idx = mutate.AppendManifests(idx, addendums...) + + return idx, nil +} diff --git a/pkg/image/index_test.go b/pkg/image/index_test.go new file mode 100644 index 000000000..c9ee0ef14 --- /dev/null +++ b/pkg/image/index_test.go @@ -0,0 +1,89 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package image_test + +import ( + "fmt" + "io" + "log" + "net/http/httptest" + "strings" + + "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" + + "github.com/shipwright-io/build/pkg/image" +) + +var _ = Describe("AssembleImageIndex", func() { + + var registryHost string + + BeforeEach(func() { + logger := log.New(io.Discard, "", 0) + reg := registry.New(registry.Logger(logger)) + server := httptest.NewServer(reg) + DeferCleanup(func() { + server.Close() + }) + registryHost = strings.ReplaceAll(server.URL, "http://", "") + }) + + It("assembles an image index from two platform images", func() { + amd64Ref, err := name.ParseReference(fmt.Sprintf("%s/test/app-linux-amd64:latest", registryHost)) + Expect(err).ToNot(HaveOccurred()) + arm64Ref, err := name.ParseReference(fmt.Sprintf("%s/test/app-linux-arm64:latest", registryHost)) + Expect(err).ToNot(HaveOccurred()) + + amd64Img, err := random.Image(256, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(remote.Write(amd64Ref, amd64Img)).To(Succeed()) + + arm64Img, err := random.Image(256, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(remote.Write(arm64Ref, arm64Img)).To(Succeed()) + + entries := []image.PlatformImageEntry{ + {OS: "linux", Arch: "amd64", ImageRef: amd64Ref}, + {OS: "linux", Arch: "arm64", ImageRef: arm64Ref}, + } + + idx, err := image.AssembleImageIndex(entries, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).ToNot(BeNil()) + + indexManifest, err := idx.IndexManifest() + Expect(err).ToNot(HaveOccurred()) + Expect(indexManifest.Manifests).To(HaveLen(2)) + + Expect(indexManifest.Manifests[0].Platform.OS).To(Equal("linux")) + Expect(indexManifest.Manifests[0].Platform.Architecture).To(Equal("amd64")) + Expect(indexManifest.Manifests[1].Platform.OS).To(Equal("linux")) + Expect(indexManifest.Manifests[1].Platform.Architecture).To(Equal("arm64")) + }) + + It("returns an error with empty entries", func() { + _, err := image.AssembleImageIndex(nil, nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("at least one platform image entry is required")) + }) + + It("returns an error when a platform image does not exist", func() { + ref, err := name.ParseReference(fmt.Sprintf("%s/test/nonexistent:latest", registryHost)) + Expect(err).ToNot(HaveOccurred()) + + entries := []image.PlatformImageEntry{ + {OS: "linux", Arch: "amd64", ImageRef: ref}, + } + + _, err = image.AssembleImageIndex(entries, nil) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("pulling image")) + }) +}) diff --git a/pkg/reconciler/buildrun/buildrun.go b/pkg/reconciler/buildrun/buildrun.go index dea8874a1..4a44175d0 100644 --- a/pkg/reconciler/buildrun/buildrun.go +++ b/pkg/reconciler/buildrun/buildrun.go @@ -474,6 +474,19 @@ func (r *ReconcileBuildRun) Reconcile(ctx context.Context, request reconcile.Req resources.UpdateBuildRunUsingTaskResults(ctx, buildRun, executorResults, request) } + // For multi-arch builds, extract per-platform results from PipelineRun child TaskRuns + if pipelineWrapper, ok := buildRunner.(*TektonPipelineRunWrapper); ok && pipelineWrapper.PipelineRun != nil { + var platforms []buildapi.ImagePlatform + if buildRun.Spec.Output != nil && len(buildRun.Spec.Output.Platforms) > 0 { + platforms = buildRun.Spec.Output.Platforms + } else if buildRun.Status.BuildSpec != nil && len(buildRun.Status.BuildSpec.Output.Platforms) > 0 { + platforms = buildRun.Status.BuildSpec.Output.Platforms + } + if len(platforms) > 0 { + resources.UpdateBuildRunWithMultiArchResults(ctx, buildRun, pipelineWrapper.PipelineRun, platforms, r.client) + } + } + executorCondition := buildRunner.GetCondition(apis.ConditionSucceeded) if executorCondition != nil { // Update BuildRun status based on the condition using the unified function diff --git a/pkg/reconciler/buildrun/resources/multiarch.go b/pkg/reconciler/buildrun/resources/multiarch.go new file mode 100644 index 000000000..443aae5cb --- /dev/null +++ b/pkg/reconciler/buildrun/resources/multiarch.go @@ -0,0 +1,491 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package resources + +import ( + "fmt" + + "github.com/tektoncd/pipeline/pkg/apis/pipeline/pod" + pipelineapi "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + corev1 "k8s.io/api/core/v1" + + buildapi "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/shipwright-io/build/pkg/config" + "github.com/shipwright-io/build/pkg/reconciler/buildrun/resources/sources" +) + +const sourceBundleTagSuffix = "-src" +const paramSourceTimestamp = "source-timestamp" + +// platformTaskName returns the PipelineTask name for a given platform build. +func platformTaskName(p buildapi.ImagePlatform) string { + return fmt.Sprintf("build-%s-%s", p.OS, p.Arch) +} + +// platformImageTag returns the tag suffix for a per-platform image. +func platformImageTag(p buildapi.ImagePlatform) string { + return fmt.Sprintf("-%s-%s", p.OS, p.Arch) +} + +// sourceBundleImageParam returns the Tekton parameter expression for the +// source bundle OCI artifact reference (base output image + "-src" suffix). +func sourceBundleImageParam() string { + return fmt.Sprintf("$(params.%s-%s)%s", prefixParamsResultsVolumes, paramOutputImage, sourceBundleTagSuffix) +} + +// generateSourceBundlePushStep creates a step that packages the workspace source +// directory as an OCI artifact and pushes it to the registry. If a pushSecret is +// provided, the secret volume is added to the taskSpec and mounted into the step +// so the image-processing binary can authenticate with the registry. +func generateSourceBundlePushStep(cfg *config.Config, taskSpec *pipelineapi.TaskSpec, pushSecret *string) pipelineapi.Step { + args := []string{ + "--push-source-bundle", fmt.Sprintf("$(params.%s-%s)", prefixParamsResultsVolumes, paramSourceRoot), + "--source-bundle-image", sourceBundleImageParam(), + fmt.Sprintf("--insecure=$(params.%s-%s)", prefixParamsResultsVolumes, paramOutputInsecure), + } + + var volumeMounts []corev1.VolumeMount + if pushSecret != nil { + sources.AppendSecretVolume(taskSpec, *pushSecret) + secretMountPath := fmt.Sprintf("/workspace/%s-push-secret", prefixParamsResultsVolumes) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: sources.SanitizeVolumeNameForSecretName(*pushSecret), + MountPath: secretMountPath, + ReadOnly: true, + }) + args = append(args, "--secret-path", secretMountPath) + } + + step := pipelineapi.Step{ + Name: "push-source-bundle", + Image: cfg.ImageProcessingContainerTemplate.Image, + ImagePullPolicy: cfg.ImageProcessingContainerTemplate.ImagePullPolicy, + Command: cfg.ImageProcessingContainerTemplate.Command, + Args: args, + Env: cfg.ImageProcessingContainerTemplate.Env, + ComputeResources: cfg.ImageProcessingContainerTemplate.Resources, + SecurityContext: cfg.ImageProcessingContainerTemplate.SecurityContext, + WorkingDir: cfg.ImageProcessingContainerTemplate.WorkingDir, + VolumeMounts: volumeMounts, + } + sources.SetupHomeAndTmpVolumes(taskSpec, &step) + return step +} + +// createPerPlatformBuildTask generates a PipelineTask that +// pulls source from the OCI artifact, +// runs the build strategy steps, and +// pushes the result with a platform-specific tag suffix. +func createPerPlatformBuildTask( + cfg *config.Config, + build *buildapi.Build, + buildRun *buildapi.BuildRun, + strategy buildapi.BuilderStrategy, + platform buildapi.ImagePlatform, + execCtx *executionContext, +) (pipelineapi.PipelineTask, error) { + taskName := platformTaskName(platform) + taskSpec := createBaseTaskSpec() + + // Add source-bundle-image param for the bundle pull step + taskSpec.Params = append(taskSpec.Params, pipelineapi.ParamSpec{ + Name: "source-bundle-image", + Type: pipelineapi.ParamTypeString, + }) + + // Accept source timestamp from source-acquisition when SourceTimestamp is requested + buildRunOutput := buildRun.Spec.Output + if buildRunOutput == nil { + buildRunOutput = &buildapi.Image{} + } + needsSourceTimestamp := false + if ts := getImageTimestamp(build.Spec.Output, *buildRunOutput); ts != nil && *ts == buildapi.OutputImageSourceTimestamp { + needsSourceTimestamp = true + taskSpec.Params = append(taskSpec.Params, pipelineapi.ParamSpec{ + Name: paramSourceTimestamp, + Type: pipelineapi.ParamTypeString, + }) + } + + // Step 1: Pull source from OCI artifact using the bundle binary. + // When source is an OCI artifact, the pull secret comes from the source + // spec; otherwise it's the output push secret (source bundle is pushed + // to the same registry as the output image). + bundlePullArgs := []string{ + "--image", "$(params.source-bundle-image)", + "--target", fmt.Sprintf("$(params.%s-%s)", prefixParamsResultsVolumes, paramSourceRoot), + } + var bundlePullVolumeMounts []corev1.VolumeMount + pullSecret := build.Spec.Output.PushSecret + if build.Spec.Source != nil && build.Spec.Source.Type == buildapi.OCIArtifactType && build.Spec.Source.OCIArtifact != nil && build.Spec.Source.OCIArtifact.PullSecret != nil { + pullSecret = build.Spec.Source.OCIArtifact.PullSecret + } + if pullSecret != nil { + sources.AppendSecretVolume(taskSpec, *pullSecret) + secretMountPath := fmt.Sprintf("/workspace/%s-pull-secret", prefixParamsResultsVolumes) + bundlePullVolumeMounts = append(bundlePullVolumeMounts, corev1.VolumeMount{ + Name: sources.SanitizeVolumeNameForSecretName(*pullSecret), + MountPath: secretMountPath, + ReadOnly: true, + }) + bundlePullArgs = append(bundlePullArgs, "--secret-path", secretMountPath) + } + bundlePullStep := pipelineapi.Step{ + Name: "pull-source-bundle", + Image: cfg.BundleContainerTemplate.Image, + ImagePullPolicy: cfg.BundleContainerTemplate.ImagePullPolicy, + Command: cfg.BundleContainerTemplate.Command, + Args: bundlePullArgs, + Env: cfg.BundleContainerTemplate.Env, + ComputeResources: cfg.BundleContainerTemplate.Resources, + SecurityContext: cfg.BundleContainerTemplate.SecurityContext, + WorkingDir: cfg.BundleContainerTemplate.WorkingDir, + VolumeMounts: bundlePullVolumeMounts, + } + sources.SetupHomeAndTmpVolumes(taskSpec, &bundlePullStep) + taskSpec.Steps = append(taskSpec.Steps, bundlePullStep) + + // Step 2: Build strategy steps + addStrategyParametersToTaskSpec(taskSpec, strategy.GetParameters()) + volumeMounts, err := applyBuildStrategySteps( + taskSpec, + build, + buildRun, + strategy.GetBuildSteps(), + strategy.GetVolumes(), + execCtx.combinedEnvs, + ) + if err != nil { + return pipelineapi.PipelineTask{}, fmt.Errorf("applying build strategy steps for %s: %w", taskName, err) + } + + if err := generateTaskSpecVolumes( + taskSpec, + volumeMounts, + execCtx.strategyVolumes, + execCtx.buildVolumes, + execCtx.buildRunVolumes, + ); err != nil { + return pipelineapi.PipelineTask{}, fmt.Errorf("generating volumes for %s: %w", taskName, err) + } + + execCtx.hasOutputDirectory = doesTaskSpecReferenceOutputDirectory(taskSpec) + if execCtx.hasOutputDirectory { + prefixedOutputDirectory := fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, paramOutputDirectory) + taskSpec.Params = append(taskSpec.Params, pipelineapi.ParamSpec{ + Name: prefixedOutputDirectory, + Type: pipelineapi.ParamTypeString, + }) + } + + // Step 3: Image processing — push (if output-directory), mutate, and record digest/size + imgProcArgs, err := buildPerPlatformImageProcessingArgs( + cfg, + build, + buildRun, + execCtx.hasOutputDirectory, + ) + if err != nil { + return pipelineapi.PipelineTask{}, fmt.Errorf("building image processing args for %s: %w", taskName, err) + } + + if err := CreateImageProcessingStep( + cfg, + taskSpec, + imgProcArgs, + false, + build.Spec.Output.PushSecret, + ); err != nil { + return pipelineapi.PipelineTask{}, fmt.Errorf("creating image processing step for %s: %w", taskName, err) + } + + // Build the task params: base params + strategy params + source bundle image + params := generateBaseTaskParamReferences() + + // Step 3: Override shp-output-image to the platform-specific tag + for i, p := range params { + if p.Name == fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, paramOutputImage) { + params[i].Value = pipelineapi.ParamValue{ + Type: pipelineapi.ParamTypeString, + StringVal: fmt.Sprintf("$(params.%s-%s)%s", prefixParamsResultsVolumes, paramOutputImage, platformImageTag(platform)), + } + break + } + } + + sourceBundleImage := sourceBundleImageParam() + if build.Spec.Source != nil && build.Spec.Source.Type == buildapi.OCIArtifactType && build.Spec.Source.OCIArtifact != nil { + sourceBundleImage = build.Spec.Source.OCIArtifact.Image + } + params = append(params, pipelineapi.Param{ + Name: "source-bundle-image", + Value: pipelineapi.ParamValue{ + Type: pipelineapi.ParamTypeString, + StringVal: sourceBundleImage, + }, + }) + + for _, strategyParam := range strategy.GetParameters() { + var paramRef string + if strategyParam.Type == buildapi.ParameterTypeArray { + paramRef = fmt.Sprintf("$(params.%s[*])", strategyParam.Name) + } else { + paramRef = fmt.Sprintf("$(params.%s)", strategyParam.Name) + } + params = append(params, pipelineapi.Param{ + Name: strategyParam.Name, + Value: pipelineapi.ParamValue{ + Type: pipelineapi.ParamTypeString, + StringVal: paramRef, + }, + }) + } + + if execCtx.hasOutputDirectory { + prefixedOutputDirectory := fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, paramOutputDirectory) + params = append(params, pipelineapi.Param{ + Name: prefixedOutputDirectory, + Value: pipelineapi.ParamValue{ + Type: pipelineapi.ParamTypeString, + StringVal: fmt.Sprintf("$(params.%s)", prefixedOutputDirectory), + }, + }) + } + + if needsSourceTimestamp { + params = append(params, pipelineapi.Param{ + Name: paramSourceTimestamp, + Value: pipelineapi.ParamValue{ + Type: pipelineapi.ParamTypeString, + StringVal: fmt.Sprintf("$(tasks.source-acquisition.results.%s)", sources.TaskResultName(defaultSourceName, sourceTimestampName)), + }, + }) + } + + // Step 4: Create the pipeline task + pipelineTask := pipelineapi.PipelineTask{ + Name: taskName, + TaskSpec: &pipelineapi.EmbeddedTask{ + TaskSpec: *taskSpec, + }, + Params: params, + Workspaces: []pipelineapi.WorkspacePipelineTaskBinding{ + {Name: workspaceSource, Workspace: workspaceSource}, + {Name: "cache", Workspace: "cache"}, + }, + RunAfter: []string{"source-acquisition"}, + } + + return pipelineTask, nil +} + +// createIndexAssemblyTask generates a PipelineTask that assembles an OCI image +// index from the per-platform build results. +func createIndexAssemblyTask( + cfg *config.Config, + platforms []buildapi.ImagePlatform, + build *buildapi.Build, +) pipelineapi.PipelineTask { + taskSpec := createBaseTaskSpec() + + // The assemble-index task only needs output-image and output-insecure params; + // it doesn't touch source files, so strip source-root, source-context, + // the source workspace, and the size/vulnerabilities results. + outputImageParam := fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, paramOutputImage) + outputInsecureParam := fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, paramOutputInsecure) + trimmedParams := taskSpec.Params[:0] + for _, p := range taskSpec.Params { + if p.Name == outputImageParam || p.Name == outputInsecureParam { + trimmedParams = append(trimmedParams, p) + } + } + taskSpec.Params = trimmedParams + + taskSpec.Workspaces = nil + + digestResult := fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, imageDigestResult) + trimmedResults := taskSpec.Results[:0] + for _, r := range taskSpec.Results { + if r.Name == digestResult { + trimmedResults = append(trimmedResults, r) + } + } + taskSpec.Results = trimmedResults + + var platformImageArgs []string + var runAfter []string + + for _, p := range platforms { + tName := platformTaskName(p) + runAfter = append(runAfter, tName) + + // Reference the per-platform image by its tag + digest from the build task result + platformImageArgs = append(platformImageArgs, + "--platform-image", + fmt.Sprintf("%s/%s=$(params.%s-%s)%s@$(tasks.%s.results.%s-%s)", + p.OS, p.Arch, + prefixParamsResultsVolumes, paramOutputImage, platformImageTag(p), + tName, + prefixParamsResultsVolumes, imageDigestResult, + ), + ) + } + + args := []string{"--assemble-index"} + args = append(args, platformImageArgs...) + args = append(args, + "--image", fmt.Sprintf("$(params.%s-%s)", prefixParamsResultsVolumes, paramOutputImage), + fmt.Sprintf("--insecure=$(params.%s-%s)", prefixParamsResultsVolumes, paramOutputInsecure), + "--result-file-image-digest", fmt.Sprintf("$(results.%s-%s.path)", prefixParamsResultsVolumes, imageDigestResult), + ) + + var secretVolumeMounts []corev1.VolumeMount + if build.Spec.Output.PushSecret != nil { + sources.AppendSecretVolume(taskSpec, *build.Spec.Output.PushSecret) + secretMountPath := fmt.Sprintf("/workspace/%s-push-secret", prefixParamsResultsVolumes) + secretVolumeMounts = append(secretVolumeMounts, corev1.VolumeMount{ + Name: sources.SanitizeVolumeNameForSecretName(*build.Spec.Output.PushSecret), + MountPath: secretMountPath, + ReadOnly: true, + }) + args = append(args, "--secret-path", secretMountPath) + } + + assemblyStep := pipelineapi.Step{ + Name: "assemble-index", + Image: cfg.ImageProcessingContainerTemplate.Image, + ImagePullPolicy: cfg.ImageProcessingContainerTemplate.ImagePullPolicy, + Command: cfg.ImageProcessingContainerTemplate.Command, + Args: args, + Env: cfg.ImageProcessingContainerTemplate.Env, + ComputeResources: cfg.ImageProcessingContainerTemplate.Resources, + SecurityContext: cfg.ImageProcessingContainerTemplate.SecurityContext, + WorkingDir: cfg.ImageProcessingContainerTemplate.WorkingDir, + VolumeMounts: secretVolumeMounts, + } + + sources.SetupHomeAndTmpVolumes(taskSpec, &assemblyStep) + taskSpec.Steps = append(taskSpec.Steps, assemblyStep) + + assemblyParams := []pipelineapi.Param{ + {Name: outputImageParam, Value: pipelineapi.ParamValue{Type: pipelineapi.ParamTypeString, StringVal: fmt.Sprintf("$(params.%s)", outputImageParam)}}, + {Name: outputInsecureParam, Value: pipelineapi.ParamValue{Type: pipelineapi.ParamTypeString, StringVal: fmt.Sprintf("$(params.%s)", outputInsecureParam)}}, + } + + return pipelineapi.PipelineTask{ + Name: "assemble-index", + TaskSpec: &pipelineapi.EmbeddedTask{ + TaskSpec: *taskSpec, + }, + Params: assemblyParams, + RunAfter: runAfter, + } +} + +// buildPerPlatformImageProcessingArgs constructs image-processing arguments +// for a per-platform build task. Unlike the single-arch path, per-platform +// tasks always need the image-processing step to record the digest and size +// as task results — the assemble-index task depends on these. +func buildPerPlatformImageProcessingArgs( + cfg *config.Config, + build *buildapi.Build, + buildRun *buildapi.BuildRun, + hasOutputDirectory bool, +) ([]string, error) { + buildRunOutput := buildRun.Spec.Output + if buildRunOutput == nil { + buildRunOutput = &buildapi.Image{} + } + + // SourceTimestamp is handled via a pipeline parameter from source-acquisition, + // not a same-task result file, so strip it before generating args. + buildOutput := build.Spec.Output + effectiveBROutput := *buildRunOutput + var wantsSourceTimestamp bool + if ts := getImageTimestamp(buildOutput, effectiveBROutput); ts != nil && *ts == buildapi.OutputImageSourceTimestamp { + wantsSourceTimestamp = true + buildOutput.Timestamp = nil + effectiveBROutput.Timestamp = nil + } + + stepArgs, err := BuildImageProcessingArgs( + cfg, + buildRun.CreationTimestamp.Time, + buildOutput, + effectiveBROutput, + hasOutputDirectory, + false, + ) + if err != nil { + return nil, err + } + + if len(stepArgs) == 0 { + stepArgs = buildMinimalImageProcessingArgs(hasOutputDirectory) + } + + if wantsSourceTimestamp { + stepArgs = append(stepArgs, "--image-timestamp", fmt.Sprintf("$(params.%s)", paramSourceTimestamp)) + } + + return stepArgs, nil +} + +// buildMinimalImageProcessingArgs returns the minimum set of args needed for +// the image-processing binary to load (or push from output-dir), record the +// digest and size, and exit. Used when no mutations are configured. +func buildMinimalImageProcessingArgs(hasOutputDirectory bool) []string { + var args []string + if hasOutputDirectory { + args = append(args, "--push", fmt.Sprintf("$(params.%s-%s)", prefixParamsResultsVolumes, paramOutputDirectory)) + } + args = append(args, + "--image", fmt.Sprintf("$(params.%s-%s)", prefixParamsResultsVolumes, paramOutputImage), + fmt.Sprintf("--insecure=$(params.%s-%s)", prefixParamsResultsVolumes, paramOutputInsecure), + "--result-file-image-digest", fmt.Sprintf("$(results.%s-%s.path)", prefixParamsResultsVolumes, imageDigestResult), + "--result-file-image-size", fmt.Sprintf("$(results.%s-%s.path)", prefixParamsResultsVolumes, imageSizeResult), + ) + return args +} + +// generateMultiArchTaskRunSpecs creates per-task PipelineTaskRunSpec entries +// with nodeSelector for each platform, merged with user-provided scheduling. +func generateMultiArchTaskRunSpecs( + platforms []buildapi.ImagePlatform, + baseNodeSelector map[string]string, + baseTolerations []corev1.Toleration, +) []pipelineapi.PipelineTaskRunSpec { + // Build a clean base nodeSelector without os/arch keys + cleanBase := make(map[string]string, len(baseNodeSelector)) + for k, v := range baseNodeSelector { + if k != corev1.LabelOSStable && k != corev1.LabelArchStable { + cleanBase[k] = v + } + } + + var specs []pipelineapi.PipelineTaskRunSpec + for _, p := range platforms { + ns := make(map[string]string, len(cleanBase)+2) + for k, v := range cleanBase { + ns[k] = v + } + ns[corev1.LabelOSStable] = p.OS + ns[corev1.LabelArchStable] = p.Arch + + podTemplate := &pod.PodTemplate{ + NodeSelector: ns, + } + if len(baseTolerations) > 0 { + podTemplate.Tolerations = baseTolerations + } + + specs = append(specs, pipelineapi.PipelineTaskRunSpec{ + PipelineTaskName: platformTaskName(p), + PodTemplate: podTemplate, + }) + } + + return specs +} diff --git a/pkg/reconciler/buildrun/resources/multiarch_test.go b/pkg/reconciler/buildrun/resources/multiarch_test.go new file mode 100644 index 000000000..09b5ebde0 --- /dev/null +++ b/pkg/reconciler/buildrun/resources/multiarch_test.go @@ -0,0 +1,478 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package resources_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + + buildapi "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/shipwright-io/build/pkg/config" + "github.com/shipwright-io/build/pkg/reconciler/buildrun/resources" + test "github.com/shipwright-io/build/test/v1beta1_samples" +) + +var _ = Describe("Multi-Arch PipelineRun Generation", func() { + var ( + cfg *config.Config + build *buildapi.Build + buildRun *buildapi.BuildRun + clusterBuildStrategy *buildapi.ClusterBuildStrategy + serviceAccountName string + ctl test.Catalog + ) + + BeforeEach(func() { + cfg = config.NewDefaultConfig() + serviceAccountName = "test-sa" + + var err error + build, err = ctl.LoadBuildYAML([]byte(test.MinimalBuild)) + Expect(err).ToNot(HaveOccurred()) + + buildRun, err = ctl.LoadBuildRunFromBytes([]byte(test.MinimalBuildRun)) + Expect(err).ToNot(HaveOccurred()) + + clusterBuildStrategy, err = ctl.LoadCBSWithName("noop", []byte(test.ClusterBuildStrategyNoOp)) + Expect(err).ToNot(HaveOccurred()) + }) + + Context("when spec.output.platforms is configured on the Build", func() { + BeforeEach(func() { + build.Spec.Output.Platforms = []buildapi.ImagePlatform{ + {OS: "linux", Arch: "amd64"}, + {OS: "linux", Arch: "arm64"}, + } + }) + + It("generates a PipelineRun with per-platform build tasks and correct ordering", func() { + pr, err := resources.GeneratePipelineRun(cfg, build, buildRun, serviceAccountName, clusterBuildStrategy) + Expect(err).ToNot(HaveOccurred()) + Expect(pr).ToNot(BeNil()) + + tasks := pr.Spec.PipelineSpec.Tasks + taskNames := make([]string, len(tasks)) + for i, t := range tasks { + taskNames[i] = t.Name + } + + Expect(taskNames).To(ContainElement("source-acquisition")) + Expect(taskNames).To(ContainElement("build-linux-amd64")) + Expect(taskNames).To(ContainElement("build-linux-arm64")) + Expect(taskNames).To(ContainElement("assemble-index")) + + for _, task := range tasks { + switch task.Name { + case "build-linux-amd64", "build-linux-arm64": + Expect(task.RunAfter).To(ContainElement("source-acquisition")) + case "assemble-index": + Expect(task.RunAfter).To(ContainElements("build-linux-amd64", "build-linux-arm64")) + } + } + }) + + It("uses EmptyDir workspace bindings for multi-arch", func() { + pr, err := resources.GeneratePipelineRun(cfg, build, buildRun, serviceAccountName, clusterBuildStrategy) + Expect(err).ToNot(HaveOccurred()) + + for _, ws := range pr.Spec.Workspaces { + Expect(ws.EmptyDir).ToNot(BeNil(), "workspace %s should use EmptyDir", ws.Name) + Expect(ws.VolumeClaimTemplate).To(BeNil(), "workspace %s should not use PVC", ws.Name) + } + }) + + It("generates TaskRunSpecs with per-platform nodeSelector matching spec.output.platforms (scheduling contract)", func() { + pr, err := resources.GeneratePipelineRun(cfg, build, buildRun, serviceAccountName, clusterBuildStrategy) + Expect(err).ToNot(HaveOccurred()) + + Expect(pr.Spec.TaskRunSpecs).To(HaveLen(2)) + + want := map[string]struct{ os, arch string }{ + "build-linux-amd64": {os: "linux", arch: "amd64"}, + "build-linux-arm64": {os: "linux", arch: "arm64"}, + } + for _, s := range pr.Spec.TaskRunSpecs { + Expect(s.PodTemplate).ToNot(BeNil()) + ns := s.PodTemplate.NodeSelector + w, ok := want[s.PipelineTaskName] + Expect(ok).To(BeTrue(), "unexpected task %s", s.PipelineTaskName) + Expect(ns[corev1.LabelOSStable]).To(Equal(w.os)) + Expect(ns[corev1.LabelArchStable]).To(Equal(w.arch)) + } + }) + + It("merges non-os/arch nodeSelector from Build into per-platform TaskRunSpecs", func() { + build.Spec.NodeSelector = map[string]string{ + "disktype": "ssd", + } + + pr, err := resources.GeneratePipelineRun(cfg, build, buildRun, serviceAccountName, clusterBuildStrategy) + Expect(err).ToNot(HaveOccurred()) + + for _, s := range pr.Spec.TaskRunSpecs { + Expect(s.PodTemplate.NodeSelector["disktype"]).To(Equal("ssd")) + Expect(s.PodTemplate.NodeSelector[corev1.LabelOSStable]).To(Equal("linux")) + } + }) + + It("propagates ClusterBuildStrategy parameters to each per-platform pipeline task (strategy contract)", func() { + pr, err := resources.GeneratePipelineRun(cfg, build, buildRun, serviceAccountName, clusterBuildStrategy) + Expect(err).ToNot(HaveOccurred()) + + for _, task := range pr.Spec.PipelineSpec.Tasks { + if task.Name != "build-linux-amd64" && task.Name != "build-linux-arm64" { + continue + } + var found bool + for _, p := range task.Params { + if p.Name == "exit-command" { + found = true + Expect(p.Value.StringVal).To(Equal("$(params.exit-command)")) + } + } + Expect(found).To(BeTrue(), "task %s should reference strategy parameter exit-command", task.Name) + } + }) + + It("includes a source bundle push step in source-acquisition", func() { + pr, err := resources.GeneratePipelineRun(cfg, build, buildRun, serviceAccountName, clusterBuildStrategy) + Expect(err).ToNot(HaveOccurred()) + + var found bool + for _, t := range pr.Spec.PipelineSpec.Tasks { + if t.Name == "source-acquisition" { + for _, s := range t.TaskSpec.Steps { + if s.Name == "push-source-bundle" { + found = true + } + } + } + } + Expect(found).To(BeTrue(), "source-acquisition should have push-source-bundle step") + }) + + It("overrides shp-output-image and includes image-processing step in per-platform tasks", func() { + pr, err := resources.GeneratePipelineRun(cfg, build, buildRun, serviceAccountName, clusterBuildStrategy) + Expect(err).ToNot(HaveOccurred()) + + for _, task := range pr.Spec.PipelineSpec.Tasks { + if task.Name != "build-linux-amd64" && task.Name != "build-linux-arm64" { + continue + } + + expectedSuffix := "-linux-amd64" + if task.Name == "build-linux-arm64" { + expectedSuffix = "-linux-arm64" + } + for _, p := range task.Params { + if p.Name == "shp-output-image" { + Expect(p.Value.StringVal).To(ContainSubstring(expectedSuffix)) + } + } + + var hasImageProcessing bool + for _, s := range task.TaskSpec.Steps { + if s.Name == "image-processing" { + hasImageProcessing = true + Expect(s.Args).To(ContainElement("--result-file-image-digest")) + Expect(s.Args).To(ContainElement("--result-file-image-size")) + } + } + Expect(hasImageProcessing).To(BeTrue(), + "per-platform task %s must have an image-processing step", task.Name) + } + }) + + It("mounts push secret on source-bundle push, per-platform pull, and assemble-index when PushSecret is set", func() { + secretName := "registry-creds" + build.Spec.Output.PushSecret = &secretName + + pr, err := resources.GeneratePipelineRun(cfg, build, buildRun, serviceAccountName, clusterBuildStrategy) + Expect(err).ToNot(HaveOccurred()) + + for _, task := range pr.Spec.PipelineSpec.Tasks { + switch task.Name { + case "source-acquisition": + for _, step := range task.TaskSpec.Steps { + if step.Name == "push-source-bundle" { + Expect(step.Args).To(ContainElement("--secret-path"), + "push-source-bundle should have --secret-path arg") + var hasMount bool + for _, vm := range step.VolumeMounts { + if vm.Name == "shp-registry-creds" && vm.ReadOnly { + hasMount = true + } + } + Expect(hasMount).To(BeTrue(), + "push-source-bundle should mount the push secret volume") + } + } + + case "build-linux-amd64", "build-linux-arm64": + for _, step := range task.TaskSpec.Steps { + if step.Name == "pull-source-bundle" { + Expect(step.Args).To(ContainElement("--secret-path"), + "pull-source-bundle in %s should have --secret-path arg", task.Name) + var hasMount bool + for _, vm := range step.VolumeMounts { + if vm.Name == "shp-registry-creds" && vm.ReadOnly { + hasMount = true + } + } + Expect(hasMount).To(BeTrue(), + "pull-source-bundle in %s should mount the push secret volume", task.Name) + } + } + + case "assemble-index": + for _, step := range task.TaskSpec.Steps { + if step.Name == "assemble-index" { + Expect(step.Args).To(ContainElement("--secret-path"), + "assemble-index should have --secret-path arg") + var hasMount bool + for _, vm := range step.VolumeMounts { + if vm.Name == "shp-registry-creds" && vm.ReadOnly { + hasMount = true + } + } + Expect(hasMount).To(BeTrue(), + "assemble-index step should mount the push secret volume") + } + } + } + } + }) + + It("sets up home and tmp volumes on all multi-arch custom steps", func() { + pr, err := resources.GeneratePipelineRun(cfg, build, buildRun, serviceAccountName, clusterBuildStrategy) + Expect(err).ToNot(HaveOccurred()) + + checkStepHasHomeTmp := func(taskName, stepName string) { + var found bool + for _, task := range pr.Spec.PipelineSpec.Tasks { + if task.Name == taskName { + for _, step := range task.TaskSpec.Steps { + if step.Name == stepName { + found = true + var hasHome, hasTmpDir bool + for _, e := range step.Env { + if e.Name == "HOME" && e.Value == "/shp-writable-home" { + hasHome = true + } + if e.Name == "TMPDIR" && e.Value == "/shp-tmp" { + hasTmpDir = true + } + } + Expect(hasHome).To(BeTrue(), + "%s/%s should have HOME=/shp-writable-home", taskName, stepName) + Expect(hasTmpDir).To(BeTrue(), + "%s/%s should have TMPDIR=/shp-tmp", taskName, stepName) + + var hasHomeMnt, hasTmpMnt bool + for _, vm := range step.VolumeMounts { + if vm.MountPath == "/shp-writable-home" { + hasHomeMnt = true + } + if vm.MountPath == "/shp-tmp" { + hasTmpMnt = true + } + } + Expect(hasHomeMnt).To(BeTrue(), + "%s/%s should have volume mount at /shp-writable-home", taskName, stepName) + Expect(hasTmpMnt).To(BeTrue(), + "%s/%s should have volume mount at /shp-tmp", taskName, stepName) + } + } + } + } + Expect(found).To(BeTrue(), "step %s not found in task %s", stepName, taskName) + } + + checkStepHasHomeTmp("source-acquisition", "push-source-bundle") + checkStepHasHomeTmp("build-linux-amd64", "pull-source-bundle") + checkStepHasHomeTmp("build-linux-arm64", "pull-source-bundle") + checkStepHasHomeTmp("assemble-index", "assemble-index") + }) + + It("propagates tolerations to per-platform TaskRunSpecs", func() { + build.Spec.Tolerations = []corev1.Toleration{ + {Key: "dedicated", Value: "build", Effect: corev1.TaintEffectNoSchedule}, + } + + pr, err := resources.GeneratePipelineRun(cfg, build, buildRun, serviceAccountName, clusterBuildStrategy) + Expect(err).ToNot(HaveOccurred()) + + Expect(pr.Spec.TaskRunSpecs).To(HaveLen(2)) + for _, spec := range pr.Spec.TaskRunSpecs { + Expect(spec.PodTemplate).ToNot(BeNil()) + Expect(spec.PodTemplate.Tolerations).To(HaveLen(1)) + Expect(spec.PodTemplate.Tolerations[0].Key).To(Equal("dedicated")) + Expect(spec.PodTemplate.Tolerations[0].Value).To(Equal("build")) + Expect(spec.PodTemplate.Tolerations[0].Effect).To(Equal(corev1.TaintEffectNoSchedule)) + } + }) + + Context("when the strategy defines a SecurityContext", func() { + BeforeEach(func() { + clusterBuildStrategy.Spec.SecurityContext = &buildapi.BuildStrategySecurityContext{ + RunAsUser: 1000, + RunAsGroup: 1000, + } + }) + + It("applies security context volumes to per-platform and assemble-index tasks", func() { + pr, err := resources.GeneratePipelineRun(cfg, build, buildRun, serviceAccountName, clusterBuildStrategy) + Expect(err).ToNot(HaveOccurred()) + + for _, task := range pr.Spec.PipelineSpec.Tasks { + switch task.Name { + case "build-linux-amd64", "build-linux-arm64": + var hasSecCtxVolume bool + for _, v := range task.TaskSpec.Volumes { + if v.Name == "shp-security-context" { + hasSecCtxVolume = true + } + } + Expect(hasSecCtxVolume).To(BeTrue(), + "per-platform task %s should have shp-security-context volume", task.Name) + + for _, step := range task.TaskSpec.Steps { + if step.Name != "step-no-and-op" { + var hasPasswd bool + for _, vm := range step.VolumeMounts { + if vm.MountPath == "/etc/passwd" && vm.Name == "shp-security-context" { + hasPasswd = true + } + } + Expect(hasPasswd).To(BeTrue(), + "non-strategy step %s in %s should have /etc/passwd mount", step.Name, task.Name) + } + } + + case "assemble-index": + var hasSecCtxVolume bool + for _, v := range task.TaskSpec.Volumes { + if v.Name == "shp-security-context" { + hasSecCtxVolume = true + } + } + Expect(hasSecCtxVolume).To(BeTrue(), + "assemble-index should have shp-security-context volume") + + for _, step := range task.TaskSpec.Steps { + var hasPasswd bool + for _, vm := range step.VolumeMounts { + if vm.MountPath == "/etc/passwd" && vm.Name == "shp-security-context" { + hasPasswd = true + } + } + Expect(hasPasswd).To(BeTrue(), + "step %s in assemble-index should have /etc/passwd mount", step.Name) + } + } + } + }) + }) + + It("wires SourceTimestamp from source-acquisition to per-platform tasks via pipeline parameter", func() { + sourceTimestamp := buildapi.OutputImageSourceTimestamp + build.Spec.Output.Timestamp = &sourceTimestamp + + pr, err := resources.GeneratePipelineRun(cfg, build, buildRun, serviceAccountName, clusterBuildStrategy) + Expect(err).ToNot(HaveOccurred()) + + for _, task := range pr.Spec.PipelineSpec.Tasks { + if task.Name == "build-linux-amd64" || task.Name == "build-linux-arm64" { + // TaskSpec must declare the source-timestamp param + var hasParam bool + for _, p := range task.TaskSpec.Params { + if p.Name == "source-timestamp" { + hasParam = true + } + } + Expect(hasParam).To(BeTrue(), + "%s TaskSpec should declare source-timestamp param", task.Name) + + // PipelineTask param must reference source-acquisition result + var paramVal string + for _, p := range task.Params { + if p.Name == "source-timestamp" { + paramVal = p.Value.StringVal + } + } + Expect(paramVal).To(ContainSubstring("tasks.source-acquisition.results.shp-source-default-source-timestamp"), + "%s should wire source-timestamp from source-acquisition", task.Name) + + // Image-processing step must use --image-timestamp with the param + for _, step := range task.TaskSpec.Steps { + if step.Name == "image-processing" { + Expect(step.Args).To(ContainElement("--image-timestamp"), + "%s image-processing should have --image-timestamp", task.Name) + Expect(step.Args).To(ContainElement("$(params.source-timestamp)"), + "%s image-processing should reference $(params.source-timestamp)", task.Name) + } + } + } + } + }) + + It("includes vulnerability scanning args in per-platform image-processing when enabled", func() { + build.Spec.Output.VulnerabilityScan = &buildapi.VulnerabilityScanOptions{ + Enabled: true, + } + + pr, err := resources.GeneratePipelineRun(cfg, build, buildRun, serviceAccountName, clusterBuildStrategy) + Expect(err).ToNot(HaveOccurred()) + + for _, task := range pr.Spec.PipelineSpec.Tasks { + if task.Name == "build-linux-amd64" || task.Name == "build-linux-arm64" { + for _, step := range task.TaskSpec.Steps { + if step.Name == "image-processing" { + Expect(step.Args).To(ContainElement("--vuln-settings"), + "per-platform image-processing in %s should have --vuln-settings", task.Name) + Expect(step.Args).To(ContainElement("--result-file-image-vulnerabilities"), + "per-platform image-processing in %s should have --result-file-image-vulnerabilities", task.Name) + } + } + } + } + }) + + Context("when the strategy references output-directory", func() { + BeforeEach(func() { + var err error + clusterBuildStrategy, err = ctl.LoadCBSWithName("crane-pull", []byte(test.ClusterBuildStrategyForVulnerabilityScanning)) + Expect(err).ToNot(HaveOccurred()) + }) + + It("adds output-directory param to pipeline spec and --push to per-platform image-processing", func() { + pr, err := resources.GeneratePipelineRun(cfg, build, buildRun, serviceAccountName, clusterBuildStrategy) + Expect(err).ToNot(HaveOccurred()) + + var found bool + for _, p := range pr.Spec.PipelineSpec.Params { + if p.Name == "shp-output-directory" { + found = true + } + } + Expect(found).To(BeTrue(), + "pipeline spec should have shp-output-directory param when strategy uses it") + + for _, task := range pr.Spec.PipelineSpec.Tasks { + if task.Name == "build-linux-amd64" || task.Name == "build-linux-arm64" { + for _, step := range task.TaskSpec.Steps { + if step.Name == "image-processing" { + Expect(step.Args).To(ContainElement("--push"), + "per-platform image-processing in %s should have --push arg", task.Name) + } + } + } + } + }) + }) + }) + +}) diff --git a/pkg/reconciler/buildrun/resources/pipelinerun_generator.go b/pkg/reconciler/buildrun/resources/pipelinerun_generator.go index a5242ba02..d1ee6e1d0 100644 --- a/pkg/reconciler/buildrun/resources/pipelinerun_generator.go +++ b/pkg/reconciler/buildrun/resources/pipelinerun_generator.go @@ -18,13 +18,15 @@ import ( // PipelineRunGenerator implements BuildRunExecutorGenerator for PipelineRun execution. // -// Each build phase runs as a separate Task: +// For single-arch builds, each build phase runs as a separate Task: // - source-acquisition // - build-strategy // - output-image // -// Tasks communicate via shared workspace (PVC). This enables future extensions like -// parallel multi-arch builds. +// For multi-arch builds (when spec.output.platforms is non-empty), the pipeline fans out: +// - source-acquisition (clone + push source as OCI artifact) +// - build-- per platform (parallel, each with per-task nodeSelector) +// - assemble-index (creates OCI image index from per-platform results) type PipelineRunGenerator struct { cfg *config.Config build *buildapi.Build @@ -53,15 +55,39 @@ func NewPipelineRunGenerator( } } +func (g *PipelineRunGenerator) isMultiArch() bool { + return len(g.effectiveOutputPlatforms()) > 0 +} + +func (g *PipelineRunGenerator) isSourceOCIArtifact() bool { + return g.build.Spec.Source != nil && g.build.Spec.Source.Type == buildapi.OCIArtifactType +} + +// effectiveOutputPlatforms returns merged output platforms: BuildRun spec.output.platforms +// overrides Build when non-empty. +func (g *PipelineRunGenerator) effectiveOutputPlatforms() []buildapi.ImagePlatform { + if g.buildRun.Spec.Output != nil && len(g.buildRun.Spec.Output.Platforms) > 0 { + return g.buildRun.Spec.Output.Platforms + } + return g.build.Spec.Output.Platforms +} + func (g *PipelineRunGenerator) InitializeExecutor() error { pipelineSpec := createBasePipelineSpec() + var workspaces []pipelineapi.WorkspaceBinding + if g.isMultiArch() { + workspaces = generateMultiArchWorkspaceBindings() + } else { + workspaces = generatePipelineWorkspaceBindings() + } + g.pipelineRun = &pipelineapi.PipelineRun{ ObjectMeta: generateTaskRunMetadata(g.build, g.buildRun), Spec: pipelineapi.PipelineRunSpec{ PipelineSpec: pipelineSpec, TaskRunTemplate: generatePipelineTaskRunTemplate(g.serviceAccountName), - Workspaces: generatePipelineWorkspaceBindings(), + Workspaces: workspaces, }, } @@ -71,6 +97,12 @@ func (g *PipelineRunGenerator) InitializeExecutor() error { func (g *PipelineRunGenerator) GenerateSourceAcquisitionPhase(_ *executionContext) error { taskSpec := createBaseTaskSpec() applySourcesToTaskSpec(g.cfg, taskSpec, g.build, g.buildRun) + + if g.isMultiArch() && !g.isSourceOCIArtifact() { + pushStep := generateSourceBundlePushStep(g.cfg, taskSpec, g.build.Spec.Output.PushSecret) + taskSpec.Steps = append(taskSpec.Steps, pushStep) + } + g.applySecurityContextToTaskSpec(taskSpec) pipelineTask := createSourceAcquisitionPipelineTask(taskSpec) @@ -80,6 +112,13 @@ func (g *PipelineRunGenerator) GenerateSourceAcquisitionPhase(_ *executionContex } func (g *PipelineRunGenerator) GenerateBuildStrategyPhase(execCtx *executionContext) error { + if g.isMultiArch() { + return g.generateMultiArchBuildPhase(execCtx) + } + return g.generateSingleArchBuildPhase(execCtx) +} + +func (g *PipelineRunGenerator) generateSingleArchBuildPhase(execCtx *executionContext) error { taskSpec := createBaseTaskSpec() addStrategyParametersToTaskSpec(taskSpec, g.strategy.GetParameters()) @@ -127,7 +166,42 @@ func (g *PipelineRunGenerator) GenerateBuildStrategyPhase(execCtx *executionCont return nil } +func (g *PipelineRunGenerator) generateMultiArchBuildPhase(execCtx *executionContext) error { + platforms := g.effectiveOutputPlatforms() + + addStrategyParametersToPipelineSpec(g.pipelineRun.Spec.PipelineSpec, g.strategy.GetParameters()) + + for _, platform := range platforms { + pipelineTask, err := createPerPlatformBuildTask( + g.cfg, + g.build, + g.buildRun, + g.strategy, + platform, + execCtx, + ) + if err != nil { + return fmt.Errorf("generating build task for %s/%s: %w", platform.OS, platform.Arch, err) + } + g.applySecurityContextToTaskSpec(&pipelineTask.TaskSpec.TaskSpec) + g.pipelineTasks = append(g.pipelineTasks, pipelineTask) + } + + if execCtx.hasOutputDirectory { + addOutputDirectoryParamToPipelineSpec(g.pipelineRun.Spec.PipelineSpec) + } + + return nil +} + func (g *PipelineRunGenerator) GenerateOutputImagePhase(execCtx *executionContext) error { + if g.isMultiArch() { + return g.generateMultiArchOutputPhase() + } + return g.generateSingleArchOutputPhase(execCtx) +} + +func (g *PipelineRunGenerator) generateSingleArchOutputPhase(execCtx *executionContext) error { buildRunOutput := g.buildRun.Spec.Output if buildRunOutput == nil { buildRunOutput = &buildapi.Image{} @@ -179,6 +253,14 @@ func (g *PipelineRunGenerator) GenerateOutputImagePhase(execCtx *executionContex return nil } +func (g *PipelineRunGenerator) generateMultiArchOutputPhase() error { + platforms := g.effectiveOutputPlatforms() + assemblyTask := createIndexAssemblyTask(g.cfg, platforms, g.build) + g.applySecurityContextToTaskSpec(&assemblyTask.TaskSpec.TaskSpec) + g.pipelineTasks = append(g.pipelineTasks, assemblyTask) + return nil +} + func (g *PipelineRunGenerator) ApplyInfrastructureConfiguration() error { nodeSelector := MergeMaps(g.build.Spec.NodeSelector, g.buildRun.Spec.NodeSelector) @@ -222,6 +304,14 @@ func (g *PipelineRunGenerator) ApplyInfrastructureConfiguration() error { } } + if g.isMultiArch() { + g.pipelineRun.Spec.TaskRunSpecs = generateMultiArchTaskRunSpecs( + g.effectiveOutputPlatforms(), + nodeSelector, + tolerations, + ) + } + return nil } diff --git a/pkg/reconciler/buildrun/resources/resource_builders.go b/pkg/reconciler/buildrun/resources/resource_builders.go index 00ae383d5..189ed3653 100644 --- a/pkg/reconciler/buildrun/resources/resource_builders.go +++ b/pkg/reconciler/buildrun/resources/resource_builders.go @@ -591,6 +591,22 @@ func generatePipelineWorkspaceBindings() []pipelineapi.WorkspaceBinding { } } +// generateMultiArchWorkspaceBindings creates EmptyDir workspace bindings for multi-arch +// PipelineRuns. Each TaskRun in the PipelineRun gets its own independent EmptyDir volume, +// which is essential because per-platform tasks run on different nodes. +func generateMultiArchWorkspaceBindings() []pipelineapi.WorkspaceBinding { + return []pipelineapi.WorkspaceBinding{ + { + Name: workspaceSource, + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + { + Name: "cache", + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + } +} + func createSourceAcquisitionPipelineTask(taskSpec *pipelineapi.TaskSpec) pipelineapi.PipelineTask { return pipelineapi.PipelineTask{ Name: "source-acquisition", diff --git a/pkg/reconciler/buildrun/resources/results.go b/pkg/reconciler/buildrun/resources/results.go index 6bb7f0bec..8d7f00392 100644 --- a/pkg/reconciler/buildrun/resources/results.go +++ b/pkg/reconciler/buildrun/resources/results.go @@ -11,6 +11,9 @@ import ( "strings" pipelineapi "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "k8s.io/apimachinery/pkg/types" + "knative.dev/pkg/apis" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" buildapi "github.com/shipwright-io/build/pkg/apis/build/v1beta1" @@ -113,3 +116,113 @@ func getSeverity(sev string) buildapi.VulnerabilitySeverity { return buildapi.Unknown } } + +// UpdateBuildRunWithMultiArchResults extracts per-platform build results and the +// manifest digest from a PipelineRun's child TaskRuns and populates the +// BuildRun's PlatformResults and ManifestDigest status fields. +func UpdateBuildRunWithMultiArchResults( + ctx context.Context, + buildRun *buildapi.BuildRun, + pipelineRun *pipelineapi.PipelineRun, + platforms []buildapi.ImagePlatform, + c client.Client, +) { + if pipelineRun == nil || len(platforms) == 0 { + return + } + + // Build a map from PipelineTaskName -> ChildStatusReference + childRefMap := make(map[string]pipelineapi.ChildStatusReference, len(pipelineRun.Status.ChildReferences)) + for _, ref := range pipelineRun.Status.ChildReferences { + childRefMap[ref.PipelineTaskName] = ref + } + + digestResultName := generateOutputResultName(imageDigestResult) + sizeResultName := generateOutputResultName(imageSizeResult) + vulnResultName := generateOutputResultName(imageVulnerabilities) + + buildRun.Status.PlatformResults = make([]buildapi.PlatformBuildResult, 0, len(platforms)) + + var allVulnerabilities []buildapi.Vulnerability + + for _, p := range platforms { + taskName := platformTaskName(p) + result := buildapi.PlatformBuildResult{ + Platform: p, + Status: buildapi.PlatformBuildStatusPending, + } + + childRef, exists := childRefMap[taskName] + if !exists { + buildRun.Status.PlatformResults = append(buildRun.Status.PlatformResults, result) + continue + } + + taskRun := &pipelineapi.TaskRun{} + if err := c.Get(ctx, types.NamespacedName{ + Namespace: pipelineRun.Namespace, + Name: childRef.Name, + }, taskRun); err != nil { + result.Status = buildapi.PlatformBuildStatusFailed + result.FailureMessage = fmt.Sprintf("failed to fetch TaskRun %s: %v", childRef.Name, err) + buildRun.Status.PlatformResults = append(buildRun.Status.PlatformResults, result) + continue + } + + condition := taskRun.Status.GetCondition(apis.ConditionSucceeded) + switch { + case condition == nil: + result.Status = buildapi.PlatformBuildStatusPending + case condition.IsTrue(): + result.Status = buildapi.PlatformBuildStatusSucceeded + case condition.IsFalse(): + result.Status = buildapi.PlatformBuildStatusFailed + result.FailureMessage = condition.Message + default: + result.Status = buildapi.PlatformBuildStatusRunning + } + + for _, tr := range taskRun.Status.Results { + switch tr.Name { + case digestResultName: + result.Digest = tr.Value.StringVal + case sizeResultName: + if size, err := strconv.ParseInt(tr.Value.StringVal, 10, 64); err == nil { + result.Size = size + } + case vulnResultName: + result.Vulnerabilities = getImageVulnerabilitiesResult(tr) + allVulnerabilities = append(allVulnerabilities, result.Vulnerabilities...) + } + } + + buildRun.Status.PlatformResults = append(buildRun.Status.PlatformResults, result) + } + + // Extract ManifestDigest from the assemble-index task and overwrite + // Output.Digest so the BuildRun reflects the manifest list, not a + // nondeterministic per-platform digest from the flat result aggregation. + // Output.Size is cleared because the flat aggregation picks it from an + // arbitrary per-platform TaskRun; correct per-platform data lives in + // PlatformResults. Output.Vulnerabilities is set to the union of all + // per-platform vulnerabilities. + if assembleRef, ok := childRefMap["assemble-index"]; ok { + taskRun := &pipelineapi.TaskRun{} + if err := c.Get(ctx, types.NamespacedName{ + Namespace: pipelineRun.Namespace, + Name: assembleRef.Name, + }, taskRun); err == nil { + if buildRun.Status.Output == nil { + buildRun.Status.Output = &buildapi.Output{} + } + for _, tr := range taskRun.Status.Results { + if tr.Name == digestResultName { + buildRun.Status.ManifestDigest = tr.Value.StringVal + buildRun.Status.Output.Digest = tr.Value.StringVal + } + } + buildRun.Status.Output.Size = 0 + buildRun.Status.Output.Vulnerabilities = allVulnerabilities + } + } +} diff --git a/pkg/reconciler/buildrun/resources/results_test.go b/pkg/reconciler/buildrun/resources/results_test.go index 6e3c77f5a..7c6cfa10b 100644 --- a/pkg/reconciler/buildrun/resources/results_test.go +++ b/pkg/reconciler/buildrun/resources/results_test.go @@ -6,14 +6,22 @@ package resources_test import ( "context" + "fmt" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" pipelineapi "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "knative.dev/pkg/apis" + duckv1 "knative.dev/pkg/apis/duck/v1" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" buildapi "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/shipwright-io/build/pkg/controller/fakes" "github.com/shipwright-io/build/pkg/reconciler/buildrun/resources" test "github.com/shipwright-io/build/test/v1beta1_samples" ) @@ -224,3 +232,230 @@ var _ = Describe("TaskRun results to BuildRun", func() { }) }) }) + +var _ = Describe("Multi-arch PipelineRun results to BuildRun", func() { + var ( + ctx context.Context + br *buildapi.BuildRun + pr *pipelineapi.PipelineRun + fakeClient *fakes.FakeClient + platforms []buildapi.ImagePlatform + taskRuns map[string]*pipelineapi.TaskRun + ) + + newTaskRunWithResults := func(name string, succeeded corev1.ConditionStatus, digest string, size string, failMsg string, vulns ...string) *pipelineapi.TaskRun { + tr := &pipelineapi.TaskRun{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Status: pipelineapi.TaskRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + { + Type: apis.ConditionSucceeded, + Status: succeeded, + Message: failMsg, + }, + }, + }, + }, + } + if digest != "" { + tr.Status.Results = append(tr.Status.Results, pipelineapi.TaskRunResult{ + Name: "shp-image-digest", + Value: pipelineapi.ParamValue{Type: pipelineapi.ParamTypeString, StringVal: digest}, + }) + } + if size != "" { + tr.Status.Results = append(tr.Status.Results, pipelineapi.TaskRunResult{ + Name: "shp-image-size", + Value: pipelineapi.ParamValue{Type: pipelineapi.ParamTypeString, StringVal: size}, + }) + } + if len(vulns) > 0 && vulns[0] != "" { + tr.Status.Results = append(tr.Status.Results, pipelineapi.TaskRunResult{ + Name: "shp-image-vulnerabilities", + Value: pipelineapi.ParamValue{Type: pipelineapi.ParamTypeString, StringVal: vulns[0]}, + }) + } + return tr + } + + BeforeEach(func() { + ctx = context.Background() + br = &buildapi.BuildRun{ + ObjectMeta: metav1.ObjectMeta{Name: "test-br", Namespace: "default"}, + } + platforms = []buildapi.ImagePlatform{ + {OS: "linux", Arch: "amd64"}, + {OS: "linux", Arch: "arm64"}, + } + taskRuns = make(map[string]*pipelineapi.TaskRun) + + fakeClient = &fakes.FakeClient{} + fakeClient.GetStub = func(_ context.Context, key types.NamespacedName, obj client.Object, _ ...client.GetOption) error { + if tr, ok := taskRuns[key.Name]; ok { + tr.DeepCopyInto(obj.(*pipelineapi.TaskRun)) + return nil + } + return fmt.Errorf("TaskRun %s not found", key.Name) + } + }) + + It("should populate PlatformResults for succeeded builds", func() { + taskRuns["pr-build-linux-amd64"] = newTaskRunWithResults("pr-build-linux-amd64", corev1.ConditionTrue, "sha256:amd64digest", "100", "") + taskRuns["pr-build-linux-arm64"] = newTaskRunWithResults("pr-build-linux-arm64", corev1.ConditionTrue, "sha256:arm64digest", "200", "") + taskRuns["pr-assemble-index"] = newTaskRunWithResults("pr-assemble-index", corev1.ConditionTrue, "sha256:indexdigest", "500", "") + + pr = &pipelineapi.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Name: "pr", Namespace: "default"}, + Status: pipelineapi.PipelineRunStatus{ + PipelineRunStatusFields: pipelineapi.PipelineRunStatusFields{ + ChildReferences: []pipelineapi.ChildStatusReference{ + {TypeMeta: runtime.TypeMeta{Kind: "TaskRun"}, Name: "pr-build-linux-amd64", PipelineTaskName: "build-linux-amd64"}, + {TypeMeta: runtime.TypeMeta{Kind: "TaskRun"}, Name: "pr-build-linux-arm64", PipelineTaskName: "build-linux-arm64"}, + {TypeMeta: runtime.TypeMeta{Kind: "TaskRun"}, Name: "pr-assemble-index", PipelineTaskName: "assemble-index"}, + }, + }, + }, + } + + resources.UpdateBuildRunWithMultiArchResults(ctx, br, pr, platforms, fakeClient) + + Expect(br.Status.PlatformResults).To(HaveLen(2)) + Expect(br.Status.PlatformResults[0].Platform).To(Equal(buildapi.ImagePlatform{OS: "linux", Arch: "amd64"})) + Expect(br.Status.PlatformResults[0].Status).To(Equal(buildapi.PlatformBuildStatusSucceeded)) + Expect(br.Status.PlatformResults[0].Digest).To(Equal("sha256:amd64digest")) + Expect(br.Status.PlatformResults[0].Size).To(Equal(int64(100))) + + Expect(br.Status.PlatformResults[1].Platform).To(Equal(buildapi.ImagePlatform{OS: "linux", Arch: "arm64"})) + Expect(br.Status.PlatformResults[1].Status).To(Equal(buildapi.PlatformBuildStatusSucceeded)) + Expect(br.Status.PlatformResults[1].Digest).To(Equal("sha256:arm64digest")) + Expect(br.Status.PlatformResults[1].Size).To(Equal(int64(200))) + + Expect(br.Status.ManifestDigest).To(Equal("sha256:indexdigest")) + Expect(br.Status.Output).ToNot(BeNil()) + Expect(br.Status.Output.Digest).To(Equal("sha256:indexdigest")) + Expect(br.Status.Output.Size).To(Equal(int64(0))) + Expect(br.Status.Output.Vulnerabilities).To(BeEmpty()) + }) + + It("should populate per-platform vulnerabilities and union them into Output.Vulnerabilities", func() { + taskRuns["pr-build-linux-amd64"] = newTaskRunWithResults("pr-build-linux-amd64", corev1.ConditionTrue, "sha256:amd64digest", "100", "", "CVE-2024-0001:H,CVE-2024-0002:M") + taskRuns["pr-build-linux-arm64"] = newTaskRunWithResults("pr-build-linux-arm64", corev1.ConditionTrue, "sha256:arm64digest", "200", "", "CVE-2024-0003:C") + taskRuns["pr-assemble-index"] = newTaskRunWithResults("pr-assemble-index", corev1.ConditionTrue, "sha256:indexdigest", "", "") + + pr = &pipelineapi.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Name: "pr", Namespace: "default"}, + Status: pipelineapi.PipelineRunStatus{ + PipelineRunStatusFields: pipelineapi.PipelineRunStatusFields{ + ChildReferences: []pipelineapi.ChildStatusReference{ + {TypeMeta: runtime.TypeMeta{Kind: "TaskRun"}, Name: "pr-build-linux-amd64", PipelineTaskName: "build-linux-amd64"}, + {TypeMeta: runtime.TypeMeta{Kind: "TaskRun"}, Name: "pr-build-linux-arm64", PipelineTaskName: "build-linux-arm64"}, + {TypeMeta: runtime.TypeMeta{Kind: "TaskRun"}, Name: "pr-assemble-index", PipelineTaskName: "assemble-index"}, + }, + }, + }, + } + + resources.UpdateBuildRunWithMultiArchResults(ctx, br, pr, platforms, fakeClient) + + Expect(br.Status.PlatformResults[0].Vulnerabilities).To(HaveLen(2)) + Expect(br.Status.PlatformResults[0].Vulnerabilities[0].ID).To(Equal("CVE-2024-0001")) + Expect(br.Status.PlatformResults[0].Vulnerabilities[0].Severity).To(Equal(buildapi.High)) + Expect(br.Status.PlatformResults[0].Vulnerabilities[1].ID).To(Equal("CVE-2024-0002")) + + Expect(br.Status.PlatformResults[1].Vulnerabilities).To(HaveLen(1)) + Expect(br.Status.PlatformResults[1].Vulnerabilities[0].ID).To(Equal("CVE-2024-0003")) + Expect(br.Status.PlatformResults[1].Vulnerabilities[0].Severity).To(Equal(buildapi.Critical)) + + Expect(br.Status.Output.Vulnerabilities).To(HaveLen(3)) + }) + + It("should report failed platform builds", func() { + taskRuns["pr-build-linux-amd64"] = newTaskRunWithResults("pr-build-linux-amd64", corev1.ConditionTrue, "sha256:amd64digest", "100", "") + taskRuns["pr-build-linux-arm64"] = newTaskRunWithResults("pr-build-linux-arm64", corev1.ConditionFalse, "", "", "no arm64 node available") + + pr = &pipelineapi.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Name: "pr", Namespace: "default"}, + Status: pipelineapi.PipelineRunStatus{ + PipelineRunStatusFields: pipelineapi.PipelineRunStatusFields{ + ChildReferences: []pipelineapi.ChildStatusReference{ + {TypeMeta: runtime.TypeMeta{Kind: "TaskRun"}, Name: "pr-build-linux-amd64", PipelineTaskName: "build-linux-amd64"}, + {TypeMeta: runtime.TypeMeta{Kind: "TaskRun"}, Name: "pr-build-linux-arm64", PipelineTaskName: "build-linux-arm64"}, + }, + }, + }, + } + + resources.UpdateBuildRunWithMultiArchResults(ctx, br, pr, platforms, fakeClient) + + Expect(br.Status.PlatformResults).To(HaveLen(2)) + Expect(br.Status.PlatformResults[0].Status).To(Equal(buildapi.PlatformBuildStatusSucceeded)) + Expect(br.Status.PlatformResults[1].Status).To(Equal(buildapi.PlatformBuildStatusFailed)) + Expect(br.Status.PlatformResults[1].FailureMessage).To(ContainSubstring("no arm64 node available")) + }) + + It("should report Pending for platforms with no child TaskRun yet", func() { + pr = &pipelineapi.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Name: "pr", Namespace: "default"}, + Status: pipelineapi.PipelineRunStatus{ + PipelineRunStatusFields: pipelineapi.PipelineRunStatusFields{ + ChildReferences: []pipelineapi.ChildStatusReference{}, + }, + }, + } + + resources.UpdateBuildRunWithMultiArchResults(ctx, br, pr, platforms, fakeClient) + + Expect(br.Status.PlatformResults).To(HaveLen(2)) + Expect(br.Status.PlatformResults[0].Status).To(Equal(buildapi.PlatformBuildStatusPending)) + Expect(br.Status.PlatformResults[1].Status).To(Equal(buildapi.PlatformBuildStatusPending)) + Expect(br.Status.ManifestDigest).To(BeEmpty()) + }) + + It("should report Running for in-progress builds", func() { + runningTR := &pipelineapi.TaskRun{ + ObjectMeta: metav1.ObjectMeta{Name: "pr-build-linux-amd64", Namespace: "default"}, + Status: pipelineapi.TaskRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + { + Type: apis.ConditionSucceeded, + Status: corev1.ConditionUnknown, + }, + }, + }, + }, + } + taskRuns["pr-build-linux-amd64"] = runningTR + + pr = &pipelineapi.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Name: "pr", Namespace: "default"}, + Status: pipelineapi.PipelineRunStatus{ + PipelineRunStatusFields: pipelineapi.PipelineRunStatusFields{ + ChildReferences: []pipelineapi.ChildStatusReference{ + {TypeMeta: runtime.TypeMeta{Kind: "TaskRun"}, Name: "pr-build-linux-amd64", PipelineTaskName: "build-linux-amd64"}, + }, + }, + }, + } + + resources.UpdateBuildRunWithMultiArchResults(ctx, br, pr, platforms, fakeClient) + + Expect(br.Status.PlatformResults).To(HaveLen(2)) + Expect(br.Status.PlatformResults[0].Status).To(Equal(buildapi.PlatformBuildStatusRunning)) + Expect(br.Status.PlatformResults[1].Status).To(Equal(buildapi.PlatformBuildStatusPending)) + }) + + It("should not populate anything for nil PipelineRun or empty platforms", func() { + resources.UpdateBuildRunWithMultiArchResults(ctx, br, nil, platforms, fakeClient) + Expect(br.Status.PlatformResults).To(BeNil()) + Expect(br.Status.ManifestDigest).To(BeEmpty()) + + emptyPR := &pipelineapi.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Name: "pr", Namespace: "default"}, + } + resources.UpdateBuildRunWithMultiArchResults(ctx, br, emptyPR, nil, fakeClient) + Expect(br.Status.PlatformResults).To(BeNil()) + Expect(br.Status.ManifestDigest).To(BeEmpty()) + }) +})