From 94835abe6b3d679962743d8365892e3e46b29f5d Mon Sep 17 00:00:00 2001 From: ChengyuZhu6 Date: Fri, 22 May 2026 10:44:31 +0800 Subject: [PATCH] image: align erofs convert with containerd Add raw and zstd EROFS conversion support with containerd's EROFS converter. Fixes: #4861 Signed-off-by: Chengyu Zhu --- cmd/nerdctl/image/image_convert.go | 26 ++++++ cmd/nerdctl/image/image_convert_linux_test.go | 43 ++++++++-- cmd/nerdctl/image/image_inspect.go | 10 ++- docs/command-reference.md | 4 +- docs/erofs.md | 53 ++++++++++++ go.mod | 1 + pkg/api/types/image_types.go | 11 +++ pkg/cmd/image/convert.go | 37 ++++++++- pkg/cmd/image/prune.go | 8 +- pkg/cmd/image/remove.go | 10 ++- pkg/imgutil/push/push.go | 7 ++ pkg/imgutil/transfer.go | 4 +- pkg/platformutil/platformutil.go | 82 ++++++++++++++++++- 13 files changed, 276 insertions(+), 20 deletions(-) create mode 100644 docs/erofs.md diff --git a/cmd/nerdctl/image/image_convert.go b/cmd/nerdctl/image/image_convert.go index 105dd0ea161..e4a3cf1f252 100644 --- a/cmd/nerdctl/image/image_convert.go +++ b/cmd/nerdctl/image/image_convert.go @@ -97,6 +97,12 @@ func convertCommand() *cobra.Command { cmd.Flags().Int64("soci-span-size", -1, "The size of SOCI spans") // #endregion + // #region erofs flags + cmd.Flags().String("erofs", "", "Convert image layers to EROFS media type. Supported values: raw, zstd") + cmd.Flags().String("erofs-compressors", "", "Specify mkfs.erofs compressor options (e.g. 'lz4hc,12')") + cmd.Flags().String("erofs-mkfs-options", "", "Specify extra mkfs.erofs options (e.g. '-T0 --mkfs-time')") + // #endregion + // #region generic flags cmd.Flags().Bool("uncompress", false, "Convert tar.gz layers to uncompressed tar layers") cmd.Flags().Bool("oci", false, "Convert Docker media types to OCI media types") @@ -248,6 +254,21 @@ func convertOptions(cmd *cobra.Command) (types.ImageConvertOptions, error) { } // #endregion + // #region erofs flags + erofs, err := cmd.Flags().GetString("erofs") + if err != nil { + return types.ImageConvertOptions{}, err + } + erofsCompressors, err := cmd.Flags().GetString("erofs-compressors") + if err != nil { + return types.ImageConvertOptions{}, err + } + erofsMkfsOptions, err := cmd.Flags().GetString("erofs-mkfs-options") + if err != nil { + return types.ImageConvertOptions{}, err + } + // #endregion + // #region generic flags uncompress, err := cmd.Flags().GetBool("uncompress") if err != nil { @@ -323,6 +344,11 @@ func convertOptions(cmd *cobra.Command) (types.ImageConvertOptions, error) { AllPlatforms: allPlatforms, }, }, + ErofsOptions: types.ErofsOptions{ + Erofs: erofs, + ErofsCompressors: erofsCompressors, + ErofsMkfsOptions: erofsMkfsOptions, + }, ProgressOutput: progressOutput, Stdout: cmd.OutOrStdout(), }, nil diff --git a/cmd/nerdctl/image/image_convert_linux_test.go b/cmd/nerdctl/image/image_convert_linux_test.go index 629db629920..9c10b247288 100644 --- a/cmd/nerdctl/image/image_convert_linux_test.go +++ b/cmd/nerdctl/image/image_convert_linux_test.go @@ -21,6 +21,7 @@ import ( "testing" "time" + "github.com/containerd/nerdctl/mod/tigron/expect" "github.com/containerd/nerdctl/mod/tigron/require" "github.com/containerd/nerdctl/mod/tigron/test" @@ -52,7 +53,7 @@ func TestImageConvert(t *testing.T) { return helpers.Command("image", "convert", "--oci", "--estargz", testutil.CommonImage, data.Identifier("converted-image")) }, - Expected: test.Expects(0, nil, nil), + Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), }, { Description: "nydus", @@ -66,7 +67,7 @@ func TestImageConvert(t *testing.T) { return helpers.Command("image", "convert", "--oci", "--nydus", testutil.CommonImage, data.Identifier("converted-image")) }, - Expected: test.Expects(0, nil, nil), + Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), }, { Description: "zstd", @@ -77,7 +78,7 @@ func TestImageConvert(t *testing.T) { return helpers.Command("image", "convert", "--oci", "--zstd", "--zstd-compression-level", "3", testutil.CommonImage, data.Identifier("converted-image")) }, - Expected: test.Expects(0, nil, nil), + Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), }, { Description: "zstdchunked", @@ -88,7 +89,35 @@ func TestImageConvert(t *testing.T) { return helpers.Command("image", "convert", "--oci", "--zstdchunked", "--zstdchunked-compression-level", "3", testutil.CommonImage, data.Identifier("converted-image")) }, - Expected: test.Expects(0, nil, nil), + Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), + }, + { + Description: "erofs raw", + Require: require.All( + require.Binary("mkfs.erofs"), + ), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier("converted-image")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("image", "convert", "--oci", "--erofs", "raw", + testutil.CommonImage, data.Identifier("converted-image")) + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), + }, + { + Description: "erofs zstd", + Require: require.All( + require.Binary("mkfs.erofs"), + ), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier("converted-image")) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("image", "convert", "--oci", "--erofs", "zstd", + testutil.CommonImage, data.Identifier("converted-image")) + }, + Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), }, { Description: "soci", @@ -107,7 +136,7 @@ func TestImageConvert(t *testing.T) { "--soci-min-layer-size", "0", testutil.CommonImage, data.Identifier("converted-image")) }, - Expected: test.Expects(0, nil, nil), + Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), }, { Description: "soci with all-platforms", @@ -126,7 +155,7 @@ func TestImageConvert(t *testing.T) { "--soci-min-layer-size", "0", testutil.CommonImage, data.Identifier("converted-image")) }, - Expected: test.Expects(0, nil, nil), + Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), }, }, } @@ -188,7 +217,7 @@ func TestImageConvertNydusVerify(t *testing.T) { cmd.WithTimeout(30 * time.Second) return cmd }, - Expected: test.Expects(0, nil, nil), + Expected: test.Expects(expect.ExitCodeSuccess, nil, nil), } testCase.Run(t) diff --git a/cmd/nerdctl/image/image_inspect.go b/cmd/nerdctl/image/image_inspect.go index 023f23f9650..c56c0f09795 100644 --- a/cmd/nerdctl/image/image_inspect.go +++ b/cmd/nerdctl/image/image_inspect.go @@ -21,12 +21,16 @@ import ( "github.com/spf13/cobra" + containerd "github.com/containerd/containerd/v2/client" + "github.com/containerd/platforms" + "github.com/containerd/nerdctl/v2/cmd/nerdctl/completion" "github.com/containerd/nerdctl/v2/cmd/nerdctl/helpers" "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/clientutil" "github.com/containerd/nerdctl/v2/pkg/cmd/image" "github.com/containerd/nerdctl/v2/pkg/formatter" + "github.com/containerd/nerdctl/v2/pkg/platformutil" ) func inspectCommand() *cobra.Command { @@ -97,7 +101,11 @@ func imageInspectAction(cmd *cobra.Command, args []string) error { return fmt.Errorf("unknown mode %q", options.Mode) } - client, ctx, cancel, err := clientutil.NewClientWithPlatform(cmd.Context(), options.GOptions.Namespace, options.GOptions.Address, options.Platform) + var clientOpts []containerd.Opt + if options.Platform == "" { + clientOpts = append(clientOpts, containerd.WithDefaultPlatform(platformutil.IgnoreOSFeaturesMatcher(platforms.Default(), platformutil.ErofsOSFeature))) + } + client, ctx, cancel, err := clientutil.NewClientWithPlatform(cmd.Context(), options.GOptions.Namespace, options.GOptions.Address, options.Platform, clientOpts...) if err != nil { return err } diff --git a/docs/command-reference.md b/docs/command-reference.md index 55813aad960..450244e594a 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1017,6 +1017,9 @@ Flags: - `--overlaybd-fs-type=` : filesystem type for overlaybd (default: `ext4`) - `--overlaybd-dbstr=` : database config string for overlaybd - `--overlaybd-vsize=` : virtual block device size in GB for overlaybd (default: 64) +- `--erofs=` : convert image layers to EROFS media type. Supported values: `raw`, `zstd` (see [`./erofs.md`](./erofs.md)) +- `--erofs-compressors=` : specify mkfs.erofs compressor options, e.g. `lz4hc,12` +- `--erofs-mkfs-options=` : specify extra mkfs.erofs options, e.g. `-T0 --mkfs-time` - `--uncompress` : convert tar.gz layers to uncompressed tar layers - `--oci` : convert Docker media types to OCI media types - `--platform=` : convert content for a specific platform @@ -1026,7 +1029,6 @@ Flags: - `--soci-span-size` : Span size in bytes that soci index uses to segment layer data. Default is 4 MiB. - `--soci-min-layer-size`: Minimum layer size in bytes to build zTOC for. Smaller layers won't have zTOC and not lazy pulled. Default is 10 MiB. - ### :nerd_face: nerdctl image encrypt Encrypt image layers. See [`./ocicrypt.md`](./ocicrypt.md). diff --git a/docs/erofs.md b/docs/erofs.md new file mode 100644 index 00000000000..2425cace8e7 --- /dev/null +++ b/docs/erofs.md @@ -0,0 +1,53 @@ +# EROFS Image Conversion + +EROFS is a read-only filesystem supported by containerd's `erofs` snapshotter and differ. nerdctl can convert image layers to EROFS media types with `nerdctl image convert --erofs`. + +## Prerequisites + +- Install containerd with the `erofs` snapshotter and differ plugins enabled. +- Install `mkfs.erofs` for `nerdctl image convert --erofs`. + +Check that containerd has loaded the EROFS plugins: + +```console +ctr plugins ls | grep erofs +``` + +## Configure containerd transfer unpack + +containerd 2.3+ provides an EROFS unpack configuration by default when the `erofs` snapshotter and differ plugins are available. + +If `plugins."io.containerd.transfer.v1.local".unpack_config` is configured manually, add an EROFS entry to `/etc/containerd/config.toml` and restart containerd: + +```toml +[[plugins."io.containerd.transfer.v1.local".unpack_config]] + platform = "linux(+erofs)/amd64" + snapshotter = "erofs" + differ = "erofs" +``` + +Replace `amd64` with the target architecture as needed. The `linux(+erofs)/ARCH` entry also allows the `erofs` snapshotter to unpack regular `linux/ARCH` tar/gzip images. + +## Convert an image + +Convert an image to raw EROFS blobs: + +```console +nerdctl image convert --erofs raw example.com/foo:latest example.com/foo:erofs +``` + +Convert an image to zstd-compressed EROFS blobs: + +```console +nerdctl image convert --erofs zstd example.com/foo:latest example.com/foo:erofs-zstd +``` + +`--erofs-compressors` passes compressor options to `mkfs.erofs`, and `--erofs-mkfs-options` passes extra `mkfs.erofs` options. See [`command-reference.md`](./command-reference.md) for flag details. + +## Pull and unpack with EROFS snapshotter + +Push the converted image to a registry, then pull it with the `erofs` snapshotter: + +```console +nerdctl image pull --snapshotter erofs example.com/foo:erofs +``` diff --git a/go.mod b/go.mod index 3bdb8a97191..5031b0d1867 100644 --- a/go.mod +++ b/go.mod @@ -149,6 +149,7 @@ require ( require ( cyphar.com/go-pathrs v0.2.5 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/moby/moby/api v1.55.0 // indirect github.com/moby/sys/capability v0.4.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect diff --git a/pkg/api/types/image_types.go b/pkg/api/types/image_types.go index 2053d7dca06..b5d3198d500 100644 --- a/pkg/api/types/image_types.go +++ b/pkg/api/types/image_types.go @@ -75,6 +75,7 @@ type ImageConvertOptions struct { NydusOptions OverlaybdOptions SociConvertOptions + ErofsOptions } // EstargzOptions contains eStargz conversion options @@ -152,6 +153,16 @@ type SociConvertOptions struct { // #endregion } +// ErofsOptions contains EROFS conversion options +type ErofsOptions struct { + // Erofs convert image layers to EROFS media type. Supported values: "raw" and "zstd" + Erofs string + // ErofsCompressors specifies mkfs compressor options, e.g. "lz4hc,12" + ErofsCompressors string + // ErofsMkfsOptions specifies extra options for mkfs.erofs, e.g. "-T0 --mkfs-time" + ErofsMkfsOptions string +} + // ImageCryptOptions specifies options for `nerdctl image encrypt` and `nerdctl image decrypt`. type ImageCryptOptions struct { Stdout io.Writer diff --git a/pkg/cmd/image/convert.go b/pkg/cmd/image/convert.go index 005309fce92..d5540ee5abd 100644 --- a/pkg/cmd/image/convert.go +++ b/pkg/cmd/image/convert.go @@ -33,6 +33,7 @@ import ( "github.com/containerd/containerd/v2/core/content" "github.com/containerd/containerd/v2/core/images" "github.com/containerd/containerd/v2/core/images/converter" + erofsconvert "github.com/containerd/containerd/v2/core/images/converter/erofs" "github.com/containerd/containerd/v2/core/images/converter/uncompress" "github.com/containerd/log" nydusconvert "github.com/containerd/nydus-snapshotter/pkg/converter" @@ -92,8 +93,9 @@ func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRa overlaybd := options.Overlaybd nydus := options.Nydus soci := options.Soci + erofs := options.Erofs != "" var finalize func(ctx context.Context, cs content.Store, ref string, desc *ocispec.Descriptor) (*images.Image, error) - if estargz || zstd || zstdchunked || overlaybd || nydus || soci { + if estargz || zstd || zstdchunked || overlaybd || nydus || soci || erofs { convertCount := 0 if estargz { convertCount++ @@ -113,12 +115,16 @@ func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRa if soci { convertCount++ } + if erofs { + convertCount++ + } if convertCount > 1 { - return errors.New("options --estargz, --zstdchunked, --overlaybd, --nydus and --soci lead to conflict, only one of them can be used") + return errors.New("options --estargz, --zstdchunked, --overlaybd, --nydus, --soci and --erofs lead to conflict, only one of them can be used") } var convertFunc converter.ConvertFunc + var updateManifestFunc converter.UpdateManifestFunc var convertType string switch { case estargz: @@ -149,6 +155,12 @@ func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRa convertFunc = overlaybdconvert.IndexConvertFunc(obdOpts...) convertOpts = append(convertOpts, converter.WithIndexConvertFunc(convertFunc)) convertType = "overlaybd" + case erofs: + convertFunc, updateManifestFunc, err = getErofsConverter(options) + if err != nil { + return err + } + convertType = "erofs" case nydus: nydusOpts, err := getNydusConvertOpts(options) if err != nil { @@ -188,6 +200,9 @@ func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRa if convertType != "overlaybd" { convertOpts = append(convertOpts, converter.WithLayerConvertFunc(convertFunc)) } + if updateManifestFunc != nil { + convertOpts = append(convertOpts, converter.WithUpdateManifest(updateManifestFunc)) + } if !options.Oci { if nydus || overlaybd { log.G(ctx).Warnf("option --%s should be used in conjunction with --oci, forcibly enabling on oci mediatype for %s conversion", convertType, convertType) @@ -369,6 +384,24 @@ func getZstdchunkedConverter(options types.ImageConvertOptions) (converter.Conve return zstdchunkedconvert.LayerConvertFuncWithCompressionLevel(zstd.EncoderLevelFromZstd(options.ZstdChunkedCompressionLevel), esgzOpts...), nil } +func getErofsConverter(options types.ImageConvertOptions) (converter.ConvertFunc, converter.UpdateManifestFunc, error) { + var convertOpts []erofsconvert.ConvertOpt + switch options.Erofs { + case "raw": + case "zstd": + convertOpts = append(convertOpts, erofsconvert.WithBlobCompression("zstd")) + default: + return nil, nil, fmt.Errorf("invalid value %q for --erofs, supported values are: raw, zstd", options.Erofs) + } + if options.ErofsCompressors != "" { + convertOpts = append(convertOpts, erofsconvert.WithCompressors(options.ErofsCompressors)) + } + if options.ErofsMkfsOptions != "" { + convertOpts = append(convertOpts, erofsconvert.WithMkfsOptions(strings.Fields(options.ErofsMkfsOptions))) + } + return erofsconvert.LayerConvertFunc(convertOpts...), erofsconvert.UpdateManifestPlatform, nil +} + func getNydusConvertOpts(options types.ImageConvertOptions) (*nydusconvert.PackOption, error) { workDir := options.NydusWorkDir if workDir == "" { diff --git a/pkg/cmd/image/prune.go b/pkg/cmd/image/prune.go index da29fbdb486..21d8fb1595f 100644 --- a/pkg/cmd/image/prune.go +++ b/pkg/cmd/image/prune.go @@ -25,10 +25,10 @@ import ( containerd "github.com/containerd/containerd/v2/client" "github.com/containerd/containerd/v2/core/images" "github.com/containerd/log" - "github.com/containerd/platforms" "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/imgutil" + "github.com/containerd/nerdctl/v2/pkg/platformutil" ) // Prune will remove all dangling images. If all is specified, will also remove all images not referenced by any container. @@ -68,10 +68,14 @@ func Prune(ctx context.Context, client *containerd.Client, options types.ImagePr return err } + platformMatcher, err := platformutil.NewMatchComparer(false, nil) + if err != nil { + return err + } delOpts := []images.DeleteOpt{images.SynchronousDelete()} removedImages := make(map[string][]digest.Digest) for _, image := range imagesToBeRemoved { - digests, err := image.RootFS(ctx, contentStore, platforms.DefaultStrict()) + digests, err := image.RootFS(ctx, contentStore, platformMatcher) if err != nil { log.G(ctx).WithError(err).Warnf("failed to enumerate rootfs") } diff --git a/pkg/cmd/image/remove.go b/pkg/cmd/image/remove.go index 44aafa5fab4..5e5c9ccd9fd 100644 --- a/pkg/cmd/image/remove.go +++ b/pkg/cmd/image/remove.go @@ -25,11 +25,11 @@ import ( containerd "github.com/containerd/containerd/v2/client" "github.com/containerd/containerd/v2/core/images" "github.com/containerd/log" - "github.com/containerd/platforms" "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/containerutil" "github.com/containerd/nerdctl/v2/pkg/idutil/imagewalker" + "github.com/containerd/nerdctl/v2/pkg/platformutil" ) // Remove removes a list of `images`. @@ -41,6 +41,10 @@ func Remove(ctx context.Context, client *containerd.Client, args []string, optio cs := client.ContentStore() is := client.ImageService() + platformMatcher, err := platformutil.NewMatchComparer(false, nil) + if err != nil { + return err + } containerList, err := client.Containers(ctx) if err != nil { return err @@ -103,7 +107,7 @@ func Remove(ctx context.Context, client *containerd.Client, args []string, optio return fmt.Errorf("conflict: unable to delete %s (must be forced) - image is being used by stopped container %s", found.Req, cid) } // digests is used only for emulating human-readable output of `docker rmi` - digests, err := found.Image.RootFS(ctx, cs, platforms.DefaultStrict()) + digests, err := found.Image.RootFS(ctx, cs, platformMatcher) if err != nil { log.G(ctx).WithError(err).Warning("failed to enumerate rootfs") } @@ -156,7 +160,7 @@ func Remove(ctx context.Context, client *containerd.Client, args []string, optio return false, fmt.Errorf("conflict: unable to delete %s (must be forced) - image is being used by stopped container %s", found.Req, cid) } // digests is used only for emulating human-readable output of `docker rmi` - digests, err := found.Image.RootFS(ctx, cs, platforms.DefaultStrict()) + digests, err := found.Image.RootFS(ctx, cs, platformMatcher) if err != nil { log.G(ctx).WithError(err).Warning("failed to enumerate rootfs") } diff --git a/pkg/imgutil/push/push.go b/pkg/imgutil/push/push.go index 94c6acca71c..89684e9dbaf 100644 --- a/pkg/imgutil/push/push.go +++ b/pkg/imgutil/push/push.go @@ -48,6 +48,7 @@ func Push(ctx context.Context, client *containerd.Client, resolver remotes.Resol } desc := img.Target + ctx = withErofsLayerRefKeyPrefixes(ctx) ongoing := newPushJobs(pushTracker) eg, ctx := errgroup.WithContext(ctx) @@ -172,3 +173,9 @@ func (j *pushjobs) status() []jobs.StatusInfo { return statuses } + +func withErofsLayerRefKeyPrefixes(ctx context.Context) context.Context { + ctx = remotes.WithMediaTypeKeyPrefix(ctx, images.MediaTypeErofsLayer, "layer") + ctx = remotes.WithMediaTypeKeyPrefix(ctx, images.MediaTypeErofsLayer+"+zstd", "layer") + return ctx +} diff --git a/pkg/imgutil/transfer.go b/pkg/imgutil/transfer.go index 532f9982aee..86c5aee5dba 100644 --- a/pkg/imgutil/transfer.go +++ b/pkg/imgutil/transfer.go @@ -42,7 +42,8 @@ import ( func prepareImageStore(ctx context.Context, parsedReference *referenceutil.ImageReference, options types.ImagePullOptions) (*transferimage.Store, error) { var storeOpts []transferimage.StoreOpt if len(options.OCISpecPlatform) > 0 { - storeOpts = append(storeOpts, transferimage.WithPlatforms(options.OCISpecPlatform...)) + platforms := platformutil.AppendOSFeatureVariants(options.OCISpecPlatform, platformutil.ErofsOSFeature) + storeOpts = append(storeOpts, transferimage.WithPlatforms(platforms...)) } unpackEnabled := len(options.OCISpecPlatform) == 1 @@ -175,6 +176,7 @@ func preparePushStore(pushRef string, options types.ImagePushOptions) (*transfer storeOpts := []transferimage.StoreOpt{} if len(platformsSlice) > 0 { + platformsSlice = platformutil.AppendOSFeatureVariants(platformsSlice, platformutil.ErofsOSFeature) storeOpts = append(storeOpts, transferimage.WithPlatforms(platformsSlice...)) } diff --git a/pkg/platformutil/platformutil.go b/pkg/platformutil/platformutil.go index ac076d0b980..2168e8909d0 100644 --- a/pkg/platformutil/platformutil.go +++ b/pkg/platformutil/platformutil.go @@ -18,6 +18,7 @@ package platformutil import ( "fmt" + "slices" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -26,13 +27,15 @@ import ( "github.com/containerd/nerdctl/v2/pkg/strutil" ) +const ErofsOSFeature = "erofs" + // NewMatchComparerFromOCISpecPlatformSlice returns MatchComparer. // If platformz is empty, NewMatchComparerFromOCISpecPlatformSlice returns All (not DefaultStrict). func NewMatchComparerFromOCISpecPlatformSlice(platformz []ocispec.Platform) platforms.MatchComparer { if len(platformz) == 0 { return platforms.All } - return platforms.Ordered(platformz...) + return IgnoreOSFeaturesMatcher(platforms.Ordered(platformz...), ErofsOSFeature) } // NewMatchComparer returns MatchComparer. @@ -45,10 +48,83 @@ func NewMatchComparer(all bool, ss []string) (platforms.MatchComparer, error) { } if len(ss) == 0 { // return DefaultStrict, not Default - return platforms.DefaultStrict(), nil + return IgnoreOSFeaturesMatcher(platforms.DefaultStrict(), ErofsOSFeature), nil } op, err := NewOCISpecPlatformSlice(false, ss) - return platforms.Ordered(op...), err + return IgnoreOSFeaturesMatcher(platforms.Ordered(op...), ErofsOSFeature), err +} + +// IgnoreOSFeaturesMatcher wraps a MatchComparer and ignores selected os.features +// on candidate platforms before delegating to the wrapped matcher. +func IgnoreOSFeaturesMatcher(mc platforms.MatchComparer, features ...string) platforms.MatchComparer { + return ignoreOSFeaturesMatcher{ + MatchComparer: mc, + features: features, + } +} + +func AppendOSFeatureVariants(platformz []ocispec.Platform, features ...string) []ocispec.Platform { + if len(platformz) == 0 || len(features) == 0 { + return platformz + } + out := slices.Clone(platformz) + seen := make(map[string]struct{}, len(platformz)*2) + for _, p := range out { + seen[platforms.FormatAll(platforms.Normalize(p))] = struct{}{} + } + for _, p := range platformz { + var added bool + for _, feature := range features { + if slices.Contains(p.OSFeatures, feature) { + continue + } + p.OSFeatures = append(p.OSFeatures, feature) + added = true + } + if added { + p = platforms.Normalize(p) + key := platforms.FormatAll(p) + if _, ok := seen[key]; ok { + continue + } + out = append(out, p) + seen[key] = struct{}{} + } + } + return out +} + +type ignoreOSFeaturesMatcher struct { + platforms.MatchComparer + features []string +} + +func (m ignoreOSFeaturesMatcher) Match(p ocispec.Platform) bool { + return m.MatchComparer.Match(p) || m.MatchComparer.Match(withoutOSFeatures(p, m.features)) +} + +func (m ignoreOSFeaturesMatcher) Less(p1, p2 ocispec.Platform) bool { + p1Match := m.MatchComparer.Match(p1) + p2Match := m.MatchComparer.Match(p2) + if p1Match != p2Match { + return p1Match + } + if p1Match { + return m.MatchComparer.Less(p1, p2) + } + return m.MatchComparer.Less(withoutOSFeatures(p1, m.features), withoutOSFeatures(p2, m.features)) +} + +func withoutOSFeatures(p ocispec.Platform, features []string) ocispec.Platform { + if !slices.ContainsFunc(p.OSFeatures, func(feature string) bool { + return slices.Contains(features, feature) + }) { + return p + } + p.OSFeatures = slices.DeleteFunc(slices.Clone(p.OSFeatures), func(feature string) bool { + return slices.Contains(features, feature) + }) + return p } // NewOCISpecPlatformSlice returns a slice of ocispec.Platform