Skip to content

Commit b6077c1

Browse files
author
weipeng
committed
Add image squash ut & Update docs/command-reference.md
Signed-off-by: weipeng <[email protected]>
1 parent 313960f commit b6077c1

File tree

5 files changed

+172
-26
lines changed

5 files changed

+172
-26
lines changed

cmd/nerdctl/image/image_squash.go

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func addSquashFlags(cmd *cobra.Command) {
3434
cmd.Flags().StringP("message", "m", "", "Commit message")
3535
}
3636

37+
// NewSquashCommand returns a new `squash` command to compress the number of layers of the image
3738
func NewSquashCommand() *cobra.Command {
3839
var squashCommand = &cobra.Command{
3940
Use: "squash [flags] SOURCE_IMAGE TAG_IMAGE",
+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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 image
18+
19+
import (
20+
"fmt"
21+
"testing"
22+
23+
"gotest.tools/v3/assert"
24+
25+
"github.com/containerd/nerdctl/v2/pkg/testutil"
26+
"github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest"
27+
"github.com/containerd/nerdctl/v2/pkg/testutil/test"
28+
)
29+
30+
func squashIdentifierName(identifier string) string {
31+
return fmt.Sprintf("%s-squash", identifier)
32+
}
33+
34+
func secondCommitedIdentifierName(identifier string) string {
35+
return fmt.Sprintf("%s-second", identifier)
36+
}
37+
38+
func TestSquash(t *testing.T) {
39+
testCase := nerdtest.Setup()
40+
41+
require := test.Require(
42+
test.Linux,
43+
test.Not(nerdtest.Docker),
44+
nerdtest.CGroup,
45+
)
46+
47+
testCase.SubTests = []*test.Case{
48+
{
49+
Description: "by layer count",
50+
Require: require,
51+
NoParallel: true,
52+
Cleanup: func(data test.Data, helpers test.Helpers) {
53+
identifier := data.Identifier()
54+
secondIdentifier := secondCommitedIdentifierName(identifier)
55+
squashIdentifier := squashIdentifierName(identifier)
56+
helpers.Anyhow("rm", "-f", identifier)
57+
helpers.Anyhow("rm", "-f", secondIdentifier)
58+
helpers.Anyhow("rm", "-f", squashIdentifier)
59+
60+
helpers.Anyhow("rmi", "-f", secondIdentifier)
61+
helpers.Anyhow("rmi", "-f", identifier)
62+
helpers.Anyhow("rmi", "-f", squashIdentifier)
63+
helpers.Anyhow("image", "prune", "-f")
64+
},
65+
Setup: func(data test.Data, helpers test.Helpers) {
66+
identifier := data.Identifier()
67+
helpers.Ensure("run", "-d", "--name", identifier, testutil.CommonImage, "sleep", nerdtest.Infinity)
68+
helpers.Ensure("exec", identifier, "sh", "-euxc", `echo hello-first-commit > /foo`)
69+
helpers.Ensure("commit", "-c", `CMD ["cat", "/foo"]`, "-m", `first commit`, "--pause=true", identifier, identifier)
70+
out := helpers.Capture("run", "--rm", identifier)
71+
assert.Equal(t, out, "hello-first-commit\n")
72+
73+
secondIdentifier := secondCommitedIdentifierName(identifier)
74+
helpers.Ensure("run", "-d", "--name", secondIdentifier, identifier, "sleep", nerdtest.Infinity)
75+
helpers.Ensure("exec", secondIdentifier, "sh", "-euxc", `echo hello-second-commit > /bar && echo hello-squash-commit > /foo`)
76+
helpers.Ensure("commit", "-c", `CMD ["cat", "/foo", "/bar"]`, "-m", `second commit`, "--pause=true", secondIdentifier, secondIdentifier)
77+
out = helpers.Capture("run", "--rm", secondIdentifier)
78+
assert.Equal(t, out, "hello-squash-commit\nhello-second-commit\n")
79+
80+
squashIdentifier := squashIdentifierName(identifier)
81+
helpers.Ensure("image", "squash", "-c", "2", "-m", "squash commit", secondIdentifier, squashIdentifier)
82+
out = helpers.Capture("run", "--rm", squashIdentifier)
83+
assert.Equal(t, out, "hello-squash-commit\nhello-second-commit\n")
84+
},
85+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
86+
identifier := data.Identifier()
87+
88+
squashIdentifier := squashIdentifierName(identifier)
89+
return helpers.Command("image", "history", "--human=true", "--format=json", squashIdentifier)
90+
},
91+
Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.T) {
92+
history, err := decode(stdout)
93+
assert.NilError(t, err, info)
94+
assert.Equal(t, len(history), 3, info)
95+
assert.Equal(t, history[0].Comment, "squash commit", info)
96+
}),
97+
},
98+
}
99+
100+
testCase.Run(t)
101+
}

cmd/nerdctl/main.go

-1
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,6 @@ Config file ($NERDCTL_TOML): %s
295295
image.NewTagCommand(),
296296
image.NewRmiCommand(),
297297
image.NewHistoryCommand(),
298-
image.NewSquashCommand(),
299298
// #endregion
300299

301300
// #region System

docs/command-reference.md

+19
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,25 @@ Flags:
973973
- `--platform=<PLATFORM>` : Convert content for a specific platform
974974
- `--all-platforms` : Convert content for all platforms (default: false)
975975

976+
### :nerd_face: nerdctl image squash
977+
978+
Squash an image layers.
979+
980+
Usage: `nerdctl image squash [OPTIONS] SOURCE_IMAGE[:TAG] TARGET_IMAGE[:TAG]`
981+
982+
Example:
983+
984+
```bash
985+
nerdctl image pull example.com/foo:latest
986+
nerdctl image squash --layer-count=2 --message="squash commit" example.com/foo:latest example.com/foo:squashed
987+
```
988+
989+
Flags:
990+
- `-c --layer-count=<COUNT>`: The number of layers that can be compressed
991+
- `-d --layer-digest=<DIGEST>`: The digest of the layer to be compressed
992+
- `-m --message=<MESSAGE>`: Commit message for the squashed image
993+
- `-a --author=<AUTHOR>`: Author of the squashed image
994+
976995
## Registry
977996

978997
### :whale: nerdctl login

pkg/cmd/image/squash.go

+51-25
Original file line numberDiff line numberDiff line change
@@ -44,20 +44,23 @@ import (
4444
"github.com/containerd/log"
4545

4646
"github.com/containerd/nerdctl/v2/pkg/api/types"
47+
"github.com/containerd/nerdctl/v2/pkg/idutil/imagewalker"
4748
"github.com/containerd/nerdctl/v2/pkg/imgutil"
4849
)
4950

5051
const (
5152
emptyDigest = digest.Digest("")
5253
)
5354

55+
// squashImage is the image for squash operation
5456
type squashImage struct {
55-
ClientImage containerd.Image
56-
Config ocispec.Image
57-
Image images.Image
58-
Manifest *ocispec.Manifest
57+
clientImage containerd.Image
58+
config ocispec.Image
59+
image images.Image
60+
manifest *ocispec.Manifest
5961
}
6062

63+
// squashRuntime is the runtime for squash operation
6164
type squashRuntime struct {
6265
opt types.ImageSquashOptions
6366

@@ -70,6 +73,7 @@ type squashRuntime struct {
7073
snapshotter snapshots.Snapshotter
7174
}
7275

76+
// initImage initializes the squashImage based on the source image reference
7377
func (sr *squashRuntime) initImage(ctx context.Context) (*squashImage, error) {
7478
containerImage, err := sr.imageStore.Get(ctx, sr.opt.SourceImageRef)
7579
if err != nil {
@@ -86,20 +90,21 @@ func (sr *squashRuntime) initImage(ctx context.Context) (*squashImage, error) {
8690
return &squashImage{}, err
8791
}
8892
resImage := &squashImage{
89-
ClientImage: clientImage,
90-
Config: config,
91-
Image: containerImage,
92-
Manifest: manifest,
93+
clientImage: clientImage,
94+
config: config,
95+
image: containerImage,
96+
manifest: manifest,
9397
}
9498
return resImage, err
9599
}
96100

101+
// generateSquashLayer generates the squash layer based on the given options
97102
func (sr *squashRuntime) generateSquashLayer(image *squashImage) ([]ocispec.Descriptor, error) {
98103
// get the layer descriptors by the layer digest
99104
if sr.opt.SquashLayerDigest != "" {
100105
find := false
101106
var res []ocispec.Descriptor
102-
for _, layer := range image.Manifest.Layers {
107+
for _, layer := range image.manifest.Layers {
103108
if layer.Digest.String() == sr.opt.SquashLayerDigest {
104109
find = true
105110
}
@@ -114,13 +119,14 @@ func (sr *squashRuntime) generateSquashLayer(image *squashImage) ([]ocispec.Desc
114119
}
115120

116121
// get the layer descriptors by the layer count
117-
if sr.opt.SquashLayerCount > 1 && sr.opt.SquashLayerCount <= len(image.Manifest.Layers) {
118-
return image.Manifest.Layers[len(image.Manifest.Layers)-sr.opt.SquashLayerCount:], nil
122+
if sr.opt.SquashLayerCount > 1 && sr.opt.SquashLayerCount <= len(image.manifest.Layers) {
123+
return image.manifest.Layers[len(image.manifest.Layers)-sr.opt.SquashLayerCount:], nil
119124
}
120125

121126
return nil, fmt.Errorf("invalid squash option: %w", errdefs.ErrInvalidArgument)
122127
}
123128

129+
// applyLayersToSnapshot applies the layers to the snapshot
124130
func (sr *squashRuntime) applyLayersToSnapshot(ctx context.Context, mount []mount.Mount, layers []ocispec.Descriptor) error {
125131
for _, layer := range layers {
126132
if _, err := sr.differ.Apply(ctx, layer, mount); err != nil {
@@ -157,7 +163,7 @@ func (sr *squashRuntime) createDiff(ctx context.Context, snapshotName string) (o
157163

158164
func (sr *squashRuntime) generateBaseImageConfig(ctx context.Context, image *squashImage, remainingLayerCount int) (ocispec.Image, error) {
159165
// generate squash squashImage config
160-
orginalConfig, _, err := imgutil.ReadImageConfig(ctx, image.ClientImage) // aware of img.platform
166+
orginalConfig, _, err := imgutil.ReadImageConfig(ctx, image.clientImage) // aware of img.platform
161167
if err != nil {
162168
return ocispec.Image{}, err
163169
}
@@ -257,9 +263,9 @@ func (sr *squashRuntime) writeContentsForImage(ctx context.Context, snName strin
257263
return newMfstDesc, configDesc.Digest, nil
258264
}
259265

266+
// createSquashImage creates a new squashImage in the image store.
260267
func (sr *squashRuntime) createSquashImage(ctx context.Context, img images.Image) (images.Image, error) {
261268
newImg, err := sr.imageStore.Update(ctx, img)
262-
log.G(ctx).Infof("updated new squashImage %s", img.Name)
263269
if err != nil {
264270
// if err is `not found` in the message then create the squashImage, otherwise return the error
265271
if !errdefs.IsNotFound(err) {
@@ -268,13 +274,12 @@ func (sr *squashRuntime) createSquashImage(ctx context.Context, img images.Image
268274
if _, err := sr.imageStore.Create(ctx, img); err != nil {
269275
return newImg, fmt.Errorf("failed to create new squashImage %s: %w", img.Name, err)
270276
}
271-
log.G(ctx).Infof("created new squashImage %s", img.Name)
272277
}
273278
return newImg, nil
274279
}
275280

276281
// generateCommitImageConfig returns commit oci image config based on the container's image.
277-
func (sr *squashRuntime) generateCommitImageConfig(ctx context.Context, baseConfig ocispec.Image, diffID digest.Digest) (ocispec.Image, error) {
282+
func (sr *squashRuntime) generateCommitImageConfig(ctx context.Context, baseImg images.Image, baseConfig ocispec.Image, diffID digest.Digest) (ocispec.Image, error) {
278283
createdTime := time.Now()
279284
arch := baseConfig.Architecture
280285
if arch == "" {
@@ -292,6 +297,7 @@ func (sr *squashRuntime) generateCommitImageConfig(ctx context.Context, baseConf
292297
}
293298
comment := strings.TrimSpace(sr.opt.Message)
294299

300+
baseImageDigest := strings.Split(baseImg.Target.Digest.String(), ":")[1][:12]
295301
return ocispec.Image{
296302
Platform: ocispec.Platform{
297303
Architecture: arch,
@@ -307,7 +313,7 @@ func (sr *squashRuntime) generateCommitImageConfig(ctx context.Context, baseConf
307313
},
308314
History: append(baseConfig.History, ocispec.History{
309315
Created: &createdTime,
310-
CreatedBy: "",
316+
CreatedBy: fmt.Sprintf("squash from %s", baseImageDigest),
311317
Author: author,
312318
Comment: comment,
313319
EmptyLayer: false,
@@ -317,19 +323,38 @@ func (sr *squashRuntime) generateCommitImageConfig(ctx context.Context, baseConf
317323

318324
// Squash will squash the image with the given options.
319325
func Squash(ctx context.Context, client *containerd.Client, option types.ImageSquashOptions) error {
326+
var srcName string
327+
walker := &imagewalker.ImageWalker{
328+
Client: client,
329+
OnFound: func(ctx context.Context, found imagewalker.Found) error {
330+
if srcName == "" {
331+
srcName = found.Image.Name
332+
}
333+
return nil
334+
},
335+
}
336+
matchCount, err := walker.Walk(ctx, option.SourceImageRef)
337+
if err != nil {
338+
return err
339+
}
340+
if matchCount < 1 {
341+
return fmt.Errorf("%s: not found", option.SourceImageRef)
342+
}
343+
344+
option.SourceImageRef = srcName
320345
sr := newSquashRuntime(client, option)
321346
ctx = namespaces.WithNamespace(ctx, sr.namespace)
322347
// init squashImage
323-
image, err := sr.initImage(ctx)
348+
img, err := sr.initImage(ctx)
324349
if err != nil {
325350
return err
326351
}
327352
// generate squash layers
328-
sLayers, err := sr.generateSquashLayer(image)
353+
sLayers, err := sr.generateSquashLayer(img)
329354
if err != nil {
330355
return err
331356
}
332-
remainingLayerCount := len(image.Manifest.Layers) - len(sLayers)
357+
remainingLayerCount := len(img.manifest.Layers) - len(sLayers)
333358
// Don't gc me and clean the dirty data after 1 hour!
334359
ctx, done, err := sr.client.WithLease(ctx, leases.WithRandomID(), leases.WithExpiration(1*time.Hour))
335360
if err != nil {
@@ -338,7 +363,7 @@ func Squash(ctx context.Context, client *containerd.Client, option types.ImageSq
338363
defer done(ctx)
339364

340365
// generate remaining base squashImage config
341-
baseImage, err := sr.generateBaseImageConfig(ctx, image, remainingLayerCount)
366+
baseImage, err := sr.generateBaseImageConfig(ctx, img, remainingLayerCount)
342367
if err != nil {
343368
return err
344369
}
@@ -348,27 +373,27 @@ func Squash(ctx context.Context, client *containerd.Client, option types.ImageSq
348373
return err
349374
}
350375
// generate commit image config
351-
imageConfig, err := sr.generateCommitImageConfig(ctx, baseImage, diffID)
376+
imageConfig, err := sr.generateCommitImageConfig(ctx, img.image, baseImage, diffID)
352377
if err != nil {
353378
log.G(ctx).WithError(err).Error("failed to generate commit image config")
354379
return fmt.Errorf("failed to generate commit image config: %w", err)
355380
}
356-
commitManifestDesc, _, err := sr.writeContentsForImage(ctx, sr.opt.GOptions.Snapshotter, imageConfig, image.Manifest.Layers[:remainingLayerCount], diffLayerDesc)
381+
commitManifestDesc, _, err := sr.writeContentsForImage(ctx, sr.opt.GOptions.Snapshotter, imageConfig, img.manifest.Layers[:remainingLayerCount], diffLayerDesc)
357382
if err != nil {
358383
log.G(ctx).WithError(err).Error("failed to write contents for image")
359384
return err
360385
}
361-
nimg := images.Image{
386+
nImg := images.Image{
362387
Name: sr.opt.TargetImageName,
363388
Target: commitManifestDesc,
364389
UpdatedAt: time.Now(),
365390
}
366-
_, err = sr.createSquashImage(ctx, nimg)
391+
_, err = sr.createSquashImage(ctx, nImg)
367392
if err != nil {
368393
log.G(ctx).WithError(err).Error("failed to create squash image")
369394
return err
370395
}
371-
cimg := containerd.NewImage(sr.client, nimg)
396+
cimg := containerd.NewImage(sr.client, nImg)
372397
if err := cimg.Unpack(ctx, sr.opt.GOptions.Snapshotter, containerd.WithSnapshotterPlatformCheck()); err != nil {
373398
log.G(ctx).WithError(err).Error("failed to unpack squash image")
374399
return err
@@ -434,6 +459,7 @@ func newSquashRuntime(client *containerd.Client, option types.ImageSquashOptions
434459
}
435460

436461
// copied from github.com/containerd/containerd/rootfs/apply.go
462+
// which commit hash is 597d0d76ae03e945996ae6e003dae0c668fa158e by McGowan
437463
func uniquePart() string {
438464
t := time.Now()
439465
var b [3]byte

0 commit comments

Comments
 (0)