From 1e2babcfccc26dbd405268aaaeaa8b3d58b99b2b Mon Sep 17 00:00:00 2001 From: ChengyuZhu6 Date: Fri, 22 May 2026 10:44:31 +0800 Subject: [PATCH] image: align erofs raw/zstd convert behavior with containerd Fixes: #4861 Signed-off-by: Chengyu Zhu --- cmd/nerdctl/image/image_convert.go | 26 +++++++++++ cmd/nerdctl/image/image_convert_linux_test.go | 43 ++++++++++++++++--- docs/command-reference.md | 3 ++ go.mod | 1 + pkg/api/types/image_types.go | 11 +++++ pkg/cmd/image/convert.go | 38 +++++++++++++++- pkg/imgutil/push/push.go | 7 +++ 7 files changed, 120 insertions(+), 9 deletions(-) diff --git a/cmd/nerdctl/image/image_convert.go b/cmd/nerdctl/image/image_convert.go index c253a2274e4..7c3095218b4 100644 --- a/cmd/nerdctl/image/image_convert.go +++ b/cmd/nerdctl/image/image_convert.go @@ -96,6 +96,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") @@ -243,6 +249,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 { @@ -317,6 +338,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/docs/command-reference.md b/docs/command-reference.md index b32633b2139..8db8002ab2c 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1012,6 +1012,9 @@ Flags: - `--zstdchunked-record-in=` : read `ctr-remote optimize --record-out=` record file. :warning: This flag is experimental and subject to change. - `--zstdchunked-compression-level=`: zstd:chunked compression level (default: 3) - `--zstdchunked-chunk-size=`: zstd:chunked chunk size +- `--erofs=` : convert image layers to EROFS media type. Supported values: `raw`, `zstd` +- `--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 diff --git a/go.mod b/go.mod index 20c5b71c3cd..a5c96d172f7 100644 --- a/go.mod +++ b/go.mod @@ -151,6 +151,7 @@ require ( require ( cyphar.com/go-pathrs v0.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/moby/moby/api v1.54.2 // 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 29ba08607d9..659e9916bcc 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 @@ -150,6 +151,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 7bb0b926ebb..1ce48d3ccaa 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,25 @@ 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": + // no-op: raw keeps native EROFS blob without extra stream compression + 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/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 +}