Skip to content

Commit c40ca4c

Browse files
committed
add soci convert feature
Signed-off-by: Arjun Raja Yogidas <[email protected]>
1 parent 694c405 commit c40ca4c

File tree

9 files changed

+207
-26
lines changed

9 files changed

+207
-26
lines changed

cmd/nerdctl/image/image_convert.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ func convertCommand() *cobra.Command {
8989
cmd.Flags().String("overlaybd-dbstr", "", "Database config string for overlaybd")
9090
// #endregion
9191

92+
// #region soci flags
93+
cmd.Flags().Bool("soci", false, "Convert image to SOCI Index V2 format.")
94+
cmd.Flags().Int64("soci-min-layer-size", -1, "The minimum size of layers that will be converted to SOCI Index V2 format")
95+
cmd.Flags().Int64("soci-span-size", -1, "The size of SOCI spans")
96+
// #endregion
97+
9298
// #region generic flags
9399
cmd.Flags().Bool("uncompress", false, "Convert tar.gz layers to uncompressed tar layers")
94100
cmd.Flags().Bool("oci", false, "Convert Docker media types to OCI media types")
@@ -213,6 +219,21 @@ func convertOptions(cmd *cobra.Command) (types.ImageConvertOptions, error) {
213219
}
214220
// #endregion
215221

222+
// #region soci flags
223+
soci, err := cmd.Flags().GetBool("soci")
224+
if err != nil {
225+
return types.ImageConvertOptions{}, err
226+
}
227+
sociMinLayerSize, err := cmd.Flags().GetInt64("soci-min-layer-size")
228+
if err != nil {
229+
return types.ImageConvertOptions{}, err
230+
}
231+
sociSpanSize, err := cmd.Flags().GetInt64("soci-span-size")
232+
if err != nil {
233+
return types.ImageConvertOptions{}, err
234+
}
235+
// #endregion
236+
216237
// #region generic flags
217238
uncompress, err := cmd.Flags().GetBool("uncompress")
218239
if err != nil {
@@ -268,6 +289,13 @@ func convertOptions(cmd *cobra.Command) (types.ImageConvertOptions, error) {
268289
OverlayFsType: overlaybdFsType,
269290
OverlaydbDBStr: overlaybdDbstr,
270291
// #endregion
292+
// #region soci flags
293+
Soci: soci,
294+
SociOptions: types.SociOptions{
295+
SpanSize: sociSpanSize,
296+
MinLayerSize: sociMinLayerSize,
297+
},
298+
// #endregion
271299
// #region generic flags
272300
Uncompress: uncompress,
273301
Oci: oci,

cmd/nerdctl/image/image_convert_linux_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,24 @@ func TestImageConvert(t *testing.T) {
8888
},
8989
Expected: test.Expects(0, nil, nil),
9090
},
91+
{
92+
Description: "soci",
93+
Require: require.All(
94+
require.Not(nerdtest.Docker),
95+
nerdtest.Soci,
96+
nerdtest.SociVersion("0.10.0"),
97+
),
98+
Cleanup: func(data test.Data, helpers test.Helpers) {
99+
helpers.Anyhow("rmi", "-f", data.Identifier("converted-image"))
100+
},
101+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
102+
return helpers.Command("image", "convert", "--soci",
103+
"--soci-span-size", "2097152",
104+
"--soci-min-layer-size", "20971520",
105+
testutil.CommonImage, data.Identifier("converted-image"))
106+
},
107+
Expected: test.Expects(0, nil, nil),
108+
},
91109
},
92110
}
93111

docs/command-reference.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,11 @@ Flags:
960960
- `--oci` : convert Docker media types to OCI media types
961961
- `--platform=<PLATFORM>` : convert content for a specific platform
962962
- `--all-platforms` : convert content for all platforms (default: false)
963+
- `--soci`: generate SOCI v2 Indices to oci images.
964+
*[**Note**: content is converted for all platforms by default when using this flag, use the `--platorm` flag to limit this behavior]*
965+
- `--soci-min-layer-size` : Span size in bytes that soci index uses to segment layer data. Default is 4 MiB.
966+
- `--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.
967+
963968

964969
### :nerd_face: nerdctl image encrypt
965970

docs/soci.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,18 @@ For images that already have SOCI indices, see https://gallery.ecr.aws/soci-work
4545
nerdctl push --snapshotter=soci --soci-span-size=2097152 --soci-min-layer-size=20971520 public.ecr.aws/my-registry/my-repo:latest
4646
```
4747
--soci-span-size and --soci-min-layer-size are two properties to customize the SOCI index. See [Command Reference](https://github.com/containerd/nerdctl/blob/377b2077bb616194a8ef1e19ccde32aa1ffd6c84/docs/command-reference.md?plain=1#L773) for further details.
48+
49+
50+
## Enable SOCI for `nerdctl image convert`
51+
52+
| :zap: Requirement | nerdctl >= 2.2.0 |
53+
| ----------------- | ---------------- |
54+
55+
| :zap: Requirement | soci-snapshotter >= 0.10.0 |
56+
| ----------------- | ---------------- |
57+
58+
- Convert an image to generate SOCI Index artifacts v2. Running the `nerdctl image convert` with the `--soci` flag and a `srcImg` and `dstImg`, `nerdctl` will create the SOCI v2 indices and the new image will be present in the `dstImg` address.
59+
```console
60+
nerdctl image convert --soci --soci-span-size=2097152 --soci-min-layer-size=20971520 public.ecr.aws/my-registry/my-repo:latest public.ecr.aws/my-registry/my-repo:soci
61+
```
62+
--soci-span-size and --soci-min-layer-size are two properties to customize the SOCI index. See [Command Reference](https://github.com/containerd/nerdctl/blob/377b2077bb616194a8ef1e19ccde32aa1ffd6c84/docs/command-reference.md?plain=1#L773) for further details.

pkg/api/types/image_types.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ package types
1919
import (
2020
"io"
2121

22-
"github.com/opencontainers/image-spec/specs-go/v1"
22+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
2323
)
2424

2525
// ImageListOptions specifies options for `nerdctl image list`.
@@ -124,6 +124,12 @@ type ImageConvertOptions struct {
124124
OverlaydbDBStr string
125125
// #endregion
126126

127+
// #region soci flags
128+
// Soci convert image to SOCI format.eiifc
129+
Soci bool
130+
// SociOptions contains SOCI-specific options
131+
SociOptions SociOptions
132+
// #endregion
127133
}
128134

129135
// ImageCryptOptions specifies options for `nerdctl image encrypt` and `nerdctl image decrypt`.
@@ -200,7 +206,7 @@ type ImagePullOptions struct {
200206
// If nil, it will unpack automatically if only 1 platform is specified.
201207
Unpack *bool
202208
// Content for specific platforms. Empty if `--all-platforms` is true
203-
OCISpecPlatform []v1.Platform
209+
OCISpecPlatform []ocispec.Platform
204210
// Pull mode
205211
Mode string
206212
// Suppress verbose output

pkg/cmd/image/convert.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import (
4747
converterutil "github.com/containerd/nerdctl/v2/pkg/imgutil/converter"
4848
"github.com/containerd/nerdctl/v2/pkg/platformutil"
4949
"github.com/containerd/nerdctl/v2/pkg/referenceutil"
50+
"github.com/containerd/nerdctl/v2/pkg/snapshotterutil"
5051
)
5152

5253
func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRawRef string, options types.ImageConvertOptions) error {
@@ -86,8 +87,9 @@ func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRa
8687
zstdchunked := options.ZstdChunked
8788
overlaybd := options.Overlaybd
8889
nydus := options.Nydus
90+
soci := options.Soci
8991
var finalize func(ctx context.Context, cs content.Store, ref string, desc *ocispec.Descriptor) (*images.Image, error)
90-
if estargz || zstd || zstdchunked || overlaybd || nydus {
92+
if estargz || zstd || zstdchunked || overlaybd || nydus || soci {
9193
convertCount := 0
9294
if estargz {
9395
convertCount++
@@ -104,9 +106,12 @@ func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRa
104106
if nydus {
105107
convertCount++
106108
}
109+
if soci {
110+
convertCount++
111+
}
107112

108113
if convertCount > 1 {
109-
return errors.New("options --estargz, --zstdchunked, --overlaybd and --nydus lead to conflict, only one of them can be used")
114+
return errors.New("options --estargz, --zstdchunked, --overlaybd, --nydus and --soci lead to conflict, only one of them can be used")
110115
}
111116

112117
var convertFunc converter.ConvertFunc
@@ -164,6 +169,16 @@ func Convert(ctx context.Context, client *containerd.Client, srcRawRef, targetRa
164169
)),
165170
)
166171
convertType = "nydus"
172+
case soci:
173+
// Convert image to SOCI format
174+
convertedRef, err := snapshotterutil.ConvertSociIndexV2(ctx, client, srcRef, targetRef, options.GOptions, options.Platforms, options.SociOptions)
175+
if err != nil {
176+
return fmt.Errorf("failed to convert image to SOCI format: %w", err)
177+
}
178+
res := converterutil.ConvertedImageInfo{
179+
Image: convertedRef,
180+
}
181+
return printConvertedImage(options.Stdout, options, res)
167182
}
168183

169184
if convertType != "overlaybd" {

pkg/cmd/image/push.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options
209209
return err
210210
}
211211
if options.GOptions.Snapshotter == "soci" {
212-
if err = snapshotterutil.CreateSoci(ref, options.GOptions, options.AllPlatforms, options.Platforms, options.SociOptions); err != nil {
212+
if err = snapshotterutil.CreateSociIndexV1(ref, options.GOptions, options.AllPlatforms, options.Platforms, options.SociOptions); err != nil {
213213
return err
214214
}
215215
if err = snapshotterutil.PushSoci(ref, options.GOptions, options.AllPlatforms, options.Platforms); err != nil {

pkg/snapshotterutil/sociutil.go

Lines changed: 68 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,26 @@ package snapshotterutil
1818

1919
import (
2020
"bufio"
21+
"context"
22+
"fmt"
2123
"os"
2224
"os/exec"
2325
"strconv"
2426
"strings"
2527

28+
"github.com/containerd/containerd/v2/client"
2629
"github.com/containerd/log"
2730

2831
"github.com/containerd/nerdctl/v2/pkg/api/types"
2932
)
3033

31-
// CreateSoci creates a SOCI index(`rawRef`)
32-
func CreateSoci(rawRef string, gOpts types.GlobalCommandOptions, allPlatform bool, platforms []string, sOpts types.SociOptions) error {
34+
// setupSociCommand creates and sets up a SOCI command with common configuration
35+
func setupSociCommand(gOpts types.GlobalCommandOptions) (*exec.Cmd, error) {
3336
sociExecutable, err := exec.LookPath("soci")
3437
if err != nil {
3538
log.L.WithError(err).Error("soci executable not found in path $PATH")
3639
log.L.Info("you might consider installing soci from: https://github.com/awslabs/soci-snapshotter/blob/main/docs/install.md")
37-
return err
40+
return nil, err
3841
}
3942

4043
sociCmd := exec.Command(sociExecutable)
@@ -47,7 +50,65 @@ func CreateSoci(rawRef string, gOpts types.GlobalCommandOptions, allPlatform boo
4750
if gOpts.Namespace != "" {
4851
sociCmd.Args = append(sociCmd.Args, "--namespace", gOpts.Namespace)
4952
}
50-
// #endregion
53+
54+
return sociCmd, nil
55+
}
56+
57+
// ConvertSociIndexV2 converts an image to SOCI format and returns the converted image reference with digest
58+
func ConvertSociIndexV2(ctx context.Context, client *client.Client, srcRef string, destRef string, gOpts types.GlobalCommandOptions, platforms []string, sOpts types.SociOptions) (string, error) {
59+
sociCmd, err := setupSociCommand(gOpts)
60+
if err != nil {
61+
return "", err
62+
}
63+
64+
// TODO: Implement conversion logic
65+
sociCmd.Args = append(sociCmd.Args, "convert")
66+
67+
if len(platforms) > 0 {
68+
// multiple values need to be passed as separate, repeating flags in soci as it uses urfave
69+
// https://github.com/urfave/cli/blob/main/docs/v2/examples/flags.md#multiple-values-per-single-flag
70+
for _, p := range platforms {
71+
sociCmd.Args = append(sociCmd.Args, "--platform", p)
72+
}
73+
}
74+
75+
if sOpts.SpanSize != -1 {
76+
sociCmd.Args = append(sociCmd.Args, "--span-size", strconv.FormatInt(sOpts.SpanSize, 10))
77+
}
78+
79+
if sOpts.MinLayerSize != -1 {
80+
sociCmd.Args = append(sociCmd.Args, "--min-layer-size", strconv.FormatInt(sOpts.MinLayerSize, 10))
81+
}
82+
83+
sociCmd.Args = append(sociCmd.Args, srcRef, destRef)
84+
85+
log.L.Infof("Converting image from %s to %s using SOCI format", srcRef, destRef)
86+
87+
err = processSociIO(sociCmd)
88+
if err != nil {
89+
return "", err
90+
}
91+
err = sociCmd.Wait()
92+
if err != nil {
93+
return "", err
94+
}
95+
96+
// Get the converted image's digest
97+
img, err := client.GetImage(ctx, destRef)
98+
if err != nil {
99+
return "", fmt.Errorf("failed to get converted image: %w", err)
100+
}
101+
102+
// Return the full reference with digest
103+
return fmt.Sprintf("%s@%s", destRef, img.Target().Digest), nil
104+
}
105+
106+
// CreateSociIndexV1 creates a SOCI index(`rawRef`)
107+
func CreateSociIndexV1(rawRef string, gOpts types.GlobalCommandOptions, allPlatform bool, platforms []string, sOpts types.SociOptions) error {
108+
sociCmd, err := setupSociCommand(gOpts)
109+
if err != nil {
110+
return err
111+
}
51112

52113
// Global flags have to be put before subcommand before soci upgrades to urfave v3.
53114
// https://github.com/urfave/cli/issues/1113
@@ -73,7 +134,7 @@ func CreateSoci(rawRef string, gOpts types.GlobalCommandOptions, allPlatform boo
73134
// --timeout, --debug, --content-store
74135
sociCmd.Args = append(sociCmd.Args, rawRef)
75136

76-
log.L.Debugf("running %s %v", sociExecutable, sociCmd.Args)
137+
log.L.Debugf("running soci %v", sociCmd.Args)
77138

78139
err = processSociIO(sociCmd)
79140
if err != nil {
@@ -88,25 +149,11 @@ func CreateSoci(rawRef string, gOpts types.GlobalCommandOptions, allPlatform boo
88149
func PushSoci(rawRef string, gOpts types.GlobalCommandOptions, allPlatform bool, platforms []string) error {
89150
log.L.Debugf("pushing SOCI index: %s", rawRef)
90151

91-
sociExecutable, err := exec.LookPath("soci")
152+
sociCmd, err := setupSociCommand(gOpts)
92153
if err != nil {
93-
log.L.WithError(err).Error("soci executable not found in path $PATH")
94-
log.L.Info("you might consider installing soci from: https://github.com/awslabs/soci-snapshotter/blob/main/docs/install.md")
95154
return err
96155
}
97156

98-
sociCmd := exec.Command(sociExecutable)
99-
sociCmd.Env = os.Environ()
100-
101-
// #region for global flags.
102-
if gOpts.Address != "" {
103-
sociCmd.Args = append(sociCmd.Args, "--address", gOpts.Address)
104-
}
105-
if gOpts.Namespace != "" {
106-
sociCmd.Args = append(sociCmd.Args, "--namespace", gOpts.Namespace)
107-
}
108-
// #endregion
109-
110157
// Global flags have to be put before subcommand before soci upgrades to urfave v3.
111158
// https://github.com/urfave/cli/issues/1113
112159
sociCmd.Args = append(sociCmd.Args, "push")
@@ -131,7 +178,7 @@ func PushSoci(rawRef string, gOpts types.GlobalCommandOptions, allPlatform bool,
131178
}
132179
sociCmd.Args = append(sociCmd.Args, rawRef)
133180

134-
log.L.Debugf("running %s %v", sociExecutable, sociCmd.Args)
181+
log.L.Debugf("running soci %v", sociCmd.Args)
135182

136183
err = processSociIO(sociCmd)
137184
if err != nil {

pkg/testutil/nerdtest/requirements.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,53 @@ var RemapIDs = &test.Requirement{
419419
},
420420
}
421421

422+
// SociVersion returns a requirement that checks if the installed SOCI version
423+
// meets the minimum required version
424+
func SociVersion(minVersion string) *test.Requirement {
425+
return &test.Requirement{
426+
Check: func(data test.Data, helpers test.Helpers) (bool, string) {
427+
sociExecutable, err := exec.LookPath("soci")
428+
if err != nil {
429+
return false, fmt.Sprintf("soci executable not found in path $PATH: %v", err)
430+
}
431+
432+
cmd := exec.Command(sociExecutable, "--version")
433+
output, err := cmd.Output()
434+
if err != nil {
435+
return false, fmt.Sprintf("failed to get soci version: %v", err)
436+
}
437+
438+
// Parse version from output
439+
// Example output format: "soci version v0.9.0 737f61a3db40c386f997c1f126344158aa3ad43c"
440+
versionStr := strings.TrimSpace(string(output))
441+
parts := strings.Fields(versionStr)
442+
if len(parts) < 3 {
443+
return false, fmt.Sprintf("unexpected soci version output format: %s", versionStr)
444+
}
445+
446+
// Extract version number without 'v' prefix
447+
installedVersion := strings.TrimPrefix(parts[2], "v")
448+
449+
// Compare versions
450+
v1, err := semver.NewVersion(installedVersion)
451+
if err != nil {
452+
return false, fmt.Sprintf("failed to parse installed version %s: %v", installedVersion, err)
453+
}
454+
455+
v2, err := semver.NewVersion(minVersion)
456+
if err != nil {
457+
return false, fmt.Sprintf("failed to parse minimum required version %s: %v", minVersion, err)
458+
}
459+
460+
if v1.LessThan(v2) {
461+
return false, fmt.Sprintf("installed soci version %s is older than required version %s", installedVersion, minVersion)
462+
}
463+
464+
return true, fmt.Sprintf("soci version %s meets minimum requirement %s", installedVersion, minVersion)
465+
},
466+
}
467+
}
468+
422469
func ContainerdVersion(v string) *test.Requirement {
423470
return &test.Requirement{
424471
Check: func(data test.Data, helpers test.Helpers) (bool, string) {

0 commit comments

Comments
 (0)