Skip to content

Commit 428e32c

Browse files
committed
ctr: add EROFS image conversion support
Add EROFS conversion support to ctr convert command with configurable options for tar-index mode and mkfs parameters. Usage: ctr image convert --erofs src:tag dst:tag ctr image convert --erofs --erofs-comression='lz4' src:tag dst:tag Signed-off-by: ChengyuZhu6 <[email protected]>
1 parent 415b5a5 commit 428e32c

File tree

3 files changed

+204
-2
lines changed

3 files changed

+204
-2
lines changed

cmd/ctr/commands/images/convert.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ package images
1919
import (
2020
"errors"
2121
"fmt"
22+
"strings"
2223

2324
"github.com/containerd/containerd/v2/cmd/ctr/commands"
2425
"github.com/containerd/containerd/v2/core/images/converter"
26+
"github.com/containerd/containerd/v2/core/images/converter/erofs"
2527
"github.com/containerd/containerd/v2/core/images/converter/uncompress"
2628
"github.com/containerd/platforms"
2729
"github.com/urfave/cli/v2"
@@ -34,6 +36,8 @@ var convertCommand = &cli.Command{
3436
Description: `Convert an image format.
3537
3638
e.g., 'ctr convert --uncompress --oci example.com/foo:orig example.com/foo:converted'
39+
'ctr convert --erofs example.com/foo:orig example.com/foo:erofs'
40+
'ctr convert --erofs --erofs-compression='lz4,zstd' example.com/foo:orig example.com/foo:erofs'
3741
3842
Use '--platform' to define the output platform.
3943
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.
4852
Name: "oci",
4953
Usage: "Convert Docker media types to OCI media types",
5054
},
55+
// erofs flags
56+
&cli.BoolFlag{
57+
Name: "erofs",
58+
Usage: "Convert layers to EROFS format",
59+
},
60+
&cli.StringFlag{
61+
Name: "erofs-compression",
62+
Usage: "Compression algorithms for EROFS (e.g., 'lz4', 'zstd')",
63+
},
64+
&cli.StringFlag{
65+
Name: "erofs-mkfs-opts",
66+
Usage: "Extra options for mkfs.erofs (e.g., '-zlz4')",
67+
},
5168
// platform flags
5269
&cli.StringSliceFlag{
5370
Name: "platform",
@@ -83,6 +100,18 @@ When '--all-platforms' is given all images in a manifest list must be available.
83100
convertOpts = append(convertOpts, converter.WithLayerConvertFunc(uncompress.LayerConvertFunc))
84101
}
85102

103+
if cliContext.Bool("erofs") {
104+
var erofsOpts []erofs.ConvertOpt
105+
if compressors := cliContext.String("erofs-compression"); compressors != "" {
106+
erofsOpts = append(erofsOpts, erofs.WithCompressor(strings.Split(compressors, ",")...))
107+
}
108+
if mkfsOptsStr := cliContext.String("erofs-mkfs-opts"); mkfsOptsStr != "" {
109+
mkfsOpts := strings.Fields(mkfsOptsStr)
110+
erofsOpts = append(erofsOpts, erofs.WithMkfsOptions(mkfsOpts))
111+
}
112+
convertOpts = append(convertOpts, converter.WithLayerConvertFunc(erofs.LayerConvertFunc(erofsOpts...)))
113+
}
114+
86115
if cliContext.Bool("oci") {
87116
convertOpts = append(convertOpts, converter.WithDockerToOCI(true))
88117
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package erofs
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"io"
23+
"os"
24+
25+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
26+
27+
"github.com/containerd/containerd/v2/core/content"
28+
"github.com/containerd/containerd/v2/core/images"
29+
"github.com/containerd/containerd/v2/core/images/converter"
30+
"github.com/containerd/containerd/v2/core/images/converter/uncompress"
31+
"github.com/containerd/containerd/v2/internal/erofsutils"
32+
"github.com/containerd/containerd/v2/pkg/labels"
33+
"github.com/containerd/containerd/v2/plugins/diff/erofs"
34+
"github.com/containerd/errdefs"
35+
"github.com/containerd/log"
36+
)
37+
38+
type ConvertOpt func(*convertOptions)
39+
40+
type convertOptions struct {
41+
compressors []string
42+
mkfsExtraOpts []string
43+
}
44+
45+
func WithCompressor(compressor ...string) ConvertOpt {
46+
return func(opts *convertOptions) {
47+
opts.compressors = compressor
48+
}
49+
}
50+
51+
func WithMkfsOptions(extraOpts []string) ConvertOpt {
52+
return func(opts *convertOptions) {
53+
opts.mkfsExtraOpts = extraOpts
54+
}
55+
}
56+
57+
// LayerConvertFunc converts tar.gz layers into EROFS layers with optional configuration.
58+
func LayerConvertFunc(opts ...ConvertOpt) converter.ConvertFunc {
59+
return func(ctx context.Context, cs content.Store, desc ocispec.Descriptor) (*ocispec.Descriptor, error) {
60+
var convertOpts convertOptions
61+
for _, opt := range opts {
62+
opt(&convertOpts)
63+
}
64+
65+
if !images.IsLayerType(desc.MediaType) || erofs.IsErofsMediaType(desc.MediaType) {
66+
return nil, nil
67+
}
68+
69+
uncompressedDesc := &desc
70+
if !uncompress.IsUncompressedType(desc.MediaType) {
71+
var err error
72+
uncompressedDesc, err = uncompress.LayerConvertFunc(ctx, cs, desc)
73+
if err != nil {
74+
return nil, err
75+
}
76+
if uncompressedDesc == nil {
77+
return nil, fmt.Errorf("unexpectedly got the same blob after compression (%s, %q)", desc.Digest, desc.MediaType)
78+
}
79+
log.G(ctx).Debugf("uncompressed %s into %s", desc.Digest, uncompressedDesc.Digest)
80+
}
81+
82+
info, err := cs.Info(ctx, desc.Digest)
83+
if err != nil {
84+
return nil, fmt.Errorf("failed to get content info: %w", err)
85+
}
86+
87+
labelz := info.Labels
88+
if labelz == nil {
89+
labelz = make(map[string]string)
90+
}
91+
92+
ra, err := cs.ReaderAt(ctx, *uncompressedDesc)
93+
if err != nil {
94+
return nil, fmt.Errorf("failed to get reader: %w", err)
95+
}
96+
defer ra.Close()
97+
98+
sr := io.NewSectionReader(ra, 0, uncompressedDesc.Size)
99+
100+
blob, err := os.CreateTemp("", "erofs-layer-*.img")
101+
if err != nil {
102+
return nil, fmt.Errorf("failed to create temp file: %w", err)
103+
}
104+
blobPath := blob.Name()
105+
blob.Close()
106+
107+
defer func() {
108+
if err := os.Remove(blobPath); err != nil && !os.IsNotExist(err) {
109+
log.G(ctx).WithError(err).Warnf("failed to remove temp file %s", blobPath)
110+
}
111+
}()
112+
113+
var mkfsArgs []string
114+
if len(convertOpts.compressors) > 0 {
115+
for _, compressor := range convertOpts.compressors {
116+
mkfsArgs = append(mkfsArgs, "-z"+compressor)
117+
}
118+
mkfsArgs = append(mkfsArgs, []string{"-C", "65536"}...)
119+
}
120+
mkfsArgs = append(mkfsArgs, convertOpts.mkfsExtraOpts...)
121+
122+
if err := erofsutils.ConvertTarErofs(ctx, sr, blobPath, "", mkfsArgs); err != nil {
123+
return nil, fmt.Errorf("failed to convert to EROFS: %w", err)
124+
}
125+
log.G(ctx).Debugf("converted %s to EROFS", desc.Digest)
126+
127+
erofsFile, err := os.Open(blobPath)
128+
if err != nil {
129+
return nil, fmt.Errorf("failed to open converted file: %w", err)
130+
}
131+
defer erofsFile.Close()
132+
133+
stat, err := erofsFile.Stat()
134+
if err != nil {
135+
return nil, fmt.Errorf("failed to stat converted file: %w", err)
136+
}
137+
138+
ref := fmt.Sprintf("convert-erofs-from-%s", desc.Digest)
139+
w, err := content.OpenWriter(ctx, cs, content.WithRef(ref))
140+
if err != nil {
141+
return nil, fmt.Errorf("failed to open content writer: %w", err)
142+
}
143+
defer w.Close()
144+
145+
if err := w.Truncate(0); err != nil {
146+
return nil, fmt.Errorf("failed to truncate writer: %w", err)
147+
}
148+
149+
n, err := io.Copy(w, erofsFile)
150+
if err != nil {
151+
return nil, fmt.Errorf("failed to copy to content store: %w", err)
152+
}
153+
154+
if n != stat.Size() {
155+
return nil, fmt.Errorf("size mismatch: copied %d bytes, expected %d bytes", n, stat.Size())
156+
}
157+
158+
labelz[labels.LabelUncompressed] = w.Digest().String()
159+
if err = w.Commit(ctx, n, "", content.WithLabels(labelz)); err != nil && !errdefs.IsAlreadyExists(err) {
160+
return nil, fmt.Errorf("failed to commit: %w", err)
161+
}
162+
163+
if err := w.Close(); err != nil {
164+
return nil, fmt.Errorf("failed to close writer: %w", err)
165+
}
166+
167+
newDesc := desc
168+
newDesc.MediaType = images.MediaTypeErofsLayer
169+
newDesc.Digest = w.Digest()
170+
newDesc.Size = n
171+
return &newDesc, nil
172+
}
173+
}

plugins/diff/erofs/differ.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ func NewErofsDiffer(store content.Store, opts ...DifferOpt) differ {
9797
//
9898
// Since `images.DiffCompression` doesn't support arbitrary media types,
9999
// disallow non-empty suffixes for now.
100-
func isErofsMediaType(mt string) bool {
100+
func IsErofsMediaType(mt string) bool {
101101
if !strings.HasSuffix(mt, ".erofs") && !strings.HasPrefix(mt, "application/vnd.erofs.layer") {
102102
return false
103103
}
@@ -119,7 +119,7 @@ func (s erofsDiff) Apply(ctx context.Context, desc ocispec.Descriptor, mounts []
119119
}()
120120

121121
native := false
122-
if isErofsMediaType(desc.MediaType) {
122+
if IsErofsMediaType(desc.MediaType) {
123123
native = true
124124
} else if _, err := images.DiffCompression(ctx, desc.MediaType); err != nil {
125125
return emptyDesc, fmt.Errorf("currently unsupported media type: %s", desc.MediaType)

0 commit comments

Comments
 (0)