Skip to content
28 changes: 25 additions & 3 deletions lib/diskutilization/diskutilization.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"io/fs"
"os"
"path/filepath"
"strings"
"syscall"

"github.com/kernel/hypeman/lib/paths"
Expand Down Expand Up @@ -56,7 +57,7 @@ func Collect(p *paths.Paths) (Breakdown, error) {
return false
}
name := entry.Name()
return name == "rootfs.erofs" || name == "rootfs.ext4"
return name == "rootfs.erofs" || name == "rootfs.ext4" || strings.HasPrefix(name, "layer.")
})
if err != nil {
return Breakdown{}, err
Expand Down Expand Up @@ -178,16 +179,32 @@ func sumDirectChildFileAllocatedBytes(root string, childFile string) (int64, err

func sumMatchingFilesAllocatedBytes(root string, match func(path string, entry fs.DirEntry) bool) (int64, error) {
var total int64
seen := make(map[fileIdentity]struct{})
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if match(path, entry) {
total += allocatedBytesForPath(path)
if !match(path, entry) {
return nil
}
info, statErr := os.Lstat(path)
if statErr != nil {
if os.IsNotExist(statErr) {
return nil
}
return statErr
}
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
identity := fileIdentity{dev: uint64(stat.Dev), ino: uint64(stat.Ino)}
if _, exists := seen[identity]; exists {
return nil
}
seen[identity] = struct{}{}
}
total += allocatedBytesForPath(path)
return nil
})
if err != nil {
Expand Down Expand Up @@ -244,6 +261,11 @@ func sumSnapshotTreeAllocatedBytes(root string, sharedExtents *sharedExtentTrack
return privateTotal, sharedTotal, nil
}

type fileIdentity struct {
dev uint64
ino uint64
}

func allocatedBytesForPath(path string) int64 {
info, err := os.Lstat(path)
if err != nil {
Expand Down
16 changes: 16 additions & 0 deletions lib/diskutilization/diskutilization_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,22 @@ func TestCollect_UsesAllocatedBytesAndClassifiesSnapshots(t *testing.T) {
require.Equal(t, otherTotal, utilization.SnapshotOther)
}

func TestCollect_DeduplicatesHardLinkedImagesAndCountsLayers(t *testing.T) {
p := paths.New(t.TempDir())
imagePath := filepath.Join(p.ImagesDir(), "repo", "digest", "rootfs.erofs")
require.NoError(t, createSparseTestFile(imagePath, 8192, []sparseWrite{{offset: 0, data: []byte("image")}}))
aliasPath := filepath.Join(p.ImagesDir(), "content", "digest", "rootfs.erofs")
require.NoError(t, os.MkdirAll(filepath.Dir(aliasPath), 0755))
require.NoError(t, os.Link(imagePath, aliasPath))

layerPath := filepath.Join(p.ImageLayersDir(), "layer-digest", "layer.erofs")
require.NoError(t, createSparseTestFile(layerPath, 8192, []sparseWrite{{offset: 0, data: []byte("layer")}}))

utilization, err := Collect(p)
require.NoError(t, err)
require.Equal(t, allocatedBytesForPath(imagePath)+allocatedBytesForPath(layerPath), utilization.Images)
}

func createSparseTestFile(path string, size int64, writes []sparseWrite) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
Expand Down
83 changes: 62 additions & 21 deletions lib/images/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import (
"strings"
)

// validateModelPairing validates the persisted model before composition so
// every manifest layer has a corresponding, verified config diff ID.
// validateModelPairing mirrors validateConfigFileForUnpack: the image config
// must carry one diff id per manifest layer so composition never indexes past
// the end of the pairing.
func validateModelPairing(layoutTag string, model *imageManifestModel) error {
if err := validateManifestModel(layoutTag, model); err != nil {
return fmt.Errorf("unpack rootfs: %w", err)
Expand All @@ -17,51 +18,91 @@ func validateModelPairing(layoutTag string, model *imageManifestModel) error {
}

// composeRootfs merges an image's layers into dest in manifest order, reading
// each layer blob from the shared OCI cache. Whiteout and opaque-directory
// markers are interpreted as each layer is applied.
// each layer blob from the shared OCI cache. The result is one complete rootfs
// tree that is exported to a single disk, matching the guest's contract: one
// read-only lower filesystem and one writable overlay upper. Whiteout and
// opaque-directory markers are interpreted as each layer is applied instead of
// being left in the tree, so the composed rootfs never relies on tar-level
// whiteouts composing on overlayfs.
func (c *ociClient) composeRootfs(dest string, layers []layerDescriptor) error {
trees, err := c.composeRootfsWithLayerTrees(dest, layers)
if err != nil {
return err
}
cleanupLayerTrees(trees)
return nil
}

func (c *ociClient) composeRootfsWithLayerTrees(dest string, layers []layerDescriptor) (map[string]layerTree, error) {
if len(layers) == 0 {
return fmt.Errorf("image has no layers")
return nil, fmt.Errorf("image has no layers")
}
if err := os.MkdirAll(dest, 0755); err != nil {
return fmt.Errorf("create compose directory: %w", err)
return nil, fmt.Errorf("create compose directory: %w", err)
}
trees := make(map[string]layerTree, len(layers))
for i, desc := range layers {
if err := c.applyLayerToDir(dest, desc); err != nil {
return fmt.Errorf("apply layer %d (%s): %w", i, desc.Digest, err)
if tree, ok := trees[desc.Digest]; ok {
if err := applyLayerTree(tree.path, dest); err != nil {
cleanupLayerTrees(trees)
return nil, fmt.Errorf("apply layer %d (%s): %w", i, desc.Digest, err)
}
continue
}
tree, err := c.extractLayerTree(desc)
if err != nil {
cleanupLayerTrees(trees)
return nil, fmt.Errorf("extract layer %d (%s): %w", i, desc.Digest, err)
}
if err := applyLayerTree(tree.path, dest); err != nil {
cleanupLayerTrees(trees)
cleanupLayerTree(tree)
return nil, fmt.Errorf("apply layer %d (%s): %w", i, desc.Digest, err)
}
trees[desc.Digest] = tree
}
return nil
return trees, nil
}

// applyLayerToDir extracts one layer into a private staging directory, then
// applies whiteouts before copying the layer's entries onto the composed rootfs.
func (c *ociClient) applyLayerToDir(dest string, desc layerDescriptor) error {
tree, err := c.extractLayerTree(desc)
if err != nil {
return err
}
defer cleanupLayerTree(tree)
if err := applyLayerTree(tree.path, dest); err != nil {
return fmt.Errorf("apply layer tree: %w", err)
}
return nil
}

func (c *ociClient) extractLayerTree(desc layerDescriptor) (layerTree, error) {
layerHex := strings.TrimPrefix(desc.Digest, "sha256:")
if layerHex == "" || strings.Contains(layerHex, "/") || layerHex == "." || strings.Contains(layerHex, "..") {
return fmt.Errorf("invalid layer digest: %s", desc.Digest)
return layerTree{}, fmt.Errorf("invalid layer digest: %s", desc.Digest)
}
blobPath := filepath.Join(c.cacheDir, "blobs", "sha256", layerHex)
if _, err := os.Stat(blobPath); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("layer blob missing from oci cache: %s", desc.Digest)
return layerTree{}, fmt.Errorf("layer blob missing from oci cache: %s", desc.Digest)
}
return fmt.Errorf("stat layer blob: %w", err)
return layerTree{}, fmt.Errorf("stat layer blob: %w", err)
}

layerDir, err := os.MkdirTemp("", "hypeman-layer-*")
if err != nil {
return fmt.Errorf("create layer staging directory: %w", err)
return layerTree{}, fmt.Errorf("create layer staging directory: %w", err)
}
defer os.RemoveAll(layerDir)

stats, err := unpackLayerBlob(blobPath, desc.MediaType, layerDir)
if err != nil {
return err
cleanupLayerTree(layerTree{path: layerDir})
return layerTree{}, err
}
if desc.DiffID != "" && stats.diffID != desc.DiffID {
return fmt.Errorf("layer %s diff id mismatch: got %s, want %s", desc.Digest, stats.diffID, desc.DiffID)
cleanupLayerTree(layerTree{path: layerDir})
return layerTree{}, fmt.Errorf("layer %s diff id mismatch: got %s, want %s", desc.Digest, stats.diffID, desc.DiffID)
}
if err := applyLayerTree(layerDir, dest); err != nil {
return fmt.Errorf("apply layer tree: %w", err)
}
return nil
return layerTree{path: layerDir, stats: stats}, nil
}
28 changes: 18 additions & 10 deletions lib/images/disk_usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,57 +108,65 @@ func totalOCICacheBlobBytesFromFilesystem(blobDir string) (int64, error) {
return total, nil
}

func (m *manager) getDiskUsageTotals() (int64, int64, error) {
func (m *manager) getDiskUsageTotals() (int64, int64, int64, error) {
m.diskUsageMu.RLock()
if m.diskUsageLoaded {
readyImageBytes := m.readyImageBytes
layerBytes := m.layerBytes
ociCacheBytes := m.ociCacheBytes
m.diskUsageMu.RUnlock()
return readyImageBytes, ociCacheBytes, nil
return readyImageBytes, layerBytes, ociCacheBytes, nil
}
m.diskUsageMu.RUnlock()

readyImageBytes, ociCacheBytes, err := m.computeDiskUsageTotals()
readyImageBytes, layerBytes, ociCacheBytes, err := m.computeDiskUsageTotals()
if err != nil {
return 0, 0, err
return 0, 0, 0, err
}

m.diskUsageMu.Lock()
if !m.diskUsageLoaded {
m.readyImageBytes = readyImageBytes
m.layerBytes = layerBytes
m.ociCacheBytes = ociCacheBytes
m.diskUsageLoaded = true
}
readyImageBytes = m.readyImageBytes
layerBytes = m.layerBytes
ociCacheBytes = m.ociCacheBytes
m.diskUsageMu.Unlock()

return readyImageBytes, ociCacheBytes, nil
return readyImageBytes, layerBytes, ociCacheBytes, nil
}

func (m *manager) refreshDiskUsageTotals() {
readyImageBytes, ociCacheBytes, err := m.computeDiskUsageTotals()
readyImageBytes, layerBytes, ociCacheBytes, err := m.computeDiskUsageTotals()
if err != nil {
return
}

m.diskUsageMu.Lock()
m.readyImageBytes = readyImageBytes
m.layerBytes = layerBytes
m.ociCacheBytes = ociCacheBytes
m.diskUsageLoaded = true
m.diskUsageMu.Unlock()
}

func (m *manager) computeDiskUsageTotals() (int64, int64, error) {
func (m *manager) computeDiskUsageTotals() (int64, int64, int64, error) {
readyImageBytes, err := totalReadyImageBytesFromMetadata(m.paths.ImagesDir())
if err != nil {
return 0, 0, err
return 0, 0, 0, err
}
layerBytes, err := totalLayerArtifactBytes(m.paths.ImageLayersDir())
if err != nil {
return 0, 0, 0, err
}
ociCacheBytes, err := totalOCICacheBlobBytesFromFilesystem(m.paths.OCICacheBlobDir())
if err != nil {
return 0, 0, err
return 0, 0, 0, err
}
return readyImageBytes, ociCacheBytes, nil
return readyImageBytes, layerBytes, ociCacheBytes, nil
}

func totalRootfsBytesInDigestDir(digestDir string) (int64, error) {
Expand Down
Loading
Loading