Repository: project-copacetic/copacetic (go)

Issue:
Multi-Arch - patch Command Changes
### `patch` Command Changes:

- Modify the patch command flow (`cmd/patch/patch.go`) to:
  1. Detect if the input reference is a multi-arch manifest using the utility built in #989 
  2. Retrieve sub-manifests and store them in a slice/struct describing each (OS, Arch, Digest).
  3. When multi-arch is detected, loop over each sub-manifest to call `Patch`; otherwise just call `Patch` as before.

Dependent on #989 

Pull request description:
feat: add multi arch command changes
- added QemuAvailable() + mapGoArch() helpers to detect binfmt/qemu support for cross-arch builds.
- new helpers for multiarch work:
  - archDigest, archTag(), dockerImageDigest(), createManifestCLI() and normalizeConfigForPlatform()
- split the monolithic patchWithContext() into:
  - patchSingleArchImage() - one arch at a time (returns *types.PatchResult).
  - patchMultiArchImage() - fan-out, run builds in parallel, then stitch a manifest list.
- added new types:
  - PatchPlatform: OCI platform + report path
  - PatchResult: original image, patched ref, digest
- added argument validation: can't pass both report-file and report-dir

Closes #988


Patch:
diff --git a/pkg/buildkit/buildkit.go b/pkg/buildkit/buildkit.go
index 6eca75d3..b350f71d 100644
--- a/pkg/buildkit/buildkit.go
+++ b/pkg/buildkit/buildkit.go
@@ -7,6 +7,8 @@ import (
 	"errors"
 	"fmt"
 	"os"
+	"os/exec"
+	"strings"
 
 	"github.com/containerd/platforms"
 	"github.com/moby/buildkit/client/llb"
@@ -15,6 +17,7 @@ import (
 	ispec "github.com/opencontainers/image-spec/specs-go/v1"
 
 	"github.com/project-copacetic/copacetic/pkg/report"
+	"github.com/project-copacetic/copacetic/pkg/types"
 	log "github.com/sirupsen/logrus"
 
 	"github.com/google/go-containerregistry/pkg/name"
@@ -40,6 +43,13 @@ type Opts struct {
 
 const linux = "linux"
 
+// for testing.
+var (
+	readDir  = os.ReadDir
+	readFile = os.ReadFile
+	lookPath = exec.LookPath
+)
+
 func InitializeBuildkitConfig(ctx context.Context, c gwclient.Client, userImage string) (*Config, error) {
 	// Initialize buildkit config for the target image
 	config := Config{
@@ -90,8 +100,8 @@ func InitializeBuildkitConfig(ctx context.Context, c gwclient.Client, userImage
 	return &config, nil
 }
 
-func DiscoverPlatformsFromReport(reportDir, scanner string) ([]ispec.Platform, error) {
-	var platforms []ispec.Platform
+func DiscoverPlatformsFromReport(reportDir, scanner string) ([]types.PatchPlatform, error) {
+	var platforms []types.PatchPlatform
 
 	reportNames, err := os.ReadDir(reportDir)
 	if err != nil {
@@ -99,7 +109,11 @@ func DiscoverPlatformsFromReport(reportDir, scanner string) ([]ispec.Platform, e
 	}
 
 	for _, file := range reportNames {
-		report, err := report.TryParseScanReport(reportDir+"/"+file.Name(), scanner)
+		filePath := reportDir + "/" + file.Name()
+		if file.IsDir() {
+			continue
+		}
+		report, err := report.TryParseScanReport(filePath, scanner)
 		if err != nil {
 			return nil, fmt.Errorf("error parsing report %w", err)
 		}
@@ -109,9 +123,12 @@ func DiscoverPlatformsFromReport(reportDir, scanner string) ([]ispec.Platform, e
 			continue
 		}
 
-		platform := ispec.Platform{
-			OS:           linux,
-			Architecture: report.Metadata.Config.Arch,
+		platform := types.PatchPlatform{
+			Platform: ispec.Platform{
+				OS:           linux,
+				Architecture: report.Metadata.Config.Arch,
+			},
+			ReportFile: filePath,
 		}
 		platforms = append(platforms, platform)
 	}
@@ -129,8 +146,8 @@ func isSupportedOsType(osType string) bool {
 }
 
 // This approach will not work for local images, add future support for this.
-func DiscoverPlatformsFromReference(manifestRef string) ([]ispec.Platform, error) {
-	var platforms []ispec.Platform
+func DiscoverPlatformsFromReference(manifestRef string) ([]types.PatchPlatform, error) {
+	var platforms []types.PatchPlatform
 
 	ref, err := name.ParseReference(manifestRef)
 	if err != nil {
@@ -159,9 +176,11 @@ func DiscoverPlatformsFromReference(manifestRef string) ([]ispec.Platform, error
 			if m.Platform.OS != linux {
 				continue
 			}
-			platforms = append(platforms, ispec.Platform{
-				OS:           m.Platform.OS,
-				Architecture: m.Platform.Architecture,
+			platforms = append(platforms, types.PatchPlatform{
+				Platform: ispec.Platform{
+					OS:           m.Platform.OS,
+					Architecture: m.Platform.Architecture,
+				},
 			})
 		}
 		return platforms, nil
@@ -171,8 +190,8 @@ func DiscoverPlatformsFromReference(manifestRef string) ([]ispec.Platform, error
 	return nil, nil
 }
 
-func DiscoverPlatforms(manifestRef, reportDir, scanner string) ([]ispec.Platform, error) {
-	var platforms []ispec.Platform
+func DiscoverPlatforms(manifestRef, reportDir, scanner string) ([]types.PatchPlatform, error) {
+	var platforms []types.PatchPlatform
 
 	p, err := DiscoverPlatformsFromReference(manifestRef)
 	if err != nil {
@@ -197,11 +216,11 @@ func DiscoverPlatforms(manifestRef, reportDir, scanner string) ([]ispec.Platform
 
 		reportSet := make(map[string]struct{}, len(p2))
 		for _, pl := range p2 {
-			reportSet[key(pl)] = struct{}{}
+			reportSet[key(pl.Platform)] = struct{}{}
 		}
 
 		for _, pl := range p {
-			if _, ok := reportSet[key(pl)]; ok {
+			if _, ok := reportSet[key(pl.Platform)]; ok {
 				platforms = append(platforms, pl)
 			}
 		}
@@ -334,3 +353,101 @@ func WithFileString(s *llb.State, path, contents string) llb.State {
 func WithFileBytes(s *llb.State, path string, contents []byte) llb.State {
 	return s.File(llb.Mkfile(path, 0o600, contents))
 }
+
+func QemuAvailable(p *types.PatchPlatform) bool {
+	if p == nil {
+		return false
+	}
+
+	archKey := mapGoArch(p.Architecture, p.Variant)
+
+	// walk binfmt_misc entries
+	entries, _ := readDir("/proc/sys/fs/binfmt_misc")
+	for _, e := range entries {
+		if e.IsDir() || e.Name() == "register" || e.Name() == "status" {
+			continue
+		}
+		data, _ := readFile("/proc/sys/fs/binfmt_misc/" + e.Name())
+		if bytes.Contains(data, []byte("interpreter")) &&
+			bytes.Contains(data, []byte("qemu-"+archKey)) {
+			return true
+		}
+	}
+	// fallback to interpreter binary on PATH (for rootless case)
+	if _, err := lookPath("qemu-" + archKey + "-static"); err == nil {
+		return true
+	}
+	return false
+}
+
+func mapGoArch(arch, variant string) string {
+	switch arch {
+	case "amd64", "amd64p32":
+		return "x86_64"
+
+	case "386":
+		return "i386"
+
+	case "arm64", "arm64be":
+		return "aarch64"
+
+	case "arm":
+		// GOARM=5/6/7 -> qemu-arm
+		// big-endian -> qemu-armeb
+		if strings.HasSuffix(variant, "eb") || strings.HasSuffix(arch, "be") {
+			return "armeb"
+		}
+		return "arm"
+
+	case "mips":
+		if strings.HasSuffix(arch, "le") {
+			return "mipsel"
+		}
+		return "mips"
+
+	case "mips64":
+		if strings.HasSuffix(variant, "n32") {
+			return "mipsn32"
+		}
+		if strings.HasSuffix(arch, "le") {
+			return "mips64el"
+		}
+		return "mips64"
+
+	case "mips64le":
+		if strings.HasSuffix(variant, "n32") {
+			return "mipsn32el"
+		}
+		return "mips64el"
+
+	case "ppc64":
+		if strings.HasSuffix(variant, "le") {
+			return "ppc64le"
+		}
+		return "ppc64"
+
+	case "loong64":
+		return "loongarch64"
+
+	case "sh4":
+		if strings.HasSuffix(variant, "eb") {
+			return "sh4eb"
+		}
+		return "sh4"
+
+	case "xtensa":
+		if strings.HasSuffix(variant, "eb") {
+			return "xtensaeb"
+		}
+		return "xtensa"
+
+	case "microblaze":
+		if strings.HasSuffix(variant, "el") {
+			return "microblazeel"
+		}
+		return "microblaze"
+	}
+
+	// fallback: hope QEMU name == GOARCH
+	return arch
+}
diff --git a/pkg/patch/patch.go b/pkg/patch/patch.go
index be2676f3..53a6836e 100644
--- a/pkg/patch/patch.go
+++ b/pkg/patch/patch.go
@@ -9,7 +9,10 @@ import (
 	"fmt"
 	"io"
 	"os"
+	"os/exec"
+	"runtime"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/containerd/platforms"
@@ -31,6 +34,7 @@ import (
 	"github.com/project-copacetic/copacetic/pkg/buildkit/connhelpers"
 	"github.com/project-copacetic/copacetic/pkg/pkgmgr"
 	"github.com/project-copacetic/copacetic/pkg/report"
+	"github.com/project-copacetic/copacetic/pkg/types"
 	"github.com/project-copacetic/copacetic/pkg/types/unversioned"
 	"github.com/project-copacetic/copacetic/pkg/utils"
 	"github.com/project-copacetic/copacetic/pkg/vex"
@@ -51,6 +55,55 @@ var (
 	bkNewClient = buildkit.NewClient
 )
 
+type archDigest struct {
+	tag    string
+	digest string
+	plat   types.PatchPlatform
+}
+
+// archTag returns "patched-arm64" or "patched-arm-v7" etc.
+func archTag(base, arch, variant string) string {
+	if variant != "" {
+		return fmt.Sprintf("%s-%s-%s", base, arch, variant)
+	}
+	return fmt.Sprintf("%s-%s", base, arch)
+}
+
+// createManifestCLI creates a manifest list for the given image and final tag.
+func createManifestCLI(ctx context.Context, image string, finalTag string, items []archDigest) error {
+	ref, _ := reference.ParseNormalizedNamed(image)
+	repo := ref.Name()
+	args := []string{"buildx", "imagetools", "create", "--tag", fmt.Sprintf("%s:%s", repo, finalTag)}
+	for i := range items {
+		ref := fmt.Sprintf("%s@sha256:%s", items[i].tag, items[i].digest)
+		args = append(args, ref)
+	}
+	cmd := exec.CommandContext(ctx, "docker", args...)
+	cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+	return cmd.Run()
+}
+
+func normalizeConfigForPlatform(j []byte, p *types.PatchPlatform) ([]byte, error) {
+	if p == nil {
+		return j, fmt.Errorf("platform is nil")
+	}
+
+	var m map[string]any
+	if err := json.Unmarshal(j, &m); err != nil {
+		return nil, err
+	}
+
+	m["architecture"] = p.Architecture
+	if p.Variant != "" {
+		m["variant"] = p.Variant
+	} else {
+		delete(m, "variant")
+	}
+	m["os"] = p.OS
+
+	return json.Marshal(m)
+}
+
 // Patch command applies package updates to an OCI image given a vulnerability report.
 func Patch(
 	ctx context.Context, timeout time.Duration,
@@ -88,30 +141,113 @@ func removeIfNotDebug(workingFolder string) {
 	}
 }
 
-//nolint:lll
-func patchWithContext(ctx context.Context, ch chan error, image, reportFile, reportDirectory, platformSpecificErrors, patchedTag, suffix, workingFolder, scanner, format, output string, ignoreError, push bool, bkOpts buildkit.Opts) error {
+func patchWithContext(
+	ctx context.Context,
+	ch chan error,
+	image, reportFile, reportDirectory, platformSpecificErrors, patchedTag, suffix, workingFolder, scanner, format, output string,
+	ignoreError, push bool,
+	bkOpts buildkit.Opts,
+) error {
 	log.Debugf("Handling platform specific errors with %s", platformSpecificErrors)
+	if reportFile != "" && reportDirectory != "" {
+		return fmt.Errorf("both report file and directory provided, please provide only one")
+	}
+
+	// try report file
+	if reportFile != "" {
+		// check if reportFile exists
+		if _, err := os.Stat(reportFile); os.IsNotExist(err) {
+			return fmt.Errorf("report file %s does not exist", reportFile)
+		}
+		// check if reportFile is a file
+		f, err := os.Stat(reportFile)
+		if err != nil {
+			// handle common errors
+			if os.IsNotExist(err) {
+				return fmt.Errorf("report file %s does not exist", reportFile)
+			}
+			return fmt.Errorf("failed to stat report file %s: %w", reportFile, err)
+		}
+		if f.IsDir() {
+			return fmt.Errorf("report file %s is a directory, please provide a file", reportFile)
+		}
+		log.Debugf("Using report file: %s", reportFile)
+		platform := types.PatchPlatform{
+			Platform: platforms.Normalize(platforms.DefaultSpec()),
+		}
+		if platform.OS != "linux" {
+			platform.OS = "linux"
+		}
+		result, err := patchSingleArchImage(ctx, ch, image, reportFile, patchedTag, suffix, workingFolder, scanner, format, output, platform, ignoreError, push, bkOpts, false)
+		if err == nil && result != nil {
+			log.Infof("Patched image (%s): %s\n", platform.OS+"/"+platform.Architecture, result.PatchedImage)
+		}
+		return err
+	} else if reportDirectory == "" && reportFile == "" {
+		platform := types.PatchPlatform{
+			Platform: platforms.Normalize(platforms.DefaultSpec()),
+		}
+		result, err := patchSingleArchImage(ctx, ch, image, reportFile, patchedTag, suffix, workingFolder, scanner, format, output, platform, ignoreError, push, bkOpts, false)
+		if err == nil && result != nil {
+			log.Infof("Patched image (%s): %s\n", platform.OS+"/"+platform.Architecture, result.PatchedImage)
+		}
+		return err
+	}
 
+	// must be dealing with a directory
+	f, err := os.Stat(reportDirectory)
+	if err != nil {
+		return err
+	}
+	if !f.IsDir() {
+		return fmt.Errorf("provided report directory path %s is not a directory", reportDirectory)
+	}
+
+	return patchMultiArchImage(ctx, ch, platformSpecificErrors, image, reportDirectory, patchedTag, suffix, workingFolder, scanner, format, output, ignoreError, push, bkOpts)
+}
+
+func patchSingleArchImage(
+	ctx context.Context,
+	ch chan error,
+	image, reportFile, patchedTag, suffix, workingFolder, scanner, format, output string,
+	//nolint:gocritic
+	targetPlatform types.PatchPlatform,
+	ignoreError, push bool,
+	bkOpts buildkit.Opts,
+	multiArch bool,
+) (*types.PatchResult, error) {
 	if reportFile == "" && output != "" {
 		log.Warn("No vulnerability report was provided, so no VEX output will be generated.")
 	}
 
+	// if the target platform is different from the host platform, we need to check if emulation is enabled
+	osEqual := platforms.DefaultSpec().OS == targetPlatform.OS
+	archEqual := platforms.DefaultSpec().Architecture == targetPlatform.Architecture
+	if osEqual && archEqual {
+		log.Debugf("Host platform %+v matches target platform %+v", platforms.DefaultSpec(), targetPlatform)
+	} else {
+		log.Debugf("Host platform %+v does not match target platform %+v", platforms.DefaultSpec(), targetPlatform)
+		// check if emulation is enabled
+
+		if emulationEnabled := buildkit.QemuAvailable(&targetPlatform); !emulationEnabled {
+			return nil, fmt.Errorf("emulation is not enabled for platform %s", targetPlatform.OS+"/"+targetPlatform.Architecture)
+		}
+		log.Debugf("Emulation is enabled for platform %+v", targetPlatform)
+	}
+
 	// parse the image reference
 	imageName, err := reference.ParseNormalizedNamed(image)
 	if err != nil {
-		return fmt.Errorf("failed to parse reference: %w", err)
+		return nil, fmt.Errorf("failed to parse reference: %w", err)
 	}
 
 	// resolve final patched tag
 	patchedTag, err = resolvePatchedTag(imageName, patchedTag, suffix)
 	if err != nil {
-		return err
+		return nil, err
 	}
-
-	// create the patched image name
-	_, err = reference.WithTag(imageName, patchedTag)
-	if err != nil {
-		return fmt.Errorf("invalid patched tag: %w with patched tag %s", err, patchedTag)
+	if multiArch {
+		patchedTag = archTag(patchedTag, targetPlatform.Architecture, targetPlatform.Variant)
 	}
 	patchedImageName := fmt.Sprintf("%s:%s", imageName.Name(), patchedTag)
 
@@ -120,16 +256,16 @@ func patchWithContext(ctx context.Context, ch chan error, image, reportFile, rep
 		var err error
 		workingFolder, err = os.MkdirTemp("", "copa-*")
 		if err != nil {
-			return err
+			return nil, err
 		}
 		defer removeIfNotDebug(workingFolder)
 		if err = os.Chmod(workingFolder, 0o744); err != nil {
-			return err
+			return nil, err
 		}
 	} else {
 		if isNew, err := utils.EnsurePath(workingFolder, 0o744); err != nil {
 			log.Errorf("failed to create workingFolder %s", workingFolder)
-			return err
+			return nil, err
 		} else if isNew {
 			defer removeIfNotDebug(workingFolder)
 		}
@@ -140,14 +276,14 @@ func patchWithContext(ctx context.Context, ch chan error, image, reportFile, rep
 	if reportFile != "" {
 		updates, err = report.TryParseScanReport(reportFile, scanner)
 		if err != nil {
-			return err
+			return nil, err
 		}
 		log.Debugf("updates to apply: %v", updates)
 	}
 
 	bkClient, err := bkNewClient(ctx, bkOpts)
 	if err != nil {
-		return err
+		return nil, err
 	}
 	defer bkClient.Close()
 
@@ -214,7 +350,7 @@ func patchWithContext(ctx context.Context, ch chan error, image, reportFile, rep
 	}
 	solveOpt.SourcePolicy, err = build.ReadSourcePolicy()
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	if solveOpt.SourcePolicy != nil {
@@ -222,20 +358,24 @@ func patchWithContext(ctx context.Context, ch chan error, image, reportFile, rep
 		case strings.Contains(solveOpt.SourcePolicy.Rules[0].Updates.Identifier, "redhat"):
 			err = errors.New("RedHat is not supported via source policies due to BusyBox not being in the RHEL repos\n" +
 				"Please use a different RPM-based image")
-			return err
+			return nil, err
 
 		case strings.Contains(solveOpt.SourcePolicy.Rules[0].Updates.Identifier, "rockylinux"):
 			err = errors.New("RockyLinux is not supported via source policies due to BusyBox not being in the RockyLinux repos\n" +
 				"Please use a different RPM-based image")
-			return err
+			return nil, err
 
 		case strings.Contains(solveOpt.SourcePolicy.Rules[0].Updates.Identifier, "alma"):
 			err = errors.New("AlmaLinux is not supported via source policies due to BusyBox not being in the AlmaLinux repos\n" +
 				"Please use a different RPM-based image")
-			return err
+			return nil, err
 		}
 	}
 
+	// create a variable to hold the patched image digest
+	var patchedImageDigest string
+
+	// Create a channel to receive the patched image digest
 	buildChannel := make(chan *client.SolveStatus)
 	eg, ctx := errgroup.WithContext(ctx)
 	eg.Go(func() error {
@@ -265,10 +405,6 @@ func patchWithContext(ctx context.Context, ch chan error, image, reportFile, rep
 				return nil, err
 			}
 
-			// Discover platforms for multi-arch images
-			test, err := buildkit.DiscoverPlatforms(imageName.String(), reportDirectory, scanner)
-			fmt.Printf("Testing DiscoverPlatforms Utility - test: %q, err: %q", test, err)
-
 			// Create package manager helper
 			var manager pkgmgr.PackageManager
 			if reportFile == "" {
@@ -313,12 +449,7 @@ func patchWithContext(ctx context.Context, ch chan error, image, reportFile, rep
 				return nil, err
 			}
 
-			platform := platforms.Normalize(platforms.DefaultSpec())
-			if platform.OS != "linux" {
-				platform.OS = "linux"
-			}
-
-			def, err := patchedImageState.Marshal(ctx, llb.Platform(platform))
+			def, err := patchedImageState.Marshal(ctx, llb.Platform(targetPlatform.Platform))
 			if err != nil {
 				ch <- err
 				return nil, fmt.Errorf("unable to get platform from ImageState %w", err)
@@ -333,7 +464,12 @@ func patchWithContext(ctx context.Context, ch chan error, image, reportFile, rep
 				return nil, err
 			}
 
-			res.AddMeta(exptypes.ExporterImageConfigKey, config.ConfigData)
+			fixed, err := normalizeConfigForPlatform(config.ConfigData, &targetPlatform)
+			if err != nil {
+				ch <- err
+				return nil, err
+			}
+			res.AddMeta(exptypes.ExporterImageConfigKey, fixed)
 
 			// for the vex document, only include updates that were successfully applied
 			pkgType = manager.GetPackageType()
@@ -351,12 +487,15 @@ func patchWithContext(ctx context.Context, ch chan error, image, reportFile, rep
 		// Currently can only validate updates if updating via scanner
 		if err == nil && solveResponse != nil && reportFile != "" && validatedManifest != nil {
 			digest := solveResponse.ExporterResponse[exptypes.ExporterImageDigestKey]
-			nameDigestOrTag := getRepoNameWithDigest(patchedImageName, digest)
-			// vex document must contain at least one statement
-			if output != "" && len(validatedManifest.Updates) > 0 {
-				if err := vex.TryOutputVexDocument(validatedManifest, pkgType, nameDigestOrTag, format, output); err != nil {
-					ch <- err
-					return err
+			patchedImageDigest = digest
+			if reportFile != "" && validatedManifest != nil {
+				nameDigestOrTag := getRepoNameWithDigest(patchedImageName, digest)
+				// vex document must contain at least one statement
+				if output != "" && len(validatedManifest.Updates) > 0 {
+					if err := vex.TryOutputVexDocument(validatedManifest, pkgType, nameDigestOrTag, format, output); err != nil {
+						ch <- err
+						return err
+					}
 				}
 			}
 		}
@@ -397,15 +536,30 @@ func patchWithContext(ctx context.Context, ch chan error, image, reportFile, rep
 			return pipeR.Close()
 		})
 	} else {
-		// when pushing, we need to close the pipe reader since we wont use it
 		go func() {
 			pipeR.Close()
 		}()
 	}
 
 	err = eg.Wait()
+	if err != nil {
+		return nil, err
+	}
 
-	return err
+	// Get the solve response from channel
+	if patchedImageDigest == "" && !push {
+		if d, err := utils.GetLocalImageDigest(ctx, patchedImageName); err == nil {
+			patchedImageDigest = d
+		} else {
+			log.Errorf("failed to get image digest: %v", err)
+		}
+	}
+
+	return &types.PatchResult{
+		OriginalImage: image,
+		PatchedImage:  patchedImageName,
+		Digest:        patchedImageDigest,
+	}, nil
 }
 
 // resolvePatchedTag merges explicit tag & suffix rules, returning the final patched tag.
@@ -564,3 +718,78 @@ func getRepoNameWithDigest(patchedImageName, imageDigest string) string {
 	nameWithDigest := fmt.Sprintf("%s@%s", last, imageDigest)
 	return nameWithDigest
 }
+
+func patchMultiArchImage(
+	ctx context.Context,
+	ch chan error,
+	platformSpecificErrors, image, reportDir, patchedTag, suffix, workingFolder, scanner, format, output string,
+	ignoreError, push bool,
+	bkOpts buildkit.Opts,
+) error {
+	platforms, err := buildkit.DiscoverPlatforms(image, reportDir, scanner)
+	if err != nil {
+		return err
+	}
+	if len(platforms) == 0 {
+		return fmt.Errorf("no patchable platforms found for image %s", image)
+	}
+
+	maxParallel := runtime.NumCPU()
+	sem := make(chan struct{}, maxParallel)
+	g, gctx := errgroup.WithContext(ctx)
+
+	var mu sync.Mutex
+	archDigests := []archDigest{}
+
+	handlePlatformErr := func(p types.PatchPlatform, err error) error {
+		switch platformSpecificErrors {
+		case "ignore":
+			return nil
+		case "skip":
+			log.Warnf("Ignoring error for platform %s: %v", p.OS+"/"+p.Architecture, err)
+			return nil
+		default:
+			return fmt.Errorf("platform %s failed: %w", p.OS+"/"+p.Architecture, err)
+		}
+	}
+
+	for _, p := range platforms {
+		g.Go(func() error {
+			sem <- struct{}{}
+			defer func() { <-sem }()
+
+			res, err := patchSingleArchImage(gctx, ch, image, p.ReportFile, patchedTag, suffix, workingFolder, scanner, format, output, p, ignoreError, push, bkOpts, true)
+			if err != nil {
+				return handlePlatformErr(p, err)
+			}
+
+			mu.Lock()
+			archDigests = append(archDigests, archDigest{tag: res.PatchedImage, digest: strings.TrimPrefix(res.Digest, "sha256:"), plat: p})
+			mu.Unlock()
+			log.Infof("Patched image (%s): %s\n", p.OS+"/"+p.Architecture, res.PatchedImage)
+			return nil
+		})
+	}
+
+	if err := g.Wait(); err != nil {
+		return err
+	}
+
+	// resolve image ref
+	imageName, err := reference.ParseNormalizedNamed(image)
+	if err != nil {
+		return fmt.Errorf("failed to parse reference: %w", err)
+	}
+
+	resolvedTag, err := resolvePatchedTag(imageName, patchedTag, suffix)
+	if err != nil {
+		return err
+	}
+
+	if push {
+		if err := createManifestCLI(ctx, image, resolvedTag, archDigests); err != nil {
+			return fmt.Errorf("manifest list creation failed: %w", err)
+		}
+	}
+	return nil
+}
diff --git a/pkg/types/types.go b/pkg/types/types.go
index 81c306e4..d32fbd45 100644
--- a/pkg/types/types.go
+++ b/pkg/types/types.go
@@ -1,5 +1,9 @@
 package types
 
+import (
+	ispec "github.com/opencontainers/image-spec/specs-go/v1"
+)
+
 type UpdatePackage struct {
 	Name             string `json:"name"`
 	InstalledVersion string `json:"installedVersion"`
@@ -15,3 +19,16 @@ type UpdateManifest struct {
 	Arch      string         `json:"arch"`
 	Updates   UpdatePackages `json:"updates"`
 }
+
+// PatchPlatform is an extension of ispec.Platform but with a reportFile.
+type PatchPlatform struct {
+	ispec.Platform
+	ReportFile string `json:"reportFile"`
+}
+
+// PatchResult represents the result of a single arch patch operation.
+type PatchResult struct {
+	OriginalImage string
+	PatchedImage  string
+	Digest        string
+}
diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go
index 068f3b7e..05b38f54 100644
--- a/pkg/utils/utils.go
+++ b/pkg/utils/utils.go
@@ -1,6 +1,8 @@
 package utils
 
 import (
+	"context"
+	"errors"
 	"io/fs"
 	"os"
 	"path/filepath"
@@ -52,3 +54,20 @@ func GetProxy() llb.ProxyEnv {
 	}
 	return proxy
 }
+
+func GetLocalImageDigest(ctx context.Context, imageRef string) (string, error) {
+	cli, err := newClient()
+	if err != nil {
+		return "", err
+	}
+	defer cli.Close()
+
+	distInspect, err := cli.ImageInspect(ctx, imageRef)
+	if err != nil {
+		return "", err
+	}
+	if distInspect.Descriptor == nil {
+		return "", errors.New("descriptor is nil")
+	}
+	return distInspect.Descriptor.Digest.String(), nil
+}


Test patch:
diff --git a/pkg/buildkit/buildkit_test.go b/pkg/buildkit/buildkit_test.go
index 6e5d9ce1..9108cc7c 100644
--- a/pkg/buildkit/buildkit_test.go
+++ b/pkg/buildkit/buildkit_test.go
@@ -4,20 +4,24 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
+	"io/fs"
 	"net"
+	"os"
 	"path/filepath"
 	"reflect"
 	"testing"
 	"time"
 
 	"github.com/opencontainers/go-digest"
+	ispec "github.com/opencontainers/image-spec/specs-go/v1"
 
 	"github.com/project-copacetic/copacetic/mocks"
+	"github.com/project-copacetic/copacetic/pkg/types"
 
 	"github.com/stretchr/testify/mock"
 
 	controlapi "github.com/moby/buildkit/api/services/control"
-	types "github.com/moby/buildkit/api/types"
+	bk_types "github.com/moby/buildkit/api/types"
 	gateway "github.com/moby/buildkit/frontend/gateway/pb"
 	"github.com/moby/buildkit/util/apicaps"
 	caps "github.com/moby/buildkit/util/apicaps/pb"
@@ -31,7 +35,7 @@ type mockControlServer struct {
 
 func (s *mockControlServer) ListWorkers(context.Context, *controlapi.ListWorkersRequest) (*controlapi.ListWorkersResponse, error) {
 	return &controlapi.ListWorkersResponse{
-		Record: []*types.WorkerRecord{},
+		Record: []*bk_types.WorkerRecord{},
 	}, nil
 }
 
@@ -394,3 +398,119 @@ func TestUpdateImageConfigData(t *testing.T) {
 		mockClient.AssertExpectations(t)
 	})
 }
+
+func TestMapGoArch(t *testing.T) {
+	cases := []struct {
+		arch, variant, want string
+	}{
+		{"amd64", "", "x86_64"},
+		{"386", "", "i386"},
+		{"arm", "v7", "arm"},
+		{"arm", "v5eb", "armeb"},
+		{"mips64", "n32", "mipsn32"},
+		{"mips64", "", "mips64"},
+		{"ppc64", "le", "ppc64le"},
+		{"loong64", "", "loongarch64"},
+		{"xtensa", "eb", "xtensaeb"},
+		{"unknown", "", "unknown"},
+	}
+	for _, c := range cases {
+		got := mapGoArch(c.arch, c.variant)
+		if got != c.want {
+			t.Errorf("mapGoArch(%q,%q) = %q, want %q", c.arch, c.variant, got, c.want)
+		}
+	}
+}
+
+func TestIsSupportedOsType(t *testing.T) {
+	supported := []string{"alpine", "debian", "ubuntu", "cbl-mariner", "azurelinux", "centos", "oracle", "redhat", "rocky", "amazon", "alma"}
+	for _, os := range supported {
+		if !isSupportedOsType(os) {
+			t.Errorf("expected %s to be supported", os)
+		}
+	}
+	unsupported := []string{"windows", "freebsd", "plan9"}
+	for _, os := range unsupported {
+		if isSupportedOsType(os) {
+			t.Errorf("did not expect %s to be supported", os)
+		}
+	}
+}
+
+// minimal DirEntry impl.
+type fakeEntry string
+
+func (f fakeEntry) Name() string             { return string(f) }
+func (fakeEntry) IsDir() bool                { return false }
+func (fakeEntry) Type() fs.FileMode          { return 0 }
+func (fakeEntry) Info() (fs.FileInfo, error) { return nil, nil }
+
+func TestQemuAvailable_Mocked(t *testing.T) {
+	platArm := &types.PatchPlatform{Platform: ispec.Platform{OS: "linux", Architecture: "arm64"}}
+	platAmd := &types.PatchPlatform{Platform: ispec.Platform{OS: "linux", Architecture: "amd64"}}
+
+	tests := []struct {
+		name     string
+		plat     *types.PatchPlatform
+		stubDir  func(string) ([]os.DirEntry, error)
+		stubRead func(string) ([]byte, error)
+		stubPath func(string) (string, error)
+		want     bool
+	}{
+		{
+			name: "nil platform", plat: nil,
+			want: false,
+		},
+		{
+			name: "binfmt_misc match", plat: platArm,
+			stubDir:  func(string) ([]os.DirEntry, error) { return []os.DirEntry{fakeEntry("arm")}, nil },
+			stubRead: func(string) ([]byte, error) { return []byte("interpreter /usr/bin/qemu-aarch64"), nil },
+			stubPath: func(string) (string, error) { return "", os.ErrNotExist },
+			want:     true,
+		},
+		{
+			name: "lookPath fallback", plat: platArm,
+			stubDir:  func(string) ([]os.DirEntry, error) { return []os.DirEntry{}, nil },
+			stubRead: func(string) ([]byte, error) { return nil, nil },
+			stubPath: func(string) (string, error) { return "/usr/bin/qemu-aarch64-static", nil },
+			want:     true,
+		},
+		{
+			name: "no match at all", plat: platAmd,
+			stubDir:  func(string) ([]os.DirEntry, error) { return []os.DirEntry{}, nil },
+			stubRead: func(string) ([]byte, error) { return nil, nil },
+			stubPath: func(string) (string, error) { return "", os.ErrNotExist },
+			want:     false,
+		},
+	}
+
+	// store originals
+	origDir, origRead, origPath := readDir, readFile, lookPath
+	defer func() { readDir, readFile, lookPath = origDir, origRead, origPath }()
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			// override or reset mocks per case
+			if tc.stubDir != nil {
+				readDir = tc.stubDir
+			} else {
+				readDir = origDir
+			}
+			if tc.stubRead != nil {
+				readFile = tc.stubRead
+			} else {
+				readFile = origRead
+			}
+			if tc.stubPath != nil {
+				lookPath = tc.stubPath
+			} else {
+				lookPath = origPath
+			}
+
+			got := QemuAvailable(tc.plat)
+			if got != tc.want {
+				t.Fatalf("QemuAvailable() = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
diff --git a/pkg/patch/patch_test.go b/pkg/patch/patch_test.go
index 0731843e..94f16c9e 100644
--- a/pkg/patch/patch_test.go
+++ b/pkg/patch/patch_test.go
@@ -2,6 +2,7 @@ package patch
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"os"
 	"strings"
@@ -10,6 +11,7 @@ import (
 
 	"github.com/distribution/reference"
 	"github.com/project-copacetic/copacetic/pkg/buildkit"
+	"github.com/project-copacetic/copacetic/pkg/types"
 	log "github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 
@@ -525,3 +527,57 @@ func TestPatch_BuildReturnsNilResponse(t *testing.T) {
 
 	t.Logf("Patch returned error as expected (and did not panic): %v", err)
 }
+
+func TestArchTag(t *testing.T) {
+	cases := []struct {
+		base, arch, variant, want string
+	}{
+		{"patched", "arm64", "", "patched-arm64"},
+		{"patched", "arm", "v7", "patched-arm-v7"},
+		{"patched", "mips64", "n32", "patched-mips64-n32"},
+	}
+	for _, c := range cases {
+		got := archTag(c.base, c.arch, c.variant)
+		if got != c.want {
+			t.Fatalf("archTag(%q,%q,%q) = %q, want %q", c.base, c.arch, c.variant, got, c.want)
+		}
+	}
+}
+
+const LINUX = "linux"
+
+func TestNormalizeConfigForPlatform(t *testing.T) {
+	// minimal starting config (missing fields on purpose)
+	orig := []byte(`{"architecture":"amd64"}`)
+
+	plat := &types.PatchPlatform{}
+	plat.OS = LINUX
+	plat.Architecture = "arm64"
+	plat.Variant = "v8"
+
+	fixed, err := normalizeConfigForPlatform(orig, plat)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	var m map[string]string
+	if err := json.Unmarshal(fixed, &m); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+
+	if m["architecture"] != "arm64" || m["os"] != LINUX || m["variant"] != "v8" {
+		t.Fatalf("fields not normalised correctly: %#v", m)
+	}
+
+	// when Variant empty, key should be removed
+	var m2 map[string]string
+	plat.Variant = ""
+	fixed, _ = normalizeConfigForPlatform(orig, plat)
+	err = json.Unmarshal(fixed, &m2)
+	if err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	if _, ok := m2["variant"]; ok {
+		t.Errorf("variant key should be dropped when empty")
+	}
+}