diff --git a/cmd/ctr/commands/images/convert.go b/cmd/ctr/commands/images/convert.go index 1c1cff8215..c711ef4b29 100644 --- a/cmd/ctr/commands/images/convert.go +++ b/cmd/ctr/commands/images/convert.go @@ -19,9 +19,11 @@ package images import ( "errors" "fmt" + "strings" "github.com/containerd/containerd/v2/cmd/ctr/commands" "github.com/containerd/containerd/v2/core/images/converter" + "github.com/containerd/containerd/v2/core/images/converter/erofs" "github.com/containerd/containerd/v2/core/images/converter/uncompress" "github.com/containerd/platforms" "github.com/urfave/cli/v2" @@ -33,7 +35,9 @@ var convertCommand = &cli.Command{ ArgsUsage: "[flags] ", Description: `Convert an image format. -e.g., 'ctr convert --uncompress --oci example.com/foo:orig example.com/foo:converted' +e.g., 'ctr image convert --uncompress --oci example.com/foo:orig example.com/foo:converted' + 'ctr image convert --erofs raw example.com/foo:orig example.com/foo:erofs' + 'ctr image convert --erofs zstd example.com/foo:orig example.com/foo:erofs-zstd' Use '--platform' to define the output platform. When '--all-platforms' is given all images in a manifest list must be available. @@ -48,6 +52,19 @@ When '--all-platforms' is given all images in a manifest list must be available. Name: "oci", Usage: "Convert Docker media types to OCI media types", }, + // erofs flags + &cli.StringFlag{ + Name: "erofs", + Usage: "Convert layers to EROFS format, must specify 'raw' or 'zstd' (e.g. --erofs raw, --erofs zstd)", + }, + &cli.StringFlag{ + Name: "erofs-compressors", + Usage: "Specify compression algorithm list when converting EROFS layers", + }, + &cli.StringFlag{ + Name: "erofs-mkfs-options", + Usage: "Extra mkfs options applied when converting EROFS layers. (e.g. '-Efragments,dedupe')", + }, // platform flags &cli.StringSliceFlag{ Name: "platform", @@ -83,6 +100,25 @@ When '--all-platforms' is given all images in a manifest list must be available. convertOpts = append(convertOpts, converter.WithLayerConvertFunc(uncompress.LayerConvertFunc)) } + if cliContext.IsSet("erofs") { + var erofsOpts []erofs.ConvertOpt + switch cliContext.String("erofs") { + case "raw": + case "zstd": + erofsOpts = append(erofsOpts, erofs.WithBlobCompression("zstd")) + default: + return fmt.Errorf("unsupported erofs format %q, supported: raw, zstd", cliContext.String("erofs")) + } + if compressors := cliContext.String("erofs-compressors"); compressors != "" { + erofsOpts = append(erofsOpts, erofs.WithCompressors(compressors)) + } + if mkfsOptsStr := cliContext.String("erofs-mkfs-options"); mkfsOptsStr != "" { + mkfsOpts := strings.Fields(mkfsOptsStr) + erofsOpts = append(erofsOpts, erofs.WithMkfsOptions(mkfsOpts)) + } + convertOpts = append(convertOpts, converter.WithLayerConvertFunc(erofs.LayerConvertFunc(erofsOpts...))) + } + if cliContext.Bool("oci") { convertOpts = append(convertOpts, converter.WithDockerToOCI(true)) } diff --git a/core/images/converter/erofs/erofs.go b/core/images/converter/erofs/erofs.go new file mode 100644 index 0000000000..8a7337c24b --- /dev/null +++ b/core/images/converter/erofs/erofs.go @@ -0,0 +1,193 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package erofs + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/core/images" + "github.com/containerd/containerd/v2/core/images/converter" + "github.com/containerd/containerd/v2/core/images/converter/uncompress" + "github.com/containerd/containerd/v2/internal/erofsutils" + "github.com/containerd/containerd/v2/pkg/archive/compression" + "github.com/containerd/containerd/v2/pkg/labels" + "github.com/containerd/errdefs" + "github.com/containerd/log" + "github.com/google/uuid" +) + +type ConvertOpt func(*convertOptions) + +type convertOptions struct { + compressors string + mkfsExtraOpts []string + blobCompression string +} + +func WithCompressors(compressors string) ConvertOpt { + return func(opts *convertOptions) { + opts.compressors = compressors + } +} + +func WithMkfsOptions(extraOpts []string) ConvertOpt { + return func(opts *convertOptions) { + opts.mkfsExtraOpts = extraOpts + } +} + +func WithBlobCompression(compression string) ConvertOpt { + return func(opts *convertOptions) { + opts.blobCompression = compression + } +} + +func LayerConvertFunc(opts ...ConvertOpt) converter.ConvertFunc { + return func(ctx context.Context, cs content.Store, desc ocispec.Descriptor) (*ocispec.Descriptor, error) { + var convertOpts convertOptions + for _, opt := range opts { + opt(&convertOpts) + } + + if !images.IsLayerType(desc.MediaType) || erofsutils.IsErofsMediaType(desc.MediaType) { + return nil, nil + } + + uncompressedDesc := &desc + if !uncompress.IsUncompressedType(desc.MediaType) { + var err error + uncompressedDesc, err = uncompress.LayerConvertFunc(ctx, cs, desc) + if err != nil { + return nil, err + } + if uncompressedDesc == nil { + return nil, fmt.Errorf("unexpectedly got the same blob after compression (%s, %q)", desc.Digest, desc.MediaType) + } + log.G(ctx).Debugf("uncompressed %s into %s", desc.Digest, uncompressedDesc.Digest) + } + + info, err := cs.Info(ctx, desc.Digest) + if err != nil { + return nil, fmt.Errorf("failed to get content info: %w", err) + } + + labelz := info.Labels + if labelz == nil { + labelz = make(map[string]string) + } + + ra, err := cs.ReaderAt(ctx, *uncompressedDesc) + if err != nil { + return nil, fmt.Errorf("failed to get reader: %w", err) + } + defer ra.Close() + + sr := io.NewSectionReader(ra, 0, uncompressedDesc.Size) + + blob, err := os.CreateTemp("", "layer-*.erofs") + if err != nil { + return nil, fmt.Errorf("failed to create temp file: %w", err) + } + blobPath := blob.Name() + blob.Close() + + defer func() { + if err := os.Remove(blobPath); err != nil && !os.IsNotExist(err) { + log.G(ctx).WithError(err).Warnf("failed to remove temp file %s", blobPath) + } + }() + + var mkfsArgs []string + if convertOpts.compressors != "" { + compressionArg := "-z" + convertOpts.compressors + mkfsArgs = append(mkfsArgs, compressionArg) + mkfsArgs = append(mkfsArgs, []string{"-C", "65536"}...) + } + mkfsArgs = append(mkfsArgs, convertOpts.mkfsExtraOpts...) + + mkfsArgs = erofsutils.AddDefaultMkfsOpts(mkfsArgs) + + u := uuid.NewSHA1(uuid.NameSpaceURL, []byte("erofs:blobs/"+desc.Digest)) + if err := erofsutils.ConvertTarErofs(ctx, sr, blobPath, u.String(), mkfsArgs); err != nil { + return nil, fmt.Errorf("failed to convert to EROFS: %w", err) + } + log.G(ctx).Debugf("converted %s to EROFS", desc.Digest) + + erofsFile, err := os.Open(blobPath) + if err != nil { + return nil, fmt.Errorf("failed to open converted file: %w", err) + } + defer erofsFile.Close() + + ref := fmt.Sprintf("convert-erofs-from-%s", desc.Digest) + w, err := content.OpenWriter(ctx, cs, content.WithRef(ref)) + if err != nil { + return nil, fmt.Errorf("failed to open content writer: %w", err) + } + defer w.Close() + + if err := w.Truncate(0); err != nil { + return nil, fmt.Errorf("failed to truncate writer: %w", err) + } + + mediaType := images.MediaTypeErofsLayer + + if convertOpts.blobCompression == "zstd" { + zw, err := compression.CompressStream(w, compression.Zstd) + if err != nil { + return nil, fmt.Errorf("failed to create zstd compressor: %w", err) + } + digester := digest.Canonical.Digester() + if _, err := io.Copy(io.MultiWriter(zw, digester.Hash()), erofsFile); err != nil { + zw.Close() + return nil, fmt.Errorf("failed to compress EROFS blob: %w", err) + } + if err := zw.Close(); err != nil { + return nil, fmt.Errorf("failed to finalize zstd stream: %w", err) + } + labelz[labels.LabelUncompressed] = digester.Digest().String() + mediaType = images.MediaTypeErofsLayer + "+" + convertOpts.blobCompression + } else { + if _, err = io.Copy(w, erofsFile); err != nil { + return nil, fmt.Errorf("failed to copy to content store: %w", err) + } + labelz[labels.LabelUncompressed] = w.Digest().String() + } + + if err = w.Commit(ctx, 0, w.Digest(), content.WithLabels(labelz)); err != nil && !errdefs.IsAlreadyExists(err) { + return nil, fmt.Errorf("failed to commit: %w", err) + } + + cInfo, err := cs.Info(ctx, w.Digest()) + if err != nil { + return nil, fmt.Errorf("failed to get content info: %w", err) + } + + newDesc := desc + newDesc.MediaType = mediaType + newDesc.Digest = w.Digest() + newDesc.Size = cInfo.Size + return &newDesc, nil + } +} diff --git a/internal/erofsutils/mount.go b/internal/erofsutils/mount.go index bcd4aa613d..afda0565c1 100644 --- a/internal/erofsutils/mount.go +++ b/internal/erofsutils/mount.go @@ -24,6 +24,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "github.com/containerd/errdefs" @@ -32,6 +33,11 @@ import ( "github.com/containerd/containerd/v2/core/mount" ) +// IsErofsMediaType returns true if the media type is an EROFS layer type. +func IsErofsMediaType(mt string) bool { + return strings.HasPrefix(mt, "application/vnd.erofs.layer") +} + func ConvertTarErofs(ctx context.Context, r io.Reader, layerPath, uuid string, mkfsExtraOpts []string) error { args := append([]string{"--tar=f", "--aufs", "--quiet", "-Enoinline_data"}, mkfsExtraOpts...) if uuid != "" { @@ -103,6 +109,24 @@ func GenerateTarIndexAndAppendTar(ctx context.Context, r io.Reader, layerPath, u return nil } +// AddDefaultMkfsOpts adds default options for mkfs.erofs +func AddDefaultMkfsOpts(mkfsExtraOpts []string) []string { + if runtime.GOOS != "darwin" { + return mkfsExtraOpts + } + + // Check if -b argument is already present + for _, opt := range mkfsExtraOpts { + if strings.HasPrefix(opt, "-b") { + return mkfsExtraOpts + } + } + + // Add -b4096 as the first option to prevent unusable block + // size from being used on macOS. + return append([]string{"-b4096"}, mkfsExtraOpts...) +} + func ConvertErofs(ctx context.Context, layerPath string, srcDir string, mkfsExtraOpts []string) error { args := append([]string{"--quiet", "-Enoinline_data"}, mkfsExtraOpts...) args = append(args, layerPath, srcDir) diff --git a/plugins/diff/erofs/differ.go b/plugins/diff/erofs/differ.go index 17baf0f109..926f6e8f13 100644 --- a/plugins/diff/erofs/differ.go +++ b/plugins/diff/erofs/differ.go @@ -22,7 +22,6 @@ import ( "io" "os" "path" - "runtime" "strings" "time" @@ -93,15 +92,11 @@ func NewErofsDiffer(store content.Store, opts ...DifferOpt) differ { } // Add default block size on darwin if not already specified - d.mkfsExtraOpts = addDefaultMkfsOpts(d.mkfsExtraOpts) + d.mkfsExtraOpts = erofsutils.AddDefaultMkfsOpts(d.mkfsExtraOpts) return d } -func isErofsMediaType(mt string) bool { - return strings.HasSuffix(mt, ".erofs") || strings.HasPrefix(mt, "application/vnd.erofs.layer") -} - func (s erofsDiff) Apply(ctx context.Context, desc ocispec.Descriptor, mounts []mount.Mount, opts ...diff.ApplyOpt) (d ocispec.Descriptor, err error) { t1 := time.Now() defer func() { @@ -120,7 +115,7 @@ func (s erofsDiff) Apply(ctx context.Context, desc ocispec.Descriptor, mounts [] fastcopy bool ) diffLayerType := desc.MediaType - native := isErofsMediaType(diffLayerType) + native := erofsutils.IsErofsMediaType(diffLayerType) if native { base, ext, hasExt := strings.Cut(diffLayerType, "+") // Mimic the OCI layer for EROFS blobs for diff.NewProcessorChain(), so @@ -259,21 +254,3 @@ func (rc *readCounter) Read(p []byte) (n int, err error) { rc.c += int64(n) return } - -// addDefaultMkfsOpts adds default options for mkfs.erofs -func addDefaultMkfsOpts(mkfsExtraOpts []string) []string { - if runtime.GOOS != "darwin" { - return mkfsExtraOpts - } - - // Check if -b argument is already present - for _, opt := range mkfsExtraOpts { - if strings.HasPrefix(opt, "-b") { - return mkfsExtraOpts - } - } - - // Add -b4096 as the first option to prevent unusable block - // size from being used on macOS. - return append([]string{"-b4096"}, mkfsExtraOpts...) -}