Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions cmd/composectl/cmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,15 @@ func checkApps(ctx context.Context,
fmt.Println()
}
}
// On a daemon whose images are installed into containerd, the layers to unpack are those without
// a snapshot, whether their blobs are in the store or not.
unpackSize, onCtrd, err := compose.CtrdUnpackSize(ctx, config, status.Apps, srcBlobProvider)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to size the install into dockerd's containerd: %w", err)
}
checkResult := &CheckAppResult{
MissingBlobs: status.MissingBlobs,
MissingBlobs: status.MissingBlobs,
TotalRuntimeSize: unpackSize,
}
for _, bi := range status.MissingBlobs {
if bi.State == compose.BlobFetching {
Expand All @@ -206,7 +213,9 @@ func checkApps(ctx context.Context,
checkResult.TotalPullSize += bi.Descriptor.Size
checkResult.TotalStoreSize += bi.StoreSize
}
checkResult.TotalRuntimeSize += bi.RuntimeSize
if !onCtrd || bi.Type != compose.BlobTypeImageLayer {
checkResult.TotalRuntimeSize += bi.RuntimeSize
}
}
ui, err := compose.GetUsageInfo(config.StoreRoot,
checkResult.TotalStoreSize+checkResult.TotalRuntimeSize, watermark, watermarkInBytes)
Expand Down
81 changes: 81 additions & 0 deletions pkg/compose/ctrd_sizing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package compose

import (
"context"
"fmt"

"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)

// CtrdUnpackSize returns the unpacked size of the image layers that installing the apps into
// dockerd's containerd has to unpack: in each image, the layer of the first missing snapshot and
// every layer above it. onCtrd is false when the images are loaded with `docker load` instead.
func CtrdUnpackSize(ctx context.Context, cfg *Config, apps []App, provider BlobProvider) (size int64, onCtrd bool, err error) {
c, err := ctrdIfInstallTarget(ctx, cfg)
if err != nil || c == nil {
return 0, false, err
}
defer c.Close()
size, err = unpackSize(ctx, cfg, apps, provider, c.firstMissingSnapshot)
return size, true, err
}

// unpackSize counts each snapshot once by chain ID across all images; firstMissing returns the
// index of the first layer of a chain whose snapshot does not exist. An image whose manifest or
// config cannot be read has no known chain: all its layers are counted, so the size never drops
// below what the install unpacks.
func unpackSize(ctx context.Context, cfg *Config, apps []App, provider BlobProvider,
firstMissing func(context.Context, []digest.Digest) (int, error)) (int64, error) {
var size int64
counted := map[digest.Digest]bool{}
for _, app := range apps {
runtimeSize := func(layer *ocispec.Descriptor) int64 {
return app.GetBlobRuntimeSize(layer, cfg.Platform.Architecture, cfg.BlockSize)
}
for _, root := range app.GetComposeRoot().Children {
img, err := ctrdImageOf(ctx, root, provider)
if err != nil {
size += layersSize(treeLayersOf(root), nil, counted, runtimeSize)
continue
}
first, err := firstMissing(ctx, img.chainIDs)
if err != nil {
return 0, fmt.Errorf("failed to scan the snapshots of %s: %w", img.ref, err)
}
size += layersSize(img.layers[first:], img.chainIDs[first:], counted, runtimeSize)
}
}
return size, nil
}

// layersSize sums the runtime size of the layers whose chain ID is not counted yet; without chain
// IDs every layer is summed.
func layersSize(layers []ocispec.Descriptor, chainIDs []digest.Digest, counted map[digest.Digest]bool,
runtimeSize func(*ocispec.Descriptor) int64) (size int64) {
for i := range layers {
if chainIDs != nil && counted[chainIDs[i]] {
continue
}
if chainIDs != nil {
counted[chainIDs[i]] = true
}
size += runtimeSize(&layers[i])
}
return size
}

// treeLayersOf returns the layer descriptors the app tree holds for an image.
func treeLayersOf(root *TreeNode) []ocispec.Descriptor {
mNode, err := platformManifestNode(root)
if err != nil {
return nil
}
var layers []ocispec.Descriptor
for _, child := range mNode.Children {
if child.Type == BlobTypeImageLayer {
layers = append(layers, *child.Descriptor)
}
}
return layers
}
139 changes: 139 additions & 0 deletions pkg/compose/ctrd_sizing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package compose

import (
"context"
"encoding/json"
"errors"
"testing"

"github.com/opencontainers/go-digest"
"github.com/opencontainers/image-spec/identity"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)

// sizingApp is an App with a compose root of images whose layers unpack to three times their size.
type sizingApp struct {
App
root *TreeNode
}

func (a *sizingApp) GetComposeRoot() *TreeNode { return a.root }

func (a *sizingApp) GetBlobRuntimeSize(desc *ocispec.Descriptor, _ string, _ int64) int64 {
return desc.Size * 3
}

func sizingLayer(name string, size int64) ocispec.Descriptor {
return ocispec.Descriptor{MediaType: ocispec.MediaTypeImageLayerGzip, Digest: digest.FromString(name), Size: size}
}

func diffID(layer ocispec.Descriptor) digest.Digest {
return digest.FromString("diff:" + layer.Digest.String())
}

// sizingImage puts the manifest and, when withConfig is set, the config of an image into blobs and
// returns the image's node in the app tree.
func sizingImage(t *testing.T, blobs map[digest.Digest][]byte, name string, withConfig bool,
layers ...ocispec.Descriptor) *TreeNode {
t.Helper()
var config ocispec.Image
for _, l := range layers {
config.RootFS.DiffIDs = append(config.RootFS.DiffIDs, diffID(l))
}
configBytes, err := json.Marshal(config)
if err != nil {
t.Fatal(err)
}
configDesc := ocispec.Descriptor{MediaType: ocispec.MediaTypeImageConfig,
Digest: digest.FromBytes(configBytes), Size: int64(len(configBytes))}
manifestBytes, err := json.Marshal(ocispec.Manifest{Config: configDesc, Layers: layers})
if err != nil {
t.Fatal(err)
}
manifestDesc := ocispec.Descriptor{MediaType: ocispec.MediaTypeImageManifest,
Digest: digest.FromBytes(manifestBytes), Size: int64(len(manifestBytes))}
blobs[manifestDesc.Digest] = manifestBytes
if withConfig {
blobs[configDesc.Digest] = configBytes
}
ref := func(d digest.Digest) []string { return []string{"registry.example/factory/" + name + "@" + d.String()} }
configDesc.URLs = ref(configDesc.Digest)
manifestDesc.URLs = ref(manifestDesc.Digest)
node := &TreeNode{Descriptor: &manifestDesc, Type: BlobTypeImageManifest,
Children: []*TreeNode{{Descriptor: &configDesc, Type: BlobTypeImageConfig}}}
for i := range layers {
node.Children = append(node.Children, &TreeNode{Descriptor: &layers[i], Type: BlobTypeImageLayer})
}
return node
}

// snapshotsOf is a snapshot lookup in which the snapshots of the given layer chains exist, each
// with its parents.
func snapshotsOf(chains ...[]ocispec.Descriptor) func(context.Context, []digest.Digest) (int, error) {
exist := map[digest.Digest]bool{}
for _, chain := range chains {
var diffIDs []digest.Digest
for _, l := range chain {
diffIDs = append(diffIDs, diffID(l))
}
for _, id := range identity.ChainIDs(diffIDs) {
exist[id] = true
}
}
return func(_ context.Context, chainIDs []digest.Digest) (int, error) {
for i, id := range chainIDs {
if !exist[id] {
return i, nil
}
}
return len(chainIDs), nil
}
}

func TestUnpackSize(t *testing.T) {
base, a, b := sizingLayer("base", 100), sizingLayer("a", 10), sizingLayer("b", 20)
cfg := &Config{}
size := func(withConfig bool, lookup func(context.Context, []digest.Digest) (int, error)) int64 {
t.Helper()
blobs := map[digest.Digest][]byte{}
root := &TreeNode{Children: []*TreeNode{
sizingImage(t, blobs, "one", true, base, a),
sizingImage(t, blobs, "two", withConfig, base, b),
}}
got, err := unpackSize(context.Background(), cfg, []App{&sizingApp{root: root}}, NewMemoryBlobProvider(blobs), lookup)
if err != nil {
t.Fatal(err)
}
return got
}

// No snapshot exists, as after a prune: every layer, the shared base once.
if got := size(true, snapshotsOf()); got != 3*(100+10+20) {
t.Errorf("no snapshots: got %d, want %d", got, 3*(100+10+20))
}
// The base snapshot exists: only the layers above it.
if got := size(true, snapshotsOf([]ocispec.Descriptor{base})); got != 3*(10+20) {
t.Errorf("base snapshot exists: got %d, want %d", got, 3*(10+20))
}
// Everything is unpacked already.
if got := size(true, snapshotsOf([]ocispec.Descriptor{base, a}, []ocispec.Descriptor{base, b})); got != 0 {
t.Errorf("all snapshots exist: got %d, want 0", got)
}
// The second image has no config in the store: all its layers count, even the shared base.
if got := size(false, snapshotsOf([]ocispec.Descriptor{base})); got != 3*(10+100+20) {
t.Errorf("unreadable config: got %d, want %d", got, 3*(10+100+20))
}
if got := size(false, snapshotsOf()); got != 3*(100+10+100+20) {
t.Errorf("unreadable config, no snapshots: got %d, want %d", got, 3*(100+10+100+20))
}
}

func TestUnpackSizeFailsOnSnapshotError(t *testing.T) {
blobs := map[digest.Digest][]byte{}
root := &TreeNode{Children: []*TreeNode{sizingImage(t, blobs, "one", true, sizingLayer("base", 100))}}
failing := func(context.Context, []digest.Digest) (int, error) { return 0, errors.New("unavailable") }
_, err := unpackSize(context.Background(), &Config{}, []App{&sizingApp{root: root}}, NewMemoryBlobProvider(blobs), failing)
if err == nil {
t.Fatal("expected the snapshot lookup error")
}
}
77 changes: 77 additions & 0 deletions test/integration/ctrd_install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -611,3 +611,80 @@ func aklitePruneAfterKilledInstall(t *testing.T) {
t.Errorf("install leases left after the prune: %+v", leftovers)
}
}

// TestCtrdCheckSizesByMissingSnapshots: `check` sizes the runtime by the snapshots the install
// still has to create, not by the blobs missing from the store: after a prune every blob is still
// in the store, yet the layers have to be unpacked again.
func TestCtrdCheckSizesByMissingSnapshots(t *testing.T) {
r := newCtrdRig(t)
app := f.NewApp(t, ctrdTestApp)
app.Publish(t)
app.Pull(t)
defer app.Remove(t)
app.Install(t)
defer app.Uninstall(t)

runtimeSize := func(when string) int64 {
t.Helper()
res := akliteCheck(t, app.PublishedUri, "--local")
if len(res.FetchCheck.MissingBlobs) != 0 {
t.Fatalf("%s: check reports missing blobs: %+v", when, res.FetchCheck.MissingBlobs)
}
return res.FetchCheck.TotalRuntimeSize
}
if size := runtimeSize("installed"); size != 0 {
t.Fatalf("installed: total_runtime_size is %d, want 0", size)
}
r.pruneImages(r.topChainIDs(app))
if size := runtimeSize("pruned"); size <= 0 {
t.Fatalf("pruned: total_runtime_size is %d, want more than 0", size)
}
app.Install(t)
if size := runtimeSize("reinstalled"); size != 0 {
t.Fatalf("reinstalled: total_runtime_size is %d, want 0", size)
}
}

// TestCtrdCheckSizesImageWithMissingConfig: `check --local` of an app whose image config is not
// in the store (an interrupted fetch) reports the missing config and sizes the runtime as if every
// layer had to be unpacked.
func TestCtrdCheckSizesImageWithMissingConfig(t *testing.T) {
r := newCtrdRig(t)
app := f.NewApp(t, ctrdTestApp)
app.Publish(t)
app.Pull(t)
defer app.Remove(t)
r.pruneImages(r.topChainIDs(app)) // no snapshot of the app exists: every layer counts

cfg := f.NewTestConfig(t)
tree, err := v1.NewAppLoader().LoadAppTree(context.Background(), compose.NewStoreBlobProvider(r.blobsRoot),
platforms.OnlyStrict(cfg.Platform), app.PublishedUri)
f.Check(t, err)
var unpacked int64
seen := map[digest.Digest]bool{}
imgs := r.images(app.PublishedUri)
for _, img := range imgs {
for _, layer := range img.manifest.Layers {
if !seen[layer.Digest] {
seen[layer.Digest] = true
unpacked += tree.GetBlobRuntimeSize(&layer, cfg.Platform.Architecture, cfg.BlockSize)
}
}
}

configDigest := imgs[0].manifest.Config.Digest
configPath := filepath.Join(r.blobsRoot, configDigest.Encoded())
config, err := os.ReadFile(configPath)
f.Check(t, err)
f.Check(t, os.Remove(configPath))
defer func() { f.Check(t, os.WriteFile(configPath, config, 0o444)) }()

res := akliteCheck(t, app.PublishedUri, "--local")
if _, ok := res.FetchCheck.MissingBlobs[configDigest]; !ok {
t.Errorf("check does not list the config among the missing blobs: %+v", res.FetchCheck.MissingBlobs)
}
if res.FetchCheck.TotalRuntimeSize < unpacked {
t.Errorf("total_runtime_size %d is below the unpacked size of the layers %d",
res.FetchCheck.TotalRuntimeSize, unpacked)
}
}
Loading