Skip to content

Commit 648fee8

Browse files
committed
feat: Add multi-arch build API and orchestration
Add ImagePlatform, MultiArch, and PlatformBuildResult types to the Build and BuildRun CRDs under spec.output.multiArch.platforms. A user declares platforms on a Build or BuildRun and the controller generates a single three-phase PipelineRun that fans out: source-acquisition pushes source as an OCI artifact, parallel per-platform tasks pull source on arch-specific nodes and run strategy steps, and assemble-index creates the final OCI image index. Result aggregation populates PlatformResults and the BuildRun status reports per-platform digest/size/vulnerabilities while setting Output.Digest to the manifest list digest. Assisted-by: Cursor Signed-off-by: Anchita Borah <anborah@redhat.com>
1 parent 55f48ac commit 648fee8

23 files changed

+2878
-13
lines changed

cmd/image-processing/main.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ type settings struct {
5353
secretPath string
5454
vulnerabilitySettings resources.VulnerablilityScanParams
5555
vulnerabilityCountLimit int
56+
57+
pushSourceBundle string
58+
sourceBundleImage string
59+
60+
assembleIndex bool
61+
platformImages []string
5662
}
5763

5864
var flagValues settings
@@ -80,6 +86,12 @@ func initializeFlag() {
8086
pflag.StringVar(&flagValues.resultFileImageVulnerabilities, "result-file-image-vulnerabilities", "", "A file to write the image vulnerabilities to")
8187
pflag.Var(&flagValues.vulnerabilitySettings, "vuln-settings", "Vulnerability settings json string. One can enable the scan by setting {\"enabled\":true} to this option")
8288
pflag.IntVar(&flagValues.vulnerabilityCountLimit, "vuln-count-limit", 50, "vulnerability count limit for the output of vulnerability scan")
89+
90+
pflag.StringVar(&flagValues.pushSourceBundle, "push-source-bundle", "", "Package this directory as an OCI artifact and push it")
91+
pflag.StringVar(&flagValues.sourceBundleImage, "source-bundle-image", "", "Registry reference for the source bundle OCI artifact")
92+
93+
pflag.BoolVar(&flagValues.assembleIndex, "assemble-index", false, "Assemble an OCI image index from per-platform images")
94+
pflag.StringArrayVar(&flagValues.platformImages, "platform-image", nil, "Per-platform image reference in os/arch=ref@digest format (repeatable)")
8395
}
8496

8597
func main() {
@@ -127,6 +139,14 @@ func Execute(ctx context.Context) error {
127139
flagValues.imageTimestamp = string(data)
128140
}
129141

142+
if flagValues.pushSourceBundle != "" {
143+
return runSourceBundlePush(ctx)
144+
}
145+
146+
if flagValues.assembleIndex {
147+
return runAssembleIndex(ctx)
148+
}
149+
130150
return runImageProcessing(ctx)
131151
}
132152

@@ -301,3 +321,122 @@ func serializeVulnerabilities(Vulnerabilities []buildapi.Vulnerability) []byte {
301321
}
302322
return []byte(strings.Join(output, ","))
303323
}
324+
325+
func runSourceBundlePush(ctx context.Context) error {
326+
if flagValues.sourceBundleImage == "" {
327+
return &ExitError{Code: 100, Message: "the 'source-bundle-image' argument is required when using 'push-source-bundle'"}
328+
}
329+
330+
bundleRef, err := name.ParseReference(flagValues.sourceBundleImage)
331+
if err != nil {
332+
return fmt.Errorf("failed to parse source bundle image reference: %w", err)
333+
}
334+
335+
log.Printf("Bundling source directory %q as OCI artifact\n", flagValues.pushSourceBundle)
336+
img, err := image.BundleSourceDirectory(flagValues.pushSourceBundle)
337+
if err != nil {
338+
return fmt.Errorf("failed to bundle source directory: %w", err)
339+
}
340+
341+
options, _, err := image.GetOptions(ctx, bundleRef, flagValues.insecure, flagValues.secretPath, "Shipwright Build")
342+
if err != nil {
343+
return fmt.Errorf("failed to get registry options: %w", err)
344+
}
345+
346+
log.Printf("Pushing source bundle to %q\n", bundleRef.String())
347+
digest, _, err := image.PushImageOrImageIndex(bundleRef, img, nil, options)
348+
if err != nil {
349+
return fmt.Errorf("failed to push source bundle: %w", err)
350+
}
351+
log.Printf("Source bundle %s@%s pushed\n", bundleRef.String(), digest)
352+
353+
if flagValues.resultFileImageDigest != "" {
354+
if err := os.WriteFile(flagValues.resultFileImageDigest, []byte(digest), 0400); err != nil {
355+
return err
356+
}
357+
}
358+
359+
return nil
360+
}
361+
362+
func runAssembleIndex(ctx context.Context) error {
363+
if flagValues.image == "" {
364+
return &ExitError{Code: 100, Message: "the 'image' argument is required when using 'assemble-index'"}
365+
}
366+
if len(flagValues.platformImages) == 0 {
367+
return &ExitError{Code: 100, Message: "at least one 'platform-image' argument is required when using 'assemble-index'"}
368+
}
369+
370+
outputRef, err := name.ParseReference(flagValues.image)
371+
if err != nil {
372+
return fmt.Errorf("failed to parse output image reference: %w", err)
373+
}
374+
375+
options, _, err := image.GetOptions(ctx, outputRef, flagValues.insecure, flagValues.secretPath, "Shipwright Build")
376+
if err != nil {
377+
return fmt.Errorf("failed to get registry options: %w", err)
378+
}
379+
380+
platformEntries, err := ParsePlatformImages(flagValues.platformImages)
381+
if err != nil {
382+
return err
383+
}
384+
385+
log.Printf("Assembling OCI image index for %d platforms\n", len(platformEntries))
386+
imageIndex, err := image.AssembleImageIndex(platformEntries, options)
387+
if err != nil {
388+
return fmt.Errorf("failed to assemble image index: %w", err)
389+
}
390+
391+
log.Printf("Pushing image index to %q\n", outputRef.String())
392+
digest, size, err := image.PushImageOrImageIndex(outputRef, nil, imageIndex, options)
393+
if err != nil {
394+
return fmt.Errorf("failed to push image index: %w", err)
395+
}
396+
log.Printf("Image index %s@%s pushed\n", outputRef.String(), digest)
397+
398+
if digest != "" && flagValues.resultFileImageDigest != "" {
399+
if err := os.WriteFile(flagValues.resultFileImageDigest, []byte(digest), 0400); err != nil {
400+
return err
401+
}
402+
}
403+
404+
if size > 0 && flagValues.resultFileImageSize != "" {
405+
if err := os.WriteFile(flagValues.resultFileImageSize, []byte(strconv.FormatInt(size, 10)), 0400); err != nil {
406+
return err
407+
}
408+
}
409+
410+
return nil
411+
}
412+
413+
// ParsePlatformImages parses --platform-image flags in the format "os/arch=ref@digest"
414+
func ParsePlatformImages(entries []string) ([]image.PlatformImageEntry, error) {
415+
var result []image.PlatformImageEntry
416+
for _, entry := range entries {
417+
parts := strings.SplitN(entry, "=", 2)
418+
if len(parts) != 2 {
419+
return nil, fmt.Errorf("invalid platform-image format %q, expected os/arch=ref@digest", entry)
420+
}
421+
422+
platformStr := parts[0]
423+
imageRef := parts[1]
424+
425+
platformParts := strings.SplitN(platformStr, "/", 2)
426+
if len(platformParts) != 2 {
427+
return nil, fmt.Errorf("invalid platform format %q, expected os/arch", platformStr)
428+
}
429+
430+
ref, err := name.ParseReference(imageRef)
431+
if err != nil {
432+
return nil, fmt.Errorf("failed to parse image reference %q: %w", imageRef, err)
433+
}
434+
435+
result = append(result, image.PlatformImageEntry{
436+
OS: platformParts[0],
437+
Arch: platformParts[1],
438+
ImageRef: ref,
439+
})
440+
}
441+
return result, nil
442+
}

cmd/image-processing/main_test.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,3 +537,180 @@ var _ = Describe("Image Processing Resource", Ordered, func() {
537537
})
538538
})
539539
})
540+
541+
var _ = Describe("Source Bundle Push", func() {
542+
run := func(args ...string) error {
543+
log.SetOutput(GinkgoWriter)
544+
os.Args = append([]string{"tool"}, args...)
545+
tmp := os.Stderr
546+
defer func() { os.Stderr = tmp }()
547+
os.Stderr = nil
548+
return Execute(context.Background())
549+
}
550+
551+
AfterEach(func() {
552+
pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ExitOnError)
553+
})
554+
555+
It("should bundle a source directory and push it as an OCI artifact", func() {
556+
logLogger := log.Logger{}
557+
logLogger.SetOutput(GinkgoWriter)
558+
s := httptest.NewServer(registry.New(registry.Logger(&logLogger)))
559+
defer s.Close()
560+
u, err := url.Parse(s.URL)
561+
Expect(err).ToNot(HaveOccurred())
562+
endpoint := u.Host
563+
564+
srcDir, err := os.MkdirTemp("", "source-bundle-test")
565+
Expect(err).ToNot(HaveOccurred())
566+
defer os.RemoveAll(srcDir)
567+
568+
Expect(os.WriteFile(fmt.Sprintf("%s/main.go", srcDir), []byte("package main"), 0644)).To(Succeed())
569+
Expect(os.MkdirAll(fmt.Sprintf("%s/pkg", srcDir), 0755)).To(Succeed())
570+
Expect(os.WriteFile(fmt.Sprintf("%s/pkg/lib.go", srcDir), []byte("package pkg"), 0644)).To(Succeed())
571+
572+
imageRef := fmt.Sprintf("%s/test/source-bundle:latest", endpoint)
573+
digestFile, err := os.CreateTemp("", "digest")
574+
Expect(err).ToNot(HaveOccurred())
575+
defer os.Remove(digestFile.Name())
576+
577+
Expect(run(
578+
"--push-source-bundle", srcDir,
579+
"--source-bundle-image", imageRef,
580+
"--insecure",
581+
"--result-file-image-digest", digestFile.Name(),
582+
)).To(Succeed())
583+
584+
ref, err := name.ParseReference(imageRef)
585+
Expect(err).ToNot(HaveOccurred())
586+
587+
desc, err := remote.Get(ref)
588+
Expect(err).ToNot(HaveOccurred())
589+
590+
img, err := desc.Image()
591+
Expect(err).ToNot(HaveOccurred())
592+
593+
layers, err := img.Layers()
594+
Expect(err).ToNot(HaveOccurred())
595+
Expect(layers).To(HaveLen(1))
596+
597+
digestData, err := os.ReadFile(digestFile.Name())
598+
Expect(err).ToNot(HaveOccurred())
599+
Expect(string(digestData)).To(HavePrefix("sha256:"))
600+
})
601+
602+
It("should fail when --source-bundle-image is missing", func() {
603+
srcDir, err := os.MkdirTemp("", "source-bundle-test")
604+
Expect(err).ToNot(HaveOccurred())
605+
defer os.RemoveAll(srcDir)
606+
607+
Expect(run(
608+
"--push-source-bundle", srcDir,
609+
)).To(HaveOccurred())
610+
})
611+
})
612+
613+
var _ = Describe("Assemble Index", func() {
614+
run := func(args ...string) error {
615+
log.SetOutput(GinkgoWriter)
616+
os.Args = append([]string{"tool"}, args...)
617+
tmp := os.Stderr
618+
defer func() { os.Stderr = tmp }()
619+
os.Stderr = nil
620+
return Execute(context.Background())
621+
}
622+
623+
AfterEach(func() {
624+
pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ExitOnError)
625+
})
626+
627+
It("should assemble an OCI image index from per-platform images", func() {
628+
logLogger := log.Logger{}
629+
logLogger.SetOutput(GinkgoWriter)
630+
s := httptest.NewServer(registry.New(registry.Logger(&logLogger)))
631+
defer s.Close()
632+
u, err := url.Parse(s.URL)
633+
Expect(err).ToNot(HaveOccurred())
634+
endpoint := u.Host
635+
636+
amd64Tag := fmt.Sprintf("%s/test/app-linux-amd64:latest", endpoint)
637+
arm64Tag := fmt.Sprintf("%s/test/app-linux-arm64:latest", endpoint)
638+
639+
amd64Ref, err := name.ParseReference(amd64Tag)
640+
Expect(err).ToNot(HaveOccurred())
641+
arm64Ref, err := name.ParseReference(arm64Tag)
642+
Expect(err).ToNot(HaveOccurred())
643+
644+
Expect(remote.Write(amd64Ref, empty.Image)).To(Succeed())
645+
Expect(remote.Write(arm64Ref, empty.Image)).To(Succeed())
646+
647+
amd64Digest, err := empty.Image.Digest()
648+
Expect(err).ToNot(HaveOccurred())
649+
arm64Digest, err := empty.Image.Digest()
650+
Expect(err).ToNot(HaveOccurred())
651+
652+
outputRef := fmt.Sprintf("%s/test/app:latest", endpoint)
653+
digestFile, err := os.CreateTemp("", "index-digest")
654+
Expect(err).ToNot(HaveOccurred())
655+
defer os.Remove(digestFile.Name())
656+
657+
Expect(run(
658+
"--assemble-index",
659+
"--image", outputRef,
660+
"--insecure",
661+
"--platform-image", fmt.Sprintf("linux/amd64=%s@%s", amd64Tag, amd64Digest),
662+
"--platform-image", fmt.Sprintf("linux/arm64=%s@%s", arm64Tag, arm64Digest),
663+
"--result-file-image-digest", digestFile.Name(),
664+
)).To(Succeed())
665+
666+
digestData, err := os.ReadFile(digestFile.Name())
667+
Expect(err).ToNot(HaveOccurred())
668+
Expect(string(digestData)).To(HavePrefix("sha256:"))
669+
670+
ref, err := name.ParseReference(outputRef)
671+
Expect(err).ToNot(HaveOccurred())
672+
673+
desc, err := remote.Get(ref)
674+
Expect(err).ToNot(HaveOccurred())
675+
idx, err := desc.ImageIndex()
676+
Expect(err).ToNot(HaveOccurred())
677+
678+
indexManifest, err := idx.IndexManifest()
679+
Expect(err).ToNot(HaveOccurred())
680+
Expect(indexManifest.Manifests).To(HaveLen(2))
681+
})
682+
683+
It("should fail when --image is missing", func() {
684+
Expect(run(
685+
"--assemble-index",
686+
"--platform-image", "linux/amd64=localhost:5000/test@sha256:abc",
687+
)).To(HaveOccurred())
688+
})
689+
690+
It("should fail when no --platform-image is provided", func() {
691+
Expect(run(
692+
"--assemble-index",
693+
"--image", "localhost:5000/test:latest",
694+
)).To(HaveOccurred())
695+
})
696+
})
697+
698+
var _ = Describe("ParsePlatformImages", func() {
699+
It("should parse valid platform image entries", func() {
700+
entries, err := ParsePlatformImages([]string{
701+
"linux/amd64=registry.example.com/app@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
702+
"linux/arm64=registry.example.com/app@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
703+
})
704+
Expect(err).ToNot(HaveOccurred())
705+
Expect(entries).To(HaveLen(2))
706+
Expect(entries[0].OS).To(Equal("linux"))
707+
Expect(entries[0].Arch).To(Equal("amd64"))
708+
Expect(entries[1].OS).To(Equal("linux"))
709+
Expect(entries[1].Arch).To(Equal("arm64"))
710+
})
711+
712+
It("should fail when the format is invalid", func() {
713+
_, err := ParsePlatformImages([]string{"not-a-valid-entry"})
714+
Expect(err).To(HaveOccurred())
715+
})
716+
})

deploy/200-role.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ rules:
7575
resources: ['pods']
7676
verbs: ['get', 'list', 'watch']
7777

78+
- apiGroups: ['']
79+
resources: ['nodes']
80+
verbs: ['get', 'list', 'watch']
81+
7882
- apiGroups: ['']
7983
resources: ['secrets']
8084
verbs: ['get', 'list', 'watch']

0 commit comments

Comments
 (0)