Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 70fc46c

Browse files
author
weipeng
committedJan 15, 2025·
Add image squash ut & Update docs/command-reference.md
Signed-off-by: weipeng <[email protected]>
1 parent baf13d2 commit 70fc46c

File tree

6 files changed

+177
-60
lines changed

6 files changed

+177
-60
lines changed
 

‎cmd/nerdctl/image/image_squash.go

+7-12
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,15 @@ import (
2828
)
2929

3030
func addSquashFlags(cmd *cobra.Command) {
31-
cmd.Flags().IntP("layer-count", "c", 0, "The number of layers that can be compressed")
32-
cmd.Flags().StringP("layer-digest", "d", "", "The digest of the layer to be compressed")
33-
cmd.Flags().StringP("author", "a", "", `Author (e.g., "nerdctl contributor <nerdctl-dev@example.com>")`)
34-
cmd.Flags().StringP("message", "m", "", "Commit message")
31+
cmd.Flags().IntP("last-n-layer", "n", 0, "The number of specify squashing the last N (N=layer-count) layers")
32+
cmd.Flags().StringP("author", "a", "nerdctl", `Author (e.g., "nerdctl contributor <nerdctl-dev@example.com>")`)
33+
cmd.Flags().StringP("message", "m", "generated by nerdctl squash", "Commit message")
3534
}
3635

36+
// NewSquashCommand returns a new `squash` command to compress the number of layers of the image
3737
func NewSquashCommand() *cobra.Command {
3838
var squashCommand = &cobra.Command{
39-
Use: "squash [flags] SOURCE_IMAGE TAG_IMAGE",
39+
Use: "squash [flags] SOURCE_IMAGE TARGET_IMAGE",
4040
Short: "Compress the number of layers of the image",
4141
Args: helpers.IsExactArgs(2),
4242
RunE: squashAction,
@@ -52,11 +52,7 @@ func processSquashCommandFlags(cmd *cobra.Command, args []string) (options types
5252
if err != nil {
5353
return options, err
5454
}
55-
layerCount, err := cmd.Flags().GetInt("layer-count")
56-
if err != nil {
57-
return options, err
58-
}
59-
layerDigest, err := cmd.Flags().GetString("layer-digest")
55+
layerN, err := cmd.Flags().GetInt("last-n-layer")
6056
if err != nil {
6157
return options, err
6258
}
@@ -78,8 +74,7 @@ func processSquashCommandFlags(cmd *cobra.Command, args []string) (options types
7874
SourceImageRef: args[0],
7975
TargetImageName: args[1],
8076

81-
SquashLayerCount: layerCount,
82-
SquashLayerDigest: layerDigest,
77+
SquashLayerLastN: layerN,
8378
}
8479
return options, nil
8580
}
+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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.Not(nerdtest.Docker),
43+
nerdtest.CGroup,
44+
)
45+
46+
testCase.SubTests = []*test.Case{
47+
{
48+
Description: "by last-n-layer",
49+
Require: require,
50+
NoParallel: true,
51+
Cleanup: func(data test.Data, helpers test.Helpers) {
52+
identifier := data.Identifier()
53+
secondIdentifier := secondCommitedIdentifierName(identifier)
54+
squashIdentifier := squashIdentifierName(identifier)
55+
helpers.Anyhow("rm", "-f", identifier)
56+
helpers.Anyhow("rm", "-f", secondIdentifier)
57+
helpers.Anyhow("rm", "-f", squashIdentifier)
58+
59+
helpers.Anyhow("rmi", "-f", secondIdentifier)
60+
helpers.Anyhow("rmi", "-f", identifier)
61+
helpers.Anyhow("rmi", "-f", squashIdentifier)
62+
helpers.Anyhow("image", "prune", "-f")
63+
},
64+
Setup: func(data test.Data, helpers test.Helpers) {
65+
identifier := data.Identifier()
66+
helpers.Ensure("run", "-d", "--name", identifier, testutil.CommonImage, "sleep", nerdtest.Infinity)
67+
helpers.Ensure("exec", identifier, "sh", "-euxc", `echo hello-first-commit > /foo`)
68+
helpers.Ensure("commit", "-c", `CMD ["cat", "/foo"]`, "-m", `first commit`, "--pause=true", identifier, identifier)
69+
out := helpers.Capture("run", "--rm", identifier)
70+
assert.Equal(t, out, "hello-first-commit\n")
71+
72+
secondIdentifier := secondCommitedIdentifierName(identifier)
73+
helpers.Ensure("run", "-d", "--name", secondIdentifier, identifier, "sleep", nerdtest.Infinity)
74+
helpers.Ensure("exec", secondIdentifier, "sh", "-euxc", `echo hello-second-commit > /bar && echo hello-squash-commit > /foo`)
75+
helpers.Ensure("commit", "-c", `CMD ["cat", "/foo", "/bar"]`, "-m", `second commit`, "--pause=true", secondIdentifier, secondIdentifier)
76+
out = helpers.Capture("run", "--rm", secondIdentifier)
77+
assert.Equal(t, out, "hello-squash-commit\nhello-second-commit\n")
78+
79+
squashIdentifier := squashIdentifierName(identifier)
80+
helpers.Ensure("image", "squash", "-n", "2", "-m", "squash commit", secondIdentifier, squashIdentifier)
81+
out = helpers.Capture("run", "--rm", squashIdentifier)
82+
assert.Equal(t, out, "hello-squash-commit\nhello-second-commit\n")
83+
},
84+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
85+
identifier := data.Identifier()
86+
87+
squashIdentifier := squashIdentifierName(identifier)
88+
return helpers.Command("image", "history", "--human=true", "--format=json", squashIdentifier)
89+
},
90+
Expected: test.Expects(0, nil, func(stdout string, info string, t *testing.T) {
91+
history, err := decode(stdout)
92+
assert.NilError(t, err, info)
93+
assert.Equal(t, len(history), 3, info)
94+
assert.Equal(t, history[0].Comment, "squash commit", info)
95+
}),
96+
},
97+
}
98+
99+
testCase.Run(t)
100+
}

‎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

+18
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,24 @@ Flags:
994994
- `--platform=<PLATFORM>` : Convert content for a specific platform
995995
- `--all-platforms` : Convert content for all platforms (default: false)
996996

997+
### :nerd_face: nerdctl image squash
998+
999+
Squash last-n-layer into a single layer.
1000+
1001+
Usage: `nerdctl image squash [OPTIONS] SOURCE_IMAGE[:TAG] TARGET_IMAGE[:TAG]`
1002+
1003+
Example:
1004+
1005+
```bash
1006+
nerdctl image pull example.com/foo:latest
1007+
nerdctl image squash ----last-n-layer=2 --message="generated by nerdctl squash" example.com/foo:latest example.com/foo:squashed
1008+
```
1009+
1010+
Flags:
1011+
- `-n --last-n-layer=<NUMBER>`: The number of specify squashing the last N (N=layer-count) layers
1012+
- `-m --message=<MESSAGE>`: Commit message for the squashed image
1013+
- `-a --author=<AUTHOR>`: Author of the squashed image
1014+
9971015
## Registry
9981016

9991017
### :whale: nerdctl login

‎pkg/api/types/image_types.go

+2-4
Original file line numberDiff line numberDiff line change
@@ -305,8 +305,6 @@ type ImageSquashOptions struct {
305305
// TargetImageName is the name of the squashed image
306306
TargetImageName string
307307

308-
// SquashLayerCount is the number of layers to squash
309-
SquashLayerCount int
310-
// SquashLayerDigest is the digest of the layer to squash
311-
SquashLayerDigest string
308+
// SquashLayerLastN is the number of layers to squash
309+
SquashLayerLastN int
312310
}

‎pkg/cmd/image/squash.go

+50-43
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,41 +90,25 @@ 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) {
98-
// get the layer descriptors by the layer digest
99-
if sr.opt.SquashLayerDigest != "" {
100-
find := false
101-
var res []ocispec.Descriptor
102-
for _, layer := range image.Manifest.Layers {
103-
if layer.Digest.String() == sr.opt.SquashLayerDigest {
104-
find = true
105-
}
106-
if find {
107-
res = append(res, layer)
108-
}
109-
}
110-
if !find {
111-
return nil, fmt.Errorf("layer digest %s not found in the image: %w", sr.opt.SquashLayerDigest, errdefs.ErrNotFound)
112-
}
113-
return res, nil
114-
}
115-
116103
// 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
104+
if sr.opt.SquashLayerLastN > 1 && sr.opt.SquashLayerLastN <= len(image.manifest.Layers) {
105+
return image.manifest.Layers[len(image.manifest.Layers)-sr.opt.SquashLayerLastN:], nil
119106
}
120107

121108
return nil, fmt.Errorf("invalid squash option: %w", errdefs.ErrInvalidArgument)
122109
}
123110

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

158146
func (sr *squashRuntime) generateBaseImageConfig(ctx context.Context, image *squashImage, remainingLayerCount int) (ocispec.Image, error) {
159147
// generate squash squashImage config
160-
orginalConfig, _, err := imgutil.ReadImageConfig(ctx, image.ClientImage) // aware of img.platform
148+
orginalConfig, _, err := imgutil.ReadImageConfig(ctx, image.clientImage) // aware of img.platform
161149
if err != nil {
162150
return ocispec.Image{}, err
163151
}
@@ -257,9 +245,9 @@ func (sr *squashRuntime) writeContentsForImage(ctx context.Context, snName strin
257245
return newMfstDesc, configDesc.Digest, nil
258246
}
259247

248+
// createSquashImage creates a new squashImage in the image store.
260249
func (sr *squashRuntime) createSquashImage(ctx context.Context, img images.Image) (images.Image, error) {
261250
newImg, err := sr.imageStore.Update(ctx, img)
262-
log.G(ctx).Infof("updated new squashImage %s", img.Name)
263251
if err != nil {
264252
// if err is `not found` in the message then create the squashImage, otherwise return the error
265253
if !errdefs.IsNotFound(err) {
@@ -268,13 +256,12 @@ func (sr *squashRuntime) createSquashImage(ctx context.Context, img images.Image
268256
if _, err := sr.imageStore.Create(ctx, img); err != nil {
269257
return newImg, fmt.Errorf("failed to create new squashImage %s: %w", img.Name, err)
270258
}
271-
log.G(ctx).Infof("created new squashImage %s", img.Name)
272259
}
273260
return newImg, nil
274261
}
275262

276263
// 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) {
264+
func (sr *squashRuntime) generateCommitImageConfig(ctx context.Context, baseImg images.Image, baseConfig ocispec.Image, diffID digest.Digest) (ocispec.Image, error) {
278265
createdTime := time.Now()
279266
arch := baseConfig.Architecture
280267
if arch == "" {
@@ -292,6 +279,7 @@ func (sr *squashRuntime) generateCommitImageConfig(ctx context.Context, baseConf
292279
}
293280
comment := strings.TrimSpace(sr.opt.Message)
294281

282+
baseImageDigest := strings.Split(baseImg.Target.Digest.String(), ":")[1][:12]
295283
return ocispec.Image{
296284
Platform: ocispec.Platform{
297285
Architecture: arch,
@@ -307,7 +295,7 @@ func (sr *squashRuntime) generateCommitImageConfig(ctx context.Context, baseConf
307295
},
308296
History: append(baseConfig.History, ocispec.History{
309297
Created: &createdTime,
310-
CreatedBy: "",
298+
CreatedBy: fmt.Sprintf("squash from %s", baseImageDigest),
311299
Author: author,
312300
Comment: comment,
313301
EmptyLayer: false,
@@ -317,19 +305,38 @@ func (sr *squashRuntime) generateCommitImageConfig(ctx context.Context, baseConf
317305

318306
// Squash will squash the image with the given options.
319307
func Squash(ctx context.Context, client *containerd.Client, option types.ImageSquashOptions) error {
308+
var srcName string
309+
walker := &imagewalker.ImageWalker{
310+
Client: client,
311+
OnFound: func(ctx context.Context, found imagewalker.Found) error {
312+
if srcName == "" {
313+
srcName = found.Image.Name
314+
}
315+
return nil
316+
},
317+
}
318+
matchCount, err := walker.Walk(ctx, option.SourceImageRef)
319+
if err != nil {
320+
return err
321+
}
322+
if matchCount < 1 {
323+
return fmt.Errorf("%s: not found", option.SourceImageRef)
324+
}
325+
326+
option.SourceImageRef = srcName
320327
sr := newSquashRuntime(client, option)
321328
ctx = namespaces.WithNamespace(ctx, sr.namespace)
322329
// init squashImage
323-
image, err := sr.initImage(ctx)
330+
img, err := sr.initImage(ctx)
324331
if err != nil {
325332
return err
326333
}
327334
// generate squash layers
328-
sLayers, err := sr.generateSquashLayer(image)
335+
sLayers, err := sr.generateSquashLayer(img)
329336
if err != nil {
330337
return err
331338
}
332-
remainingLayerCount := len(image.Manifest.Layers) - len(sLayers)
339+
remainingLayerCount := len(img.manifest.Layers) - len(sLayers)
333340
// Don't gc me and clean the dirty data after 1 hour!
334341
ctx, done, err := sr.client.WithLease(ctx, leases.WithRandomID(), leases.WithExpiration(1*time.Hour))
335342
if err != nil {
@@ -338,7 +345,7 @@ func Squash(ctx context.Context, client *containerd.Client, option types.ImageSq
338345
defer done(ctx)
339346

340347
// generate remaining base squashImage config
341-
baseImage, err := sr.generateBaseImageConfig(ctx, image, remainingLayerCount)
348+
baseImage, err := sr.generateBaseImageConfig(ctx, img, remainingLayerCount)
342349
if err != nil {
343350
return err
344351
}
@@ -348,27 +355,27 @@ func Squash(ctx context.Context, client *containerd.Client, option types.ImageSq
348355
return err
349356
}
350357
// generate commit image config
351-
imageConfig, err := sr.generateCommitImageConfig(ctx, baseImage, diffID)
358+
imageConfig, err := sr.generateCommitImageConfig(ctx, img.image, baseImage, diffID)
352359
if err != nil {
353360
log.G(ctx).WithError(err).Error("failed to generate commit image config")
354361
return fmt.Errorf("failed to generate commit image config: %w", err)
355362
}
356-
commitManifestDesc, _, err := sr.writeContentsForImage(ctx, sr.opt.GOptions.Snapshotter, imageConfig, image.Manifest.Layers[:remainingLayerCount], diffLayerDesc)
363+
commitManifestDesc, _, err := sr.writeContentsForImage(ctx, sr.opt.GOptions.Snapshotter, imageConfig, img.manifest.Layers[:remainingLayerCount], diffLayerDesc)
357364
if err != nil {
358365
log.G(ctx).WithError(err).Error("failed to write contents for image")
359366
return err
360367
}
361-
nimg := images.Image{
368+
nImg := images.Image{
362369
Name: sr.opt.TargetImageName,
363370
Target: commitManifestDesc,
364371
UpdatedAt: time.Now(),
365372
}
366-
_, err = sr.createSquashImage(ctx, nimg)
373+
_, err = sr.createSquashImage(ctx, nImg)
367374
if err != nil {
368375
log.G(ctx).WithError(err).Error("failed to create squash image")
369376
return err
370377
}
371-
cimg := containerd.NewImage(sr.client, nimg)
378+
cimg := containerd.NewImage(sr.client, nImg)
372379
if err := cimg.Unpack(ctx, sr.opt.GOptions.Snapshotter, containerd.WithSnapshotterPlatformCheck()); err != nil {
373380
log.G(ctx).WithError(err).Error("failed to unpack squash image")
374381
return err
@@ -433,7 +440,7 @@ func newSquashRuntime(client *containerd.Client, option types.ImageSquashOptions
433440
}
434441
}
435442

436-
// copied from github.com/containerd/containerd/rootfs/apply.go
443+
// copied from https://github.com/containerd/containerd/blob/89623f28b87a6004d4b785663257362d1658a729/rootfs/apply.go#L106
437444
func uniquePart() string {
438445
t := time.Now()
439446
var b [3]byte

0 commit comments

Comments
 (0)
Please sign in to comment.