Skip to content

feat: Implement multi-arch build orchestration and image index assembly#2170

Open
anchi205 wants to merge 1 commit into
shipwright-io:mainfrom
anchi205:multiarch-orchestration
Open

feat: Implement multi-arch build orchestration and image index assembly#2170
anchi205 wants to merge 1 commit into
shipwright-io:mainfrom
anchi205:multiarch-orchestration

Conversation

@anchi205
Copy link
Copy Markdown
Member

Changes

Generate per-platform PipelineTasks with architecture-specific nodeSelectors so each build runs on a matching node. Source code is bundled as an OCI artifact during source-acquisition and pulled on each platform node. Per-platform tasks suffix the output image tag with the platform to prevent registry GC from deleting intermediate images.

After all platform builds complete, an assemble-index task creates an OCI image index referencing each platform image. Result aggregation populates BuildRunStatus.PlatformResults with per-platform digest, size, vulnerabilities, and status, and sets ManifestDigest and Output.Digest to the index digest. Partial failure is tracked per platform so users can identify which architecture failed.

CLI flags --push-source-bundle and --assemble-index are added to the image-processing binary, consumed by the source-acquisition and assemble-index pipeline tasks respectively.

Related Issues

Fixes #2143
Fixes #2076

Type of PR

/kind feature

Submitter Checklist

  • Includes tests if functionality changed/was added
  • Includes docs if changes are user-facing
  • Kind label has been set
  • Release notes block has been filled in, or marked NONE

Release Notes

Multi-architecture container image builds. When spec.output.platforms is set, the controller runs parallel per-platform builds on architecture-matched nodes and assembles an OCI image index. Per-platform status, digest, and vulnerabilities are reported in status.platformResults.

@openshift-ci openshift-ci Bot added kind/feature Categorizes issue or PR as related to a new feature. release-note Label for when a PR has specified a release note labels Apr 24, 2026
@openshift-ci openshift-ci Bot requested review from adambkaplan and qu1queee April 24, 2026 13:05
@openshift-ci
Copy link
Copy Markdown
Contributor

openshift-ci Bot commented Apr 24, 2026

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign apoorvajagtap for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@pull-request-size pull-request-size Bot added the size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. label Apr 24, 2026
Comment thread pkg/apis/build/v1beta1/buildrun_types.go Outdated
Comment thread pkg/image/bundle.go Outdated
Comment thread pkg/image/bundle.go Outdated
Generate per-platform PipelineTasks with architecture-specific
nodeSelectors so each build runs on a matching node. Source code is
bundled as an OCI artifact during source-acquisition and pulled on
each platform node. Per-platform tasks suffix the output image tag with
the platform to prevent registry GC from deleting intermediate images.

After all platform builds complete, an assemble-index task creates an
OCI image index referencing each platform image. Result aggregation
populates BuildRunStatus.platformResults with per-platform digest,
size, vulnerabilities, and status, and sets status.output.digest to the
image index digest. Partial failure is tracked per platform so users can
identify which architecture failed.

CLI flags --push-source-bundle and --assemble-index are added to the
image-processing binary, consumed by the source-acquisition and
assemble-index pipeline tasks respectively.

Closes shipwright-io#2143, closes shipwright-io#2076

Signed-off-by: Anchita Borah <anborah@redhat.com>
@anchi205 anchi205 force-pushed the multiarch-orchestration branch from d625a1c to 7ec0b3f Compare May 26, 2026 05:06
It("should run vulnerability scanning on an image that is already pushed by the strategy", func() {
if testing.Short() {
Skip("skipping network-dependent test in -short mode")
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This change is unrelated to the feature.

Comment on lines +632 to +659
logLogger := log.Logger{}
logLogger.SetOutput(GinkgoWriter)
s := httptest.NewServer(registry.New(registry.Logger(&logLogger)))
defer s.Close()
u, err := url.Parse(s.URL)
Expect(err).ToNot(HaveOccurred())
endpoint := u.Host

amd64Tag := fmt.Sprintf("%s/test/app-linux-amd64:latest", endpoint)
arm64Tag := fmt.Sprintf("%s/test/app-linux-arm64:latest", endpoint)

amd64Ref, err := name.ParseReference(amd64Tag)
Expect(err).ToNot(HaveOccurred())
arm64Ref, err := name.ParseReference(arm64Tag)
Expect(err).ToNot(HaveOccurred())

Expect(remote.Write(amd64Ref, empty.Image)).To(Succeed())
Expect(remote.Write(arm64Ref, empty.Image)).To(Succeed())

amd64Digest, err := empty.Image.Digest()
Expect(err).ToNot(HaveOccurred())
arm64Digest, err := empty.Image.Digest()
Expect(err).ToNot(HaveOccurred())

outputRef := fmt.Sprintf("%s/test/app:latest", endpoint)
digestFile, err := os.CreateTemp("", "index-digest")
Expect(err).ToNot(HaveOccurred())
defer os.Remove(digestFile.Name())
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These are all setup-things that are better suited into a BeforeAll/Each node. DeferCleanup is the Ginkgo pattern to then delete after related Its ran.

In pkg/image/index_test.go for example you are setting up the registry server in a before node.

Comment thread pkg/image/index_test.go
{OS: "linux", Arch: "arm64", ImageRef: arm64Ref},
}

idx, err := image.AssembleImageIndex(entries, nil)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

At a first look, I had not expected this to work. The registry host is http. I assume only with options returned from GetOptions(insecure=true) this would be possible. But, instead GetOptions(insecure=false) builds something that actively prevents HTTP protocol. Maybe worth a comment that you can pass nil here.

// spec; otherwise it's the output push secret (source bundle is pushed
// to the same registry as the output image).
bundlePullArgs := []string{
"--image", "$(params.source-bundle-image)",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can we stick to our params being prefixed with shp/ prefixParamsResultsVolumes ? And having a constant that we can reuse at declaration (further below) and usage.

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)),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This will only work as desired if the output image value is with tag, e. g. myimage:test as you then make myimage:test-linux-amd64 out of it. But for myimage, you make my-image-linux-amd64. This may still work as it is still a valid image name, but I assume we rather want my-image:latest-linux-amd64. Or do we have some normalization around this somewhere?

Comment on lines +460 to +475
// Build a clean base nodeSelector without os/arch keys
cleanBase := make(map[string]string, len(baseNodeSelector))
for k, v := range baseNodeSelector {
if k != corev1.LabelOSStable && k != corev1.LabelArchStable {
cleanBase[k] = v
}
}

var specs []pipelineapi.PipelineTaskRunSpec
for _, p := range platforms {
ns := make(map[string]string, len(cleanBase)+2)
for k, v := range cleanBase {
ns[k] = v
}
ns[corev1.LabelOSStable] = p.OS
ns[corev1.LabelArchStable] = p.Arch
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I find this quite complex. I think below is the same.

Suggested change
// Build a clean base nodeSelector without os/arch keys
cleanBase := make(map[string]string, len(baseNodeSelector))
for k, v := range baseNodeSelector {
if k != corev1.LabelOSStable && k != corev1.LabelArchStable {
cleanBase[k] = v
}
}
var specs []pipelineapi.PipelineTaskRunSpec
for _, p := range platforms {
ns := make(map[string]string, len(cleanBase)+2)
for k, v := range cleanBase {
ns[k] = v
}
ns[corev1.LabelOSStable] = p.OS
ns[corev1.LabelArchStable] = p.Arch
var specs []pipelineapi.PipelineTaskRunSpec
for _, p := range platforms {
ns := maps.Close(baseNodeSelector)
ns[corev1.LabelOSStable] = p.OS
ns[corev1.LabelArchStable] = p.Arch

EmptyDir: &corev1.EmptyDirVolumeSource{},
},
{
Name: "cache",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Please use a constant for declaration and usage.

Comment on lines +176 to +179
result.Status = buildapi.PlatformBuildStatusFailed
result.FailureMessage = fmt.Sprintf("failed to fetch TaskRun %s: %v", childRef.Name, err)
buildRun.Status.PlatformResults = append(buildRun.Status.PlatformResults, result)
continue
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would rather return the error (unless NotFound) and reconcile again.

And what I wonder and am not sure out of my head ... does not Tekton inline parts of the TaskRun status into the PipelineRun status ? If so, is that something we can use here ?

if err := c.Get(ctx, types.NamespacedName{
Namespace: pipelineRun.Namespace,
Name: assembleRef.Name,
}, taskRun); err == nil {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Similar as above, is data available in the PipelineRun status and should we rather return the error (unless NotFound) and reconcile again?

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds first-class multi-architecture build support to the BuildRun PipelineRun executor by fanning out per-platform build tasks (scheduled onto matching nodes), pushing/pulling source as an OCI artifact, and assembling a final OCI image index. It also extends status reporting to include per-platform results and updates docs/CRDs/tests accordingly.

Changes:

  • Generate multi-arch PipelineRuns: per-platform build-<os>-<arch> tasks + assemble-index, with per-task OS/arch nodeSelectors and EmptyDir workspaces.
  • Add image-processing CLI support for --push-source-bundle and --assemble-index, plus an AssembleImageIndex helper.
  • Report per-platform digests/sizes/vulnerabilities in .status.platformResults and set .status.output.digest to the index digest; add unit + e2e coverage and update docs/CRDs.

Reviewed changes

Copilot reviewed 19 out of 20 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
test/utils/v1beta1/taskruns.go Add helper to list/sort TaskRuns for a PipelineRun by label for e2e assertions.
test/utils/v1beta1/image.go Fix digest validation for multi-platform outputs by using remote.Get (index-safe).
test/e2e/v1beta1/e2e_pipelinerun_test.go Add e2e coverage for multi-arch spec.output.platforms and index assembly behavior.
test/e2e/v1beta1/common_suite_test.go Extend test build prototype to set spec.output.platforms.
pkg/reconciler/buildrun/resources/results.go Populate .status.platformResults and set output digest from assemble-index results.
pkg/reconciler/buildrun/resources/results_test.go Add unit tests for multi-arch result extraction/aggregation.
pkg/reconciler/buildrun/resources/resource_builders.go Add EmptyDir workspace bindings for multi-arch PipelineRuns.
pkg/reconciler/buildrun/resources/pipelinerun_generator.go Implement multi-arch PipelineRun generation + scheduling pinning via TaskRunSpecs.
pkg/reconciler/buildrun/resources/multiarch.go New: multi-arch pipeline task generation (source bundle push/pull, per-platform tasks, assemble-index).
pkg/reconciler/buildrun/resources/multiarch_test.go New: unit tests for multi-arch pipeline generation contracts.
pkg/reconciler/buildrun/buildrun.go Reconcile: extract per-platform results from PipelineRun child TaskRuns into BuildRun status.
pkg/image/index.go New: build an OCI image index from per-platform images with platform descriptors.
pkg/image/index_test.go Unit tests for index assembly behavior and error cases.
pkg/apis/build/v1beta1/zz_generated.deepcopy.go DeepCopy updates for new PlatformResults / PlatformBuildResult.
pkg/apis/build/v1beta1/buildrun_types.go API: define PlatformBuildResult and clarify output digest semantics for multi-arch.
docs/buildrun.md Document .status.platformResults and multi-arch digest semantics.
docs/build.md Document spec.output.platforms behavior/requirements and examples.
deploy/crds/shipwright.io_buildruns.yaml CRD schema updates for new status fields and digest description.
cmd/image-processing/main.go Add CLI modes for pushing source bundles and assembling/pushing image indexes.
cmd/image-processing/main_test.go Add tests for new CLI modes and improve short-mode behavior for network-dependent test.
Files not reviewed (1)
  • pkg/apis/build/v1beta1/zz_generated.deepcopy.go: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +171 to +180
taskRun := &pipelineapi.TaskRun{}
if err := c.Get(ctx, types.NamespacedName{
Namespace: pipelineRun.Namespace,
Name: childRef.Name,
}, taskRun); err != nil {
result.Status = buildapi.PlatformBuildStatusFailed
result.FailureMessage = fmt.Sprintf("failed to fetch TaskRun %s: %v", childRef.Name, err)
buildRun.Status.PlatformResults = append(buildRun.Status.PlatformResults, result)
continue
}
Comment on lines +212 to +236
// Set output digest from the assemble-index task so the BuildRun reflects the manifest list, not a
// nondeterministic per-platform digest from the flat result aggregation.
// Output.Size is cleared because the flat aggregation picks it from an
// arbitrary per-platform TaskRun; correct per-platform data lives in
// PlatformResults. Output.Vulnerabilities is set to the union of all
// per-platform vulnerabilities.
if assembleRef, ok := childRefMap["assemble-index"]; ok {
taskRun := &pipelineapi.TaskRun{}
if err := c.Get(ctx, types.NamespacedName{
Namespace: pipelineRun.Namespace,
Name: assembleRef.Name,
}, taskRun); err == nil {
if buildRun.Status.Output == nil {
buildRun.Status.Output = &buildapi.Output{}
}
for _, tr := range taskRun.Status.Results {
if tr.Name == digestResultName {
applyAssembleIndexOutputDigest(buildRun, tr.Value.StringVal)
}
}
buildRun.Status.Output.Size = 0
buildRun.Status.Output.Vulnerabilities = allVulnerabilities
}
}
}
Comment on lines +101 to +104
if g.isMultiArch() && !g.isSourceOCIArtifact() {
pushStep := generateSourceBundlePushStep(g.cfg, taskSpec, g.build.Spec.Output.PushSecret)
taskSpec.Steps = append(taskSpec.Steps, pushStep)
}
Comment on lines +344 to +348
var secretVolumeMounts []corev1.VolumeMount
if build.Spec.Output.PushSecret != nil {
sources.AppendSecretVolume(taskSpec, *build.Spec.Output.PushSecret)
secretMountPath := fmt.Sprintf("/workspace/%s-push-secret", prefixParamsResultsVolumes)
secretVolumeMounts = append(secretVolumeMounts, corev1.VolumeMount{
Comment on lines +194 to +202
if err := CreateImageProcessingStep(
cfg,
taskSpec,
imgProcArgs,
false,
build.Spec.Output.PushSecret,
); err != nil {
return pipelineapi.PipelineTask{}, fmt.Errorf("creating image processing step for %s: %w", taskName, err)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/feature Categorizes issue or PR as related to a new feature. release-note Label for when a PR has specified a release note size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files.

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[FEATURE] Implement Per-Arch Builds [FEATURE] Implement image index for multi-arch images

4 participants