diff --git a/cmd/composectl/cmd/check.go b/cmd/composectl/cmd/check.go index b32763b..1f1a96a 100644 --- a/cmd/composectl/cmd/check.go +++ b/cmd/composectl/cmd/check.go @@ -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 { @@ -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) diff --git a/pkg/compose/ctrd_sizing.go b/pkg/compose/ctrd_sizing.go new file mode 100644 index 0000000..88306d5 --- /dev/null +++ b/pkg/compose/ctrd_sizing.go @@ -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 +} diff --git a/pkg/compose/ctrd_sizing_test.go b/pkg/compose/ctrd_sizing_test.go new file mode 100644 index 0000000..cd786e6 --- /dev/null +++ b/pkg/compose/ctrd_sizing_test.go @@ -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") + } +} diff --git a/test/integration/ctrd_install_test.go b/test/integration/ctrd_install_test.go index c95d36f..f554e04 100644 --- a/test/integration/ctrd_install_test.go +++ b/test/integration/ctrd_install_test.go @@ -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) + } +}