diff --git a/cmd/image-processing/main.go b/cmd/image-processing/main.go index 9f6c804b8..1038003f5 100644 --- a/cmd/image-processing/main.go +++ b/cmd/image-processing/main.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/pflag" buildapi "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/shipwright-io/build/pkg/bundle" "github.com/shipwright-io/build/pkg/image" "github.com/shipwright-io/build/pkg/reconciler/buildrun/resources" ) @@ -54,6 +55,12 @@ type settings struct { secretPath string vulnerabilitySettings resources.VulnerablilityScanParams vulnerabilityCountLimit int + + pushSourceBundle string + sourceBundleImage string + + assembleIndex bool + platformImages []string } var flagValues settings @@ -81,6 +88,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 +141,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 +323,119 @@ 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) + + 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()) + d, err := bundle.PackAndPush(bundleRef, flagValues.pushSourceBundle, options...) + if err != nil { + return fmt.Errorf("failed to push source bundle: %w", err) + } + digest := d.DigestStr() + 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..581a52c3c 100644 --- a/cmd/image-processing/main_test.go +++ b/cmd/image-processing/main_test.go @@ -537,3 +537,184 @@ 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()) + } + + var endpoint string + + BeforeEach(func() { + logLogger := log.Logger{} + logLogger.SetOutput(GinkgoWriter) + s := httptest.NewServer(registry.New(registry.Logger(&logLogger))) + DeferCleanup(s.Close) + u, err := url.Parse(s.URL) + Expect(err).ToNot(HaveOccurred()) + endpoint = u.Host + }) + + AfterEach(func() { + pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ExitOnError) + }) + + It("should assemble an OCI image index from per-platform images", func() { + 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()) + DeferCleanup(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 0d6067ba2..15c706cee 100644 --- a/deploy/crds/shipwright.io_buildruns.yaml +++ b/deploy/crds/shipwright.io_buildruns.yaml @@ -15423,7 +15423,11 @@ spec: of an output properties: digest: - description: Digest holds the digest of output image + description: |- + Digest holds the digest of the built output image. + + For multi-arch builds that publish a manifest list (OCI image index), this is the + digest of that index; per-platform digests are in status.platformResults. type: string size: description: Size holds the compressed size of output image @@ -15445,6 +15449,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..e0731d9bb 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,41 @@ 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`. The manifest-list (OCI image index) digest is recorded in **`.status.output.digest`**, consistent with other multi-platform flows (for example BuildKit or `ko`); per-platform digests remain under `platformResults`. + +```yaml +# [...] +status: + output: + digest: sha256:ab12cd34... # OCI image index digest + 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..62d76d195 100644 --- a/pkg/apis/build/v1beta1/buildrun_types.go +++ b/pkg/apis/build/v1beta1/buildrun_types.go @@ -217,7 +217,10 @@ type Vulnerability struct { // Output holds the information about the container image that the BuildRun built type Output struct { - // Digest holds the digest of output image + // Digest holds the digest of the built output image. + // + // For multi-arch builds that publish a manifest list (OCI image index), this is the + // digest of that index; per-platform digests are in status.platformResults. // // +optional Digest string `json:"digest,omitempty"` @@ -233,6 +236,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 +321,11 @@ 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"` + // 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/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..c41c54d36 --- /dev/null +++ b/pkg/image/index_test.go @@ -0,0 +1,92 @@ +// 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}, + } + + // nil options are intentional: go-containerregistry treats plain-HTTP + // localhost registries as insecure by default when no transport is + // configured, so no remote.WithTransport(insecure) is needed here. + 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..d0d36ad66 100644 --- a/pkg/reconciler/buildrun/buildrun.go +++ b/pkg/reconciler/buildrun/buildrun.go @@ -474,6 +474,20 @@ 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 buildOutputPlatforms []buildapi.ImagePlatform + if buildRun.Status.BuildSpec != nil { + buildOutputPlatforms = buildRun.Status.BuildSpec.Output.Platforms + } + platforms := resources.EffectiveOutputPlatforms(buildRun, buildOutputPlatforms) + if len(platforms) > 0 { + if err := resources.UpdateBuildRunWithMultiArchResults(ctx, buildRun, pipelineWrapper.PipelineRun, platforms, r.client); err != nil { + return reconcile.Result{}, err + } + } + } + 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..f70a57e00 --- /dev/null +++ b/pkg/reconciler/buildrun/resources/multiarch.go @@ -0,0 +1,501 @@ +// Copyright The Shipwright Contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package resources + +import ( + "fmt" + "maps" + "strings" + + "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" + paramSourceTimestamp = "source-timestamp" + paramSourceBundleImage = "source-bundle-image" +) + +// 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) +} + +// effectivePushSecret returns the push secret to use for output image operations. +// BuildRun.spec.output.pushSecret takes precedence over Build.spec.output.pushSecret +// when a BuildRun-level override is provided. +func effectivePushSecret(build *buildapi.Build, buildRun *buildapi.BuildRun) *string { + if buildRun.Spec.Output != nil && buildRun.Spec.Output.PushSecret != nil { + return buildRun.Spec.Output.PushSecret + } + return build.Spec.Output.PushSecret +} + +// 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, bool, error) { + taskName := platformTaskName(platform) + taskSpec := createBaseTaskSpec() + + // Add source-bundle-image param for the bundle pull step + prefixedSourceBundleImage := fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, paramSourceBundleImage) + taskSpec.Params = append(taskSpec.Params, pipelineapi.ParamSpec{ + Name: prefixedSourceBundleImage, + 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", fmt.Sprintf("$(params.%s)", prefixedSourceBundleImage), + "--target", fmt.Sprintf("$(params.%s-%s)", prefixParamsResultsVolumes, paramSourceRoot), + } + var bundlePullVolumeMounts []corev1.VolumeMount + pullSecret := effectivePushSecret(build, buildRun) + 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 + hasOutputDir, err := applyBuildStrategyToTaskSpec(taskSpec, build, buildRun, strategy, execCtx) + if err != nil { + return pipelineapi.PipelineTask{}, false, fmt.Errorf("applying build strategy for %s: %w", taskName, err) + } + + // Step 3: Image processing — push (if output-directory), mutate, and record digest/size + imgProcArgs, err := buildPerPlatformImageProcessingArgs( + cfg, + build, + buildRun, + hasOutputDir, + ) + if err != nil { + return pipelineapi.PipelineTask{}, false, fmt.Errorf("building image processing args for %s: %w", taskName, err) + } + + if err := CreateImageProcessingStep( + cfg, + taskSpec, + imgProcArgs, + false, + effectivePushSecret(build, buildRun), + ); err != nil { + return pipelineapi.PipelineTask{}, false, 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: prefixedSourceBundleImage, + Value: pipelineapi.ParamValue{ + Type: pipelineapi.ParamTypeString, + StringVal: sourceBundleImage, + }, + }) + + params = append(params, strategyParamReferences(strategy.GetParameters())...) + + if hasOutputDir { + 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: workspaceCache, Workspace: workspaceCache}, + }, + RunAfter: []string{"source-acquisition"}, + } + + return pipelineTask, hasOutputDir, 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, + buildRun *buildapi.BuildRun, +) 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 pushSecret := effectivePushSecret(build, buildRun); pushSecret != nil { + sources.AppendSecretVolume(taskSpec, *pushSecret) + secretMountPath := fmt.Sprintf("/workspace/%s-push-secret", prefixParamsResultsVolumes) + secretVolumeMounts = append(secretVolumeMounts, corev1.VolumeMount{ + Name: sources.SanitizeVolumeNameForSecretName(*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. + // Deep-copy the Image structs so MergeMaps inside BuildImageProcessingArgs + // does not mutate the informer-cached Build object's Annotations/Labels maps. + buildOutput := build.Spec.Output + buildOutput.Annotations = cloneStringMap(buildOutput.Annotations) + buildOutput.Labels = cloneStringMap(buildOutput.Labels) + 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 +} + +// EffectiveOutputPlatforms returns the platform list to use for a BuildRun. +// BuildRun.Spec.Output.Platforms overrides the build-level platforms when non-empty. +// buildOutputPlatforms should come from the Build (at generation time) or from +// BuildRun.Status.BuildSpec (at reconciliation time). +func EffectiveOutputPlatforms(buildRun *buildapi.BuildRun, buildOutputPlatforms []buildapi.ImagePlatform) []buildapi.ImagePlatform { + if buildRun.Spec.Output != nil && len(buildRun.Spec.Output.Platforms) > 0 { + return buildRun.Spec.Output.Platforms + } + return buildOutputPlatforms +} + +// validateOutputImageForMultiArch rejects digest-pinned output image references +// for multi-arch builds. +func validateOutputImageForMultiArch(build *buildapi.Build, buildRun *buildapi.BuildRun) error { + image := build.Spec.Output.Image + if buildRun.Spec.Output != nil && buildRun.Spec.Output.Image != "" { + image = buildRun.Spec.Output.Image + } + lastSlash := strings.LastIndex(image, "/") + nameAndTag := image + if lastSlash >= 0 { + nameAndTag = image[lastSlash+1:] + } + if strings.Contains(nameAndTag, "@") { + return fmt.Errorf("multi-arch builds do not support digest-pinned output images (%s); use a tag reference instead", image) + } + return nil +} + +func cloneStringMap(m map[string]string) map[string]string { + if m == nil { + return nil + } + return maps.Clone(m) +} + +// 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 { + var specs []pipelineapi.PipelineTaskRunSpec + for _, p := range platforms { + // Clone the full base selector and overwrite os/arch keys so any + // user-provided os/arch values are replaced by the per-platform values. + ns := maps.Clone(baseNodeSelector) + if ns == nil { + ns = make(map[string]string, 2) + } + 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..6511c9799 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,34 @@ 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 +} + +func (g *PipelineRunGenerator) effectiveOutputPlatforms() []buildapi.ImagePlatform { + return EffectiveOutputPlatforms(g.buildRun, 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 +92,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, effectivePushSecret(g.build, g.buildRun)) + taskSpec.Steps = append(taskSpec.Steps, pushStep) + } + g.applySecurityContextToTaskSpec(taskSpec) pipelineTask := createSourceAcquisitionPipelineTask(taskSpec) @@ -80,41 +107,22 @@ func (g *PipelineRunGenerator) GenerateSourceAcquisitionPhase(_ *executionContex } func (g *PipelineRunGenerator) GenerateBuildStrategyPhase(execCtx *executionContext) error { - taskSpec := createBaseTaskSpec() - addStrategyParametersToTaskSpec(taskSpec, g.strategy.GetParameters()) - - volumeMounts, err := applyBuildStrategySteps( - taskSpec, - g.build, - g.buildRun, - g.strategy.GetBuildSteps(), - g.strategy.GetVolumes(), - execCtx.combinedEnvs, - ) - if err != nil { - return err + if g.isMultiArch() { + return g.generateMultiArchBuildPhase(execCtx) } + return g.generateSingleArchBuildPhase(execCtx) +} - execCtx.volumeMounts = volumeMounts +func (g *PipelineRunGenerator) generateSingleArchBuildPhase(execCtx *executionContext) error { + taskSpec := createBaseTaskSpec() - if err := generateTaskSpecVolumes( - taskSpec, - execCtx.volumeMounts, - execCtx.strategyVolumes, - execCtx.buildVolumes, - execCtx.buildRunVolumes, - ); err != nil { + hasOutputDir, err := applyBuildStrategyToTaskSpec(taskSpec, g.build, g.buildRun, g.strategy, execCtx) + if err != nil { return err } + execCtx.hasOutputDirectory = hasOutputDir - 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, - }) + if hasOutputDir { addOutputDirectoryParamToPipelineSpec(g.pipelineRun.Spec.PipelineSpec) } @@ -127,7 +135,48 @@ func (g *PipelineRunGenerator) GenerateBuildStrategyPhase(execCtx *executionCont return nil } +func (g *PipelineRunGenerator) generateMultiArchBuildPhase(execCtx *executionContext) error { + platforms := g.effectiveOutputPlatforms() + + if err := validateOutputImageForMultiArch(g.build, g.buildRun); err != nil { + return err + } + + addStrategyParametersToPipelineSpec(g.pipelineRun.Spec.PipelineSpec, g.strategy.GetParameters()) + + hasOutputDirectory := false + for _, platform := range platforms { + pipelineTask, hasOutputDir, 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) + } + hasOutputDirectory = hasOutputDirectory || hasOutputDir + g.applySecurityContextToTaskSpec(&pipelineTask.TaskSpec.TaskSpec) + g.pipelineTasks = append(g.pipelineTasks, pipelineTask) + } + + if 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 +228,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.buildRun) + 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 +279,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..6a06733be 100644 --- a/pkg/reconciler/buildrun/resources/resource_builders.go +++ b/pkg/reconciler/buildrun/resources/resource_builders.go @@ -67,13 +67,30 @@ func generateBaseTaskParamReferences() []pipelineapi.Param { } } +// normalizeImageTag ensures the image reference includes an explicit tag. Without an +// explicit tag, per-platform suffix appending (e.g. "-linux-amd64") produces an +// image-name mutation like "myimage-linux-amd64" instead of the intended +// "myimage:latest-linux-amd64". +func normalizeImageTag(image string) string { + lastSlash := strings.LastIndex(image, "/") + nameAndTag := image + if lastSlash >= 0 { + nameAndTag = image[lastSlash+1:] + } + if !strings.Contains(nameAndTag, ":") && !strings.Contains(nameAndTag, "@") { + return image + ":latest" + } + return image +} + func generateBaseParamValues(build *buildapi.Build, buildRun *buildapi.BuildRun) []pipelineapi.Param { var image string - if buildRun.Spec.Output != nil { + if buildRun.Spec.Output != nil && buildRun.Spec.Output.Image != "" { image = buildRun.Spec.Output.Image } else { image = build.Spec.Output.Image } + image = normalizeImageTag(image) insecure := false if buildRun.Spec.Output != nil && buildRun.Spec.Output.Insecure != nil { @@ -434,11 +451,12 @@ func applyAnnotationsAndLabels(taskRun *pipelineapi.TaskRun, strategy buildapi.B func applyParameters(taskRun *pipelineapi.TaskRun, build *buildapi.Build, buildRun *buildapi.BuildRun, strategy buildapi.BuilderStrategy) error { var image string - if buildRun.Spec.Output != nil { + if buildRun.Spec.Output != nil && buildRun.Spec.Output.Image != "" { image = buildRun.Spec.Output.Image } else { image = build.Spec.Output.Image } + image = normalizeImageTag(image) insecure := false if buildRun.Spec.Output != nil && buildRun.Spec.Output.Insecure != nil { @@ -556,7 +574,7 @@ func generateBasePipelineWorkspaceDeclarations() []pipelineapi.PipelineWorkspace Description: "Workspace for source code and build artifacts", }, { - Name: "cache", + Name: workspaceCache, Description: "Cache workspace for build artifacts", Optional: true, }, @@ -585,7 +603,23 @@ func generatePipelineWorkspaceBindings() []pipelineapi.WorkspaceBinding { }, }, { - Name: "cache", + Name: workspaceCache, + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + } +} + +// 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: workspaceCache, EmptyDir: &corev1.EmptyDirVolumeSource{}, }, } @@ -667,17 +701,15 @@ func createBuildStrategyPipelineTask(taskSpec *pipelineapi.TaskSpec, strategy bu return pipelineTask } -func generateBuildStrategyTaskParams(strategy buildapi.BuilderStrategy) []pipelineapi.Param { - params := generateBaseTaskParamReferences() - - for _, strategyParam := range strategy.GetParameters() { +func strategyParamReferences(parameters []buildapi.Parameter) []pipelineapi.Param { + var params []pipelineapi.Param + for _, strategyParam := range parameters { 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{ @@ -686,10 +718,52 @@ func generateBuildStrategyTaskParams(strategy buildapi.BuilderStrategy) []pipeli }, }) } + return params +} +func generateBuildStrategyTaskParams(strategy buildapi.BuilderStrategy) []pipelineapi.Param { + params := generateBaseTaskParamReferences() + params = append(params, strategyParamReferences(strategy.GetParameters())...) return params } +func applyBuildStrategyToTaskSpec( + taskSpec *pipelineapi.TaskSpec, + build *buildapi.Build, + buildRun *buildapi.BuildRun, + strategy buildapi.BuilderStrategy, + execCtx *executionContext, +) (bool, error) { + addStrategyParametersToTaskSpec(taskSpec, strategy.GetParameters()) + + volumeMounts, err := applyBuildStrategySteps( + taskSpec, build, buildRun, + strategy.GetBuildSteps(), strategy.GetVolumes(), + execCtx.combinedEnvs, + ) + if err != nil { + return false, err + } + + if err := generateTaskSpecVolumes( + taskSpec, volumeMounts, + execCtx.strategyVolumes, execCtx.buildVolumes, execCtx.buildRunVolumes, + ); err != nil { + return false, err + } + + hasOutputDir := doesTaskSpecReferenceOutputDirectory(taskSpec) + if hasOutputDir { + prefixedOutputDirectory := fmt.Sprintf("%s-%s", prefixParamsResultsVolumes, paramOutputDirectory) + taskSpec.Params = append(taskSpec.Params, pipelineapi.ParamSpec{ + Name: prefixedOutputDirectory, + Type: pipelineapi.ParamTypeString, + }) + } + + return hasOutputDir, nil +} + func generateBuildStrategyWorkspaceBindings() []pipelineapi.WorkspacePipelineTaskBinding { return []pipelineapi.WorkspacePipelineTaskBinding{ { @@ -697,8 +771,8 @@ func generateBuildStrategyWorkspaceBindings() []pipelineapi.WorkspacePipelineTas Workspace: workspaceSource, }, { - Name: "cache", - Workspace: "cache", + Name: workspaceCache, + Workspace: workspaceCache, }, } } diff --git a/pkg/reconciler/buildrun/resources/results.go b/pkg/reconciler/buildrun/resources/results.go index 6bb7f0bec..7f087df2e 100644 --- a/pkg/reconciler/buildrun/resources/results.go +++ b/pkg/reconciler/buildrun/resources/results.go @@ -10,7 +10,11 @@ import ( "strconv" "strings" + pipeline "github.com/tektoncd/pipeline/pkg/apis/pipeline" pipelineapi "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + "k8s.io/apimachinery/pkg/labels" + "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 +117,125 @@ func getSeverity(sev string) buildapi.VulnerabilitySeverity { return buildapi.Unknown } } + +// applyAssembleIndexOutputDigest sets status.output.digest to the OCI image index digest +// produced by assemble-index (same convention as ko/buildkit multi-platform: output.digest +// refers to the manifest list when applicable). +func applyAssembleIndexOutputDigest(buildRun *buildapi.BuildRun, digest string) { + if buildRun.Status.Output == nil { + buildRun.Status.Output = &buildapi.Output{} + } + buildRun.Status.Output.Digest = digest +} + +// UpdateBuildRunWithMultiArchResults extracts per-platform build results from a PipelineRun's +// child TaskRuns and populates the BuildRun's platformResults status field; the assemble-index +// task supplies the manifest-list digest via status.output.digest. +// A non-nil error is returned for any TaskRun fetch failure that is not a NotFound +func UpdateBuildRunWithMultiArchResults( + ctx context.Context, + buildRun *buildapi.BuildRun, + pipelineRun *pipelineapi.PipelineRun, + platforms []buildapi.ImagePlatform, + c client.Client, +) error { + if pipelineRun == nil || len(platforms) == 0 { + return nil + } + + // Fetch all child TaskRuns in a single List call + var taskRunList pipelineapi.TaskRunList + if err := c.List(ctx, &taskRunList, client.InNamespace(pipelineRun.Namespace), client.MatchingLabelsSelector{ + Selector: labels.SelectorFromSet(labels.Set{ + pipeline.PipelineRunLabelKey: pipelineRun.Name, + }), + }); err != nil { + return fmt.Errorf("listing TaskRuns for PipelineRun %s: %w", pipelineRun.Name, err) + } + + taskRunsByPipelineTask := make(map[string]*pipelineapi.TaskRun, len(taskRunList.Items)) + for i := range taskRunList.Items { + tr := &taskRunList.Items[i] + if ptName, ok := tr.Labels[pipeline.PipelineTaskLabelKey]; ok { + taskRunsByPipelineTask[ptName] = tr + } + } + + digestResultName := generateOutputResultName(imageDigestResult) + sizeResultName := generateOutputResultName(imageSizeResult) + vulnResultName := generateOutputResultName(imageVulnerabilities) + + buildRun.Status.PlatformResults = make([]buildapi.PlatformBuildResult, 0, len(platforms)) + + if buildRun.Status.Output != nil { + buildRun.Status.Output.Digest = "" + buildRun.Status.Output.Size = 0 + buildRun.Status.Output.Vulnerabilities = nil + } + + var allVulnerabilities []buildapi.Vulnerability + allPlatformsTerminal := true + + for _, p := range platforms { + taskName := platformTaskName(p) + result := buildapi.PlatformBuildResult{ + Platform: p, + Status: buildapi.PlatformBuildStatusPending, + } + + taskRun, exists := taskRunsByPipelineTask[taskName] + if !exists { + allPlatformsTerminal = false + buildRun.Status.PlatformResults = append(buildRun.Status.PlatformResults, result) + continue + } + + condition := taskRun.Status.GetCondition(apis.ConditionSucceeded) + switch { + case condition == nil: + result.Status = buildapi.PlatformBuildStatusPending + allPlatformsTerminal = false + case condition.IsTrue(): + result.Status = buildapi.PlatformBuildStatusSucceeded + case condition.IsFalse(): + result.Status = buildapi.PlatformBuildStatusFailed + result.FailureMessage = condition.Message + default: + result.Status = buildapi.PlatformBuildStatusRunning + allPlatformsTerminal = false + } + + 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) + } + + if assembleTaskRun, ok := taskRunsByPipelineTask["assemble-index"]; ok { + if buildRun.Status.Output == nil { + buildRun.Status.Output = &buildapi.Output{} + } + for _, tr := range assembleTaskRun.Status.Results { + if tr.Name == digestResultName { + applyAssembleIndexOutputDigest(buildRun, tr.Value.StringVal) + } + } + buildRun.Status.Output.Size = 0 + if allPlatformsTerminal { + buildRun.Status.Output.Vulnerabilities = allVulnerabilities + } + } + + return nil +} diff --git a/pkg/reconciler/buildrun/resources/results_test.go b/pkg/reconciler/buildrun/resources/results_test.go index 6e3c77f5a..6d89f00eb 100644 --- a/pkg/reconciler/buildrun/resources/results_test.go +++ b/pkg/reconciler/buildrun/resources/results_test.go @@ -9,11 +9,18 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + pipeline "github.com/tektoncd/pipeline/pkg/apis/pipeline" 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/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 +231,203 @@ 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, pipelineTaskName string, succeeded corev1.ConditionStatus, digest string, size string, failMsg string, vulns ...string) *pipelineapi.TaskRun { + tr := &pipelineapi.TaskRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + Labels: map[string]string{ + pipeline.PipelineTaskLabelKey: pipelineTaskName, + }, + }, + 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.ListStub = func(_ context.Context, list client.ObjectList, _ ...client.ListOption) error { + trList := list.(*pipelineapi.TaskRunList) + for _, tr := range taskRuns { + trList.Items = append(trList.Items, *tr) + } + return nil + } + }) + + It("should populate PlatformResults for succeeded builds", func() { + taskRuns["pr-build-linux-amd64"] = newTaskRunWithResults("pr-build-linux-amd64", "build-linux-amd64", corev1.ConditionTrue, "sha256:amd64digest", "100", "") + taskRuns["pr-build-linux-arm64"] = newTaskRunWithResults("pr-build-linux-arm64", "build-linux-arm64", corev1.ConditionTrue, "sha256:arm64digest", "200", "") + taskRuns["pr-assemble-index"] = newTaskRunWithResults("pr-assemble-index", "assemble-index", corev1.ConditionTrue, "sha256:indexdigest", "500", "") + + pr = &pipelineapi.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Name: "pr", Namespace: "default"}, + } + + Expect(resources.UpdateBuildRunWithMultiArchResults(ctx, br, pr, platforms, fakeClient)).To(Succeed()) + + 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.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", "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", "build-linux-arm64", corev1.ConditionTrue, "sha256:arm64digest", "200", "", "CVE-2024-0003:C") + taskRuns["pr-assemble-index"] = newTaskRunWithResults("pr-assemble-index", "assemble-index", corev1.ConditionTrue, "sha256:indexdigest", "", "") + + pr = &pipelineapi.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Name: "pr", Namespace: "default"}, + } + + Expect(resources.UpdateBuildRunWithMultiArchResults(ctx, br, pr, platforms, fakeClient)).To(Succeed()) + + 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", "build-linux-amd64", corev1.ConditionTrue, "sha256:amd64digest", "100", "") + taskRuns["pr-build-linux-arm64"] = newTaskRunWithResults("pr-build-linux-arm64", "build-linux-arm64", corev1.ConditionFalse, "", "", "no arm64 node available") + + pr = &pipelineapi.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Name: "pr", Namespace: "default"}, + } + + Expect(resources.UpdateBuildRunWithMultiArchResults(ctx, br, pr, platforms, fakeClient)).To(Succeed()) + + 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"}, + } + + Expect(resources.UpdateBuildRunWithMultiArchResults(ctx, br, pr, platforms, fakeClient)).To(Succeed()) + + 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.Output).To(BeNil()) + }) + + It("should report Running for in-progress builds", func() { + runningTR := &pipelineapi.TaskRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pr-build-linux-amd64", + Namespace: "default", + Labels: map[string]string{ + pipeline.PipelineTaskLabelKey: "build-linux-amd64", + }, + }, + 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"}, + } + + Expect(resources.UpdateBuildRunWithMultiArchResults(ctx, br, pr, platforms, fakeClient)).To(Succeed()) + + 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() { + Expect(resources.UpdateBuildRunWithMultiArchResults(ctx, br, nil, platforms, fakeClient)).To(Succeed()) + Expect(br.Status.PlatformResults).To(BeNil()) + Expect(br.Status.Output).To(BeNil()) + + emptyPR := &pipelineapi.PipelineRun{ + ObjectMeta: metav1.ObjectMeta{Name: "pr", Namespace: "default"}, + } + Expect(resources.UpdateBuildRunWithMultiArchResults(ctx, br, emptyPR, nil, fakeClient)).To(Succeed()) + Expect(br.Status.PlatformResults).To(BeNil()) + Expect(br.Status.Output).To(BeNil()) + }) +}) diff --git a/pkg/reconciler/buildrun/resources/taskrun.go b/pkg/reconciler/buildrun/resources/taskrun.go index 6ec5b88b7..8c1cb068d 100644 --- a/pkg/reconciler/buildrun/resources/taskrun.go +++ b/pkg/reconciler/buildrun/resources/taskrun.go @@ -23,6 +23,7 @@ const ( paramOutputDirectory = "output-directory" workspaceSource = "source" + workspaceCache = "cache" ) // GenerateTaskRun creates a TaskRun with all build phases as sequential steps. diff --git a/test/e2e/v1beta1/common_suite_test.go b/test/e2e/v1beta1/common_suite_test.go index 2e57ea900..552748af2 100644 --- a/test/e2e/v1beta1/common_suite_test.go +++ b/test/e2e/v1beta1/common_suite_test.go @@ -277,6 +277,13 @@ func (b *buildPrototype) OutputImageInsecure(insecure bool) *buildPrototype { return b } +// OutputImagePlatforms sets spec.output.platforms for multi-arch PipelineRun orchestration +// (per-platform PipelineTasks and assemble-index). Requires BUILDRUN_EXECUTOR=PipelineRun. +func (b *buildPrototype) OutputImagePlatforms(platforms []buildapi.ImagePlatform) *buildPrototype { + b.build.Spec.Output.Platforms = append([]buildapi.ImagePlatform(nil), platforms...) + return b +} + func (b *buildPrototype) OutputTimestamp(timestampString string) *buildPrototype { b.build.Spec.Output.Timestamp = ×tampString return b diff --git a/test/e2e/v1beta1/e2e_pipelinerun_test.go b/test/e2e/v1beta1/e2e_pipelinerun_test.go index edb439ec5..9581623cd 100644 --- a/test/e2e/v1beta1/e2e_pipelinerun_test.go +++ b/test/e2e/v1beta1/e2e_pipelinerun_test.go @@ -7,15 +7,21 @@ package e2e_test import ( "fmt" "os" + "sort" "strconv" + "strings" "github.com/google/go-containerregistry/pkg/name" + crv1 "github.com/google/go-containerregistry/pkg/v1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + tektonpipeline "github.com/tektoncd/pipeline/pkg/apis/pipeline" + pipelineapi "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" buildapi "github.com/shipwright-io/build/pkg/apis/build/v1beta1" + "github.com/shipwright-io/build/pkg/validate" ) var _ = Describe("PipelineRun E2E Tests", Label("PipelineRun", "CORE"), func() { @@ -229,6 +235,108 @@ var _ = Describe("PipelineRun E2E Tests", Label("PipelineRun", "CORE"), func() { }) }) + Context("Multi-arch spec.output.platforms (OCI image index)", func() { + var ( + outputImage name.Reference + platforms = []buildapi.ImagePlatform{ + {OS: "linux", Arch: "amd64"}, + {OS: "linux", Arch: "arm64"}, + } + ) + + BeforeEach(func() { + skipUnlessTestImageRepoForPush() + skipUnlessSchedulablePlatforms(platforms) + testID = generateTestID("pipelinerun-multiarch-index") + outputImage, err = name.ParseReference(fmt.Sprintf("%s/%s:%s", + os.Getenv(EnvVarImageRepo), + testID, + "latest", + )) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should run per-platform PipelineTasks, assemble-index, and push a multi-platform image", Label("Buildah", "GitSource", "PipelineRun", "MultiArchOutputPlatforms"), func() { + buildRun, err = NewBuildRunPrototype(). + Namespace(testBuild.Namespace). + Name(testID). + WithBuildSpec(NewBuildPrototype(). + ClusterBuildStrategy("buildah-shipwright-managed-push"). + Namespace(testBuild.Namespace). + Name(testID). + SourceGit("https://github.com/shipwright-io/sample-go.git"). + SourceContextDir("docker-build"). + Dockerfile("Dockerfile"). + ArrayParamValue("registries-insecure", outputImage.Context().RegistryStr()). + OutputImage(outputImage.String()). + OutputImagePlatforms(platforms). + OutputImageCredentials(os.Getenv(EnvVarImageRepoSecret)). + OutputImageInsecure(insecure). + BuildSpec()). + Create() + Expect(err).ToNot(HaveOccurred()) + + buildRun = validateBuildRunToSucceed(testBuild, buildRun) + validatePipelineRunExistsAndSucceeded(buildRun) + validateMultiArchPipelineRunTektonContract(buildRun, platforms, outputImage.String(), insecure) + validatePipelineRunResultsFromGitSource(buildRun) + testBuild.ValidateImageDigest(buildRun) + testBuild.ValidateImagePlatformsExist(buildRun, []crv1.Platform{ + {OS: "linux", Architecture: "amd64"}, + {OS: "linux", Architecture: "arm64"}, + }) + }) + }) + + Context("spec.output.platforms with one platform (homogeneous cluster smoke)", func() { + var ( + outputImage name.Reference + platforms []buildapi.ImagePlatform + ) + + BeforeEach(func() { + skipUnlessTestImageRepoForPush() + platforms = pickSingleSchedulablePlatformForE2E() + testID = generateTestID("pipelinerun-single-platform-index") + outputImage, err = name.ParseReference(fmt.Sprintf("%s/%s:%s", + os.Getenv(EnvVarImageRepo), + testID, + "latest", + )) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should run source-acquisition, one build--, assemble-index, and push an image", Label("Buildah", "GitSource", "PipelineRun", "SinglePlatformOutputPlatforms"), func() { + buildRun, err = NewBuildRunPrototype(). + Namespace(testBuild.Namespace). + Name(testID). + WithBuildSpec(NewBuildPrototype(). + ClusterBuildStrategy("buildah-shipwright-managed-push"). + Namespace(testBuild.Namespace). + Name(testID). + SourceGit("https://github.com/shipwright-io/sample-go.git"). + SourceContextDir("docker-build"). + Dockerfile("Dockerfile"). + ArrayParamValue("registries-insecure", outputImage.Context().RegistryStr()). + OutputImage(outputImage.String()). + OutputImagePlatforms(platforms). + OutputImageCredentials(os.Getenv(EnvVarImageRepoSecret)). + OutputImageInsecure(insecure). + BuildSpec()). + Create() + Expect(err).ToNot(HaveOccurred()) + + buildRun = validateBuildRunToSucceed(testBuild, buildRun) + validatePipelineRunExistsAndSucceeded(buildRun) + validateMultiArchPipelineRunTektonContract(buildRun, platforms, outputImage.String(), insecure) + validatePipelineRunResultsFromGitSource(buildRun) + testBuild.ValidateImageDigest(buildRun) + testBuild.ValidateImagePlatformsExist(buildRun, []crv1.Platform{ + {OS: platforms[0].OS, Architecture: platforms[0].Arch}, + }) + }) + }) + Context("PipelineRun Error Handling", func() { It("should handle PipelineRun failures gracefully", Label("ErrorHandling", "PipelineRun"), func() { testID = generateTestID("pipelinerun-error") @@ -264,6 +372,297 @@ var _ = Describe("PipelineRun E2E Tests", Label("PipelineRun", "CORE"), func() { }) }) +func skipUnlessTestImageRepoForPush() { + if strings.TrimSpace(os.Getenv(EnvVarImageRepo)) == "" { + Skip(fmt.Sprintf("%s must be set to a registry prefix (e.g. localhost:5001/org/repo) so multi-arch source-bundle and image pushes do not target Docker Hub anonymously", + EnvVarImageRepo)) + } +} + +func skipUnlessSchedulablePlatforms(platforms []buildapi.ImagePlatform) { + nodes, err := testBuild.GetNodes() + Expect(err).ToNot(HaveOccurred()) + ok, _, msg := validate.ValidateNodeAvailability(platforms, nodes.Items) + if !ok { + Skip(fmt.Sprintf("multi-arch PipelineRun test needs Ready schedulable nodes for each platform: %s", msg)) + } +} + +// pickSingleSchedulablePlatformForE2E returns one {os, arch} from Ready schedulable nodes (deterministic: +// lexicographic sort). Used to exercise spec.output.platforms on homogeneous clusters without amd64+arm64. +func pickSingleSchedulablePlatformForE2E() []buildapi.ImagePlatform { + nodes, err := testBuild.GetNodes() + Expect(err).ToNot(HaveOccurred()) + plats := schedulableImagePlatforms(nodes.Items) + if len(plats) == 0 { + Skip("single-platform output.platforms E2E needs at least one Ready schedulable node with kubernetes.io/os and kubernetes.io/arch labels") + } + return []buildapi.ImagePlatform{plats[0]} +} + +func schedulableImagePlatforms(nodes []corev1.Node) []buildapi.ImagePlatform { + type pair struct{ os, arch string } + var pairs []pair + seen := make(map[string]bool) + for _, node := range nodes { + if node.Spec.Unschedulable { + continue + } + ready := false + for _, c := range node.Status.Conditions { + if c.Type == corev1.NodeReady && c.Status == corev1.ConditionTrue { + ready = true + break + } + } + if !ready { + continue + } + osLabel := node.Labels[corev1.LabelOSStable] + archLabel := node.Labels[corev1.LabelArchStable] + if osLabel == "" || archLabel == "" { + continue + } + key := osLabel + "/" + archLabel + if seen[key] { + continue + } + seen[key] = true + pairs = append(pairs, pair{os: osLabel, arch: archLabel}) + } + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].os != pairs[j].os { + return pairs[i].os < pairs[j].os + } + return pairs[i].arch < pairs[j].arch + }) + out := make([]buildapi.ImagePlatform, len(pairs)) + for i, p := range pairs { + out[i] = buildapi.ImagePlatform{OS: p.os, Arch: p.arch} + } + return out +} + +func validateMultiArchPipelineRunTektonContract(buildRun *buildapi.BuildRun, platforms []buildapi.ImagePlatform, expectedOutputImage string, outputInsecure bool) { + Expect(buildRun.Status.PlatformResults).To(HaveLen(len(platforms)), + "use BuildRun.status.platformResults digests as the source of truth for TaskRun cross-checks") + for _, p := range platforms { + Expect(platformDigestFromBuildRunStatus(buildRun, p)).NotTo(BeEmpty(), + "missing digest in BuildRun.status.platformResults for %s/%s", p.OS, p.Arch) + } + + pr, err := testBuild.GetPipelineRunFromBuildRun(buildRun.Name) + Expect(err).ToNot(HaveOccurred()) + Expect(pr.Spec.PipelineSpec).NotTo(BeNil()) + + taskNames := make(map[string]struct{}, len(pr.Spec.PipelineSpec.Tasks)) + for _, t := range pr.Spec.PipelineSpec.Tasks { + taskNames[t.Name] = struct{}{} + } + Expect(taskNames).To(HaveKey("source-acquisition")) + Expect(taskNames).To(HaveKey("assemble-index")) + var buildLikeTasks int + for name := range taskNames { + if strings.HasPrefix(name, "build-") { + buildLikeTasks++ + } + } + Expect(buildLikeTasks).To(Equal(len(platforms)), + "expected exactly one PipelineTask build-- per requested platform") + for _, p := range platforms { + Expect(taskNames).To(HaveKey(fmt.Sprintf("build-%s-%s", p.OS, p.Arch))) + } + + Expect(pipelineRunParamString(pr.Spec.Params, "shp-output-image")).To(Equal(expectedOutputImage)) + Expect(pipelineRunParamString(pr.Spec.Params, "shp-output-insecure")).To(Equal(strconv.FormatBool(outputInsecure))) + + Expect(pr.Spec.TaskRunSpecs).To(HaveLen(len(platforms)), "TaskRunSpecs should pin each per-platform build task to a node OS/arch") + for _, p := range platforms { + taskName := fmt.Sprintf("build-%s-%s", p.OS, p.Arch) + spec := findPipelineTaskRunSpec(pr.Spec.TaskRunSpecs, taskName) + Expect(spec).NotTo(BeNil(), "missing PipelineTaskRunSpec for %q", taskName) + Expect(spec.PodTemplate).NotTo(BeNil()) + Expect(spec.PodTemplate.NodeSelector).To(HaveKey(corev1.LabelOSStable)) + Expect(spec.PodTemplate.NodeSelector).To(HaveKey(corev1.LabelArchStable)) + Expect(spec.PodTemplate.NodeSelector[corev1.LabelOSStable]).To(Equal(p.OS)) + Expect(spec.PodTemplate.NodeSelector[corev1.LabelArchStable]).To(Equal(p.Arch)) + + pt := findPipelineTaskByName(pr.Spec.PipelineSpec.Tasks, taskName) + Expect(pt).NotTo(BeNil()) + Expect(pt.TaskSpec).NotTo(BeNil()) + imgStep := findPipelineStep(pt.TaskSpec.Steps, "image-processing") + Expect(imgStep).NotTo(BeNil()) + imgArgs := strings.Join(imgStep.Args, " ") + Expect(imgArgs).To(ContainSubstring("$(params.shp-output-image)")) + Expect(imgArgs).To(ContainSubstring("--result-file-image-digest")) + + outImgParam := findPipelineTaskParam(pt.Params, "shp-output-image") + Expect(outImgParam).NotTo(BeNil()) + Expect(outImgParam.Value.Type).To(Equal(pipelineapi.ParamTypeString)) + Expect(outImgParam.Value.StringVal).To(Equal(fmt.Sprintf("$(params.shp-output-image)%s", platformImageTagSuffix(p)))) + } + + assemblePT := findPipelineTaskByName(pr.Spec.PipelineSpec.Tasks, "assemble-index") + Expect(assemblePT).NotTo(BeNil()) + Expect(assemblePT.TaskSpec).NotTo(BeNil()) + assembleStep := findPipelineStep(assemblePT.TaskSpec.Steps, "assemble-index") + Expect(assembleStep).NotTo(BeNil()) + assembleArgLine := strings.Join(assembleStep.Args, " ") + Expect(assembleArgLine).To(ContainSubstring("--assemble-index")) + Expect(assembleArgLine).To(ContainSubstring("--image $(params.shp-output-image)")) + for _, p := range platforms { + Expect(assembleArgLine).To(ContainSubstring(expectedAssemblePlatformArgFragment(p))) + } + + taskRuns, err := testBuild.ListTaskRunsForPipelineRun(pr.Name) + Expect(err).ToNot(HaveOccurred()) + Expect(taskRuns).To(HaveLen(2+len(platforms)), "expected source-acquisition + N build-* tasks + assemble-index TaskRuns") + + byPipelineTask := map[string]pipelineapi.TaskRun{} + for _, tr := range taskRuns { + taskLabel := tr.Labels[tektonpipeline.PipelineTaskLabelKey] + Expect(taskLabel).NotTo(BeEmpty(), "TaskRun %q missing %s label", tr.Name, tektonpipeline.PipelineTaskLabelKey) + byPipelineTask[taskLabel] = tr + } + + for _, p := range platforms { + taskName := fmt.Sprintf("build-%s-%s", p.OS, p.Arch) + tr, ok := byPipelineTask[taskName] + Expect(ok).To(BeTrue(), "expected a TaskRun labeled for pipeline task %q", taskName) + Expect(tr.Spec.PodTemplate).NotTo(BeNil()) + Expect(tr.Spec.PodTemplate.NodeSelector[corev1.LabelOSStable]).To(Equal(p.OS)) + Expect(tr.Spec.PodTemplate.NodeSelector[corev1.LabelArchStable]).To(Equal(p.Arch)) + + Expect(tr.Status.PodName).NotTo(BeEmpty()) + pod, err := testBuild.LookupPod(types.NamespacedName{Namespace: tr.Namespace, Name: tr.Status.PodName}) + Expect(err).ToNot(HaveOccurred()) + ctr := findPodStepContainer(pod.Spec.Containers, "image-processing") + Expect(ctr).NotTo(BeNil()) + podImgArgs := strings.Join(ctr.Args, " ") + // Tekton substitutes $(params.shp-output-image) into the literal registry reference; per-platform + // builds append -- to the tag. The digest is emitted via --result-file-image-digest, not in --image. + resolvedPlatformPushRef := fmt.Sprintf("%s%s", expectedOutputImage, platformImageTagSuffix(p)) + Expect(podImgArgs).To(ContainSubstring("--image")) + Expect(podImgArgs).To(ContainSubstring(resolvedPlatformPushRef), + "image-processing pod should receive the platform-scoped push reference after param substitution") + Expect(podImgArgs).To(ContainSubstring("--result-file-image-digest")) + + platformDigest := platformDigestFromBuildRunStatus(buildRun, p) + Expect(taskRunSHAImageDigest(&tr)).To(Equal(platformDigest), + "per-platform TaskRun result shp-image-digest must match BuildRun.status.platformResults for %s/%s", p.OS, p.Arch) + } + + aTR, ok := byPipelineTask["assemble-index"] + Expect(ok).To(BeTrue()) + Expect(aTR.Status.PodName).NotTo(BeEmpty()) + aPod, err := testBuild.LookupPod(types.NamespacedName{Namespace: aTR.Namespace, Name: aTR.Status.PodName}) + Expect(err).ToNot(HaveOccurred()) + aCtr := findPodStepContainer(aPod.Spec.Containers, "assemble-index") + Expect(aCtr).NotTo(BeNil()) + aPodArgs := strings.Join(aCtr.Args, " ") + Expect(aPodArgs).To(ContainSubstring("--assemble-index")) + Expect(aPodArgs).To(ContainSubstring("@sha256:")) + for _, p := range platforms { + Expect(aPodArgs).To(ContainSubstring(fmt.Sprintf("%s/%s=", p.OS, p.Arch))) + d := platformDigestFromBuildRunStatus(buildRun, p) + Expect(aPodArgs).To(ContainSubstring(fmt.Sprintf("@%s", d)), + "assemble-index argv should resolve each platform pin to BuildRun.status.platformResults digest for %s/%s", + p.OS, p.Arch) + refAndDigest := fmt.Sprintf("%s@%s", + fmt.Sprintf("%s%s", expectedOutputImage, platformImageTagSuffix(p)), d) + Expect(aPodArgs).To(ContainSubstring(refAndDigest), + "assemble-index should pass the pinned ref@digest emitted for %s/%s", p.OS, p.Arch) + } +} + +const tektonSHAImageDigestResultName = "shp-image-digest" + +func platformDigestFromBuildRunStatus(br *buildapi.BuildRun, p buildapi.ImagePlatform) string { + for _, pr := range br.Status.PlatformResults { + if pr.Platform.OS == p.OS && pr.Platform.Arch == p.Arch { + return pr.Digest + } + } + return "" +} + +// taskRunSHAImageDigest returns the Tekton TaskRun string result wired from the Shipwright image-processing step. +func taskRunSHAImageDigest(tr *pipelineapi.TaskRun) string { + for _, r := range tr.Status.Results { + if r.Name == tektonSHAImageDigestResultName { + return r.Value.StringVal + } + } + return "" +} + +func pipelineRunParamString(params pipelineapi.Params, name string) string { + for _, p := range params { + if p.Name == name && p.Value.Type == pipelineapi.ParamTypeString { + return p.Value.StringVal + } + } + return "" +} + +func findPipelineTaskRunSpec(specs []pipelineapi.PipelineTaskRunSpec, pipelineTaskName string) *pipelineapi.PipelineTaskRunSpec { + for i := range specs { + if specs[i].PipelineTaskName == pipelineTaskName { + return &specs[i] + } + } + return nil +} + +func findPipelineTaskByName(tasks []pipelineapi.PipelineTask, name string) *pipelineapi.PipelineTask { + for i := range tasks { + if tasks[i].Name == name { + return &tasks[i] + } + } + return nil +} + +func findPipelineStep(steps []pipelineapi.Step, name string) *pipelineapi.Step { + for i := range steps { + if steps[i].Name == name { + return &steps[i] + } + } + return nil +} + +func findPipelineTaskParam(params []pipelineapi.Param, name string) *pipelineapi.Param { + for i := range params { + if params[i].Name == name { + return ¶ms[i] + } + } + return nil +} + +func platformImageTagSuffix(p buildapi.ImagePlatform) string { + return fmt.Sprintf("-%s-%s", p.OS, p.Arch) +} + +func expectedAssemblePlatformArgFragment(p buildapi.ImagePlatform) string { + tName := fmt.Sprintf("build-%s-%s", p.OS, p.Arch) + return fmt.Sprintf("%s/%s=$(params.shp-output-image)%s@$(tasks.%s.results.shp-image-digest)", + p.OS, p.Arch, platformImageTagSuffix(p), tName) +} + +func findPodStepContainer(containers []corev1.Container, stepName string) *corev1.Container { + candidates := []string{"step-" + stepName, stepName} + for _, want := range candidates { + for i := range containers { + if containers[i].Name == want { + return &containers[i] + } + } + } + return nil +} + // validatePipelineRunExistsAndSucceeded verifies that a PipelineRun was created and succeeded func validatePipelineRunExistsAndSucceeded(buildRun *buildapi.BuildRun) { // Verify that the BuildRun has the succeeded condition diff --git a/test/utils/v1beta1/image.go b/test/utils/v1beta1/image.go index bcfe7f410..f33b579c4 100644 --- a/test/utils/v1beta1/image.go +++ b/test/utils/v1beta1/image.go @@ -110,6 +110,12 @@ func (t *TestBuild) ValidateImageDigest(buildRun *buildapi.BuildRun) { gomega.Expect(buildRun.Status.Output).NotTo(gomega.BeNil(), ".status.output is nil") gomega.Expect(buildRun.Status.Output.Digest).NotTo(gomega.Equal(""), ".status.output.digest is empty") - // Verify that the digest is valid by retrieving the image manifest - t.GetImage(buildRun) + // Verify that the digest resolves in the registry. Use remote.Get instead of + // remote.Image: multi-platform outputs report an image index digest, and + // remote.Image defaults to linux/amd64 when resolving an index, which fails + // for clusters that only built arm64 (or other single-platform indexes). + ref, err := name.ParseReference(getImageURL(buildRun)) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + _, err = remote.Get(ref, remote.WithAuth(t.getRegistryAuthentication(buildRun, ref))) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) } diff --git a/test/utils/v1beta1/taskruns.go b/test/utils/v1beta1/taskruns.go index a17e06eac..66e466c20 100644 --- a/test/utils/v1beta1/taskruns.go +++ b/test/utils/v1beta1/taskruns.go @@ -8,7 +8,9 @@ import ( "context" "errors" "fmt" + "sort" + tektonpipeline "github.com/tektoncd/pipeline/pkg/apis/pipeline" pipelineapi "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -39,6 +41,23 @@ func (t *TestBuild) GetTaskRunFromBuildRun(buildRunName string) (*pipelineapi.Ta return &trList.Items[0], nil } +// ListTaskRunsForPipelineRun returns TaskRuns created for a PipelineRun (Tekton label tekton.dev/pipelineRun). +func (t *TestBuild) ListTaskRunsForPipelineRun(pipelineRunName string) ([]pipelineapi.TaskRun, error) { + sel := fmt.Sprintf("%s=%s", tektonpipeline.PipelineRunLabelKey, pipelineRunName) + + trList, err := t.PipelineClientSet.TektonV1().TaskRuns(t.Namespace).List(t.Context, metav1.ListOptions{ + LabelSelector: sel, + }) + if err != nil { + return nil, err + } + + out := make([]pipelineapi.TaskRun, len(trList.Items)) + copy(out, trList.Items) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + // UpdateTaskRun applies changes to a TaskRun object func (t *TestBuild) UpdateTaskRun(name string, apply func(tr *pipelineapi.TaskRun)) (*pipelineapi.TaskRun, error) { var tr *pipelineapi.TaskRun