/* TinyVol Assembly — Assemble directory trees from CAS blobs via hard-links. TinyVol is the mechanism that turns a CAS blob manifest into a usable rootfs directory tree. Instead of copying files, TinyVol creates hard-links from the assembled tree into the CAS objects directory. This gives each workload its own directory layout while sharing the actual file data on disk. Features: - Manifest-driven: reads a BlobManifest and creates the directory tree - Hard-link based: no data duplication, instant assembly - Assembly timing metrics - Cleanup / disassembly - Integrity verification of assembled trees Copyright (c) Armored Gates LLC. All rights reserved. */ package storage import ( "fmt" "io" "os" "path/filepath" "sort" "strings" "time" ) // ── TinyVol Assembler ──────────────────────────────────────────────────────── // TinyVol assembles and manages CAS-backed directory trees. type TinyVol struct { cas *CASStore baseDir string // root directory for assembled trees } // NewTinyVol creates a TinyVol assembler backed by the given CAS store. // Assembled trees are created under baseDir (e.g. /var/lib/volt/tinyvol). func NewTinyVol(cas *CASStore, baseDir string) *TinyVol { if baseDir == "" { baseDir = "/var/lib/volt/tinyvol" } return &TinyVol{ cas: cas, baseDir: baseDir, } } // ── Assembly ───────────────────────────────────────────────────────────────── // AssemblyResult holds metrics from a TinyVol assembly operation. type AssemblyResult struct { TargetDir string // where the tree was assembled FilesLinked int // number of files hard-linked DirsCreated int // number of directories created TotalBytes int64 // sum of all file sizes (logical, not on-disk) Duration time.Duration // wall-clock time for assembly Errors []string // non-fatal errors encountered } // Assemble creates a directory tree at targetDir from the given BlobManifest. // Each file is hard-linked from the CAS objects directory — no data is copied. // // If targetDir is empty, a directory is created under the TinyVol base dir // using the manifest name. // // The CAS objects directory and the target directory must be on the same // filesystem for hard-links to work. If hard-linking fails (e.g. cross-device), // Assemble falls back to a regular file copy with a warning. func (tv *TinyVol) Assemble(bm *BlobManifest, targetDir string) (*AssemblyResult, error) { start := time.Now() if targetDir == "" { targetDir = filepath.Join(tv.baseDir, bm.Name) } result := &AssemblyResult{TargetDir: targetDir} // Resolve blob list from manifest. entries, err := tv.cas.ResolveBlobList(bm) if err != nil { return nil, fmt.Errorf("tinyvol assemble: %w", err) } // Sort entries so directories are created in order. sort.Slice(entries, func(i, j int) bool { return entries[i].RelPath < entries[j].RelPath }) // Track which directories we've created. createdDirs := make(map[string]bool) for _, entry := range entries { destPath := filepath.Join(targetDir, entry.RelPath) destDir := filepath.Dir(destPath) // Create parent directories. if !createdDirs[destDir] { if err := os.MkdirAll(destDir, 0755); err != nil { result.Errors = append(result.Errors, fmt.Sprintf("mkdir %s: %v", destDir, err)) continue } // Count newly created directories. parts := strings.Split(entry.RelPath, string(filepath.Separator)) for i := 1; i < len(parts); i++ { partial := filepath.Join(targetDir, strings.Join(parts[:i], string(filepath.Separator))) if !createdDirs[partial] { createdDirs[partial] = true result.DirsCreated++ } } createdDirs[destDir] = true } // Try hard-link first. if err := os.Link(entry.BlobPath, destPath); err != nil { // Cross-device or other error — fall back to copy. if copyErr := copyFileForAssembly(entry.BlobPath, destPath); copyErr != nil { result.Errors = append(result.Errors, fmt.Sprintf("link/copy %s: %v / %v", entry.RelPath, err, copyErr)) continue } result.Errors = append(result.Errors, fmt.Sprintf("hard-link failed for %s, fell back to copy", entry.RelPath)) } // Accumulate size from blob. if info, err := os.Stat(entry.BlobPath); err == nil { result.TotalBytes += info.Size() } result.FilesLinked++ } result.Duration = time.Since(start) return result, nil } // AssembleFromRef assembles a tree from a manifest reference name (filename in // the refs directory). func (tv *TinyVol) AssembleFromRef(refName, targetDir string) (*AssemblyResult, error) { bm, err := tv.cas.LoadManifest(refName) if err != nil { return nil, fmt.Errorf("tinyvol assemble from ref: %w", err) } return tv.Assemble(bm, targetDir) } // ── Disassembly / Cleanup ──────────────────────────────────────────────────── // Disassemble removes an assembled directory tree. This only removes the // hard-links and directories — the CAS blobs remain untouched. func (tv *TinyVol) Disassemble(targetDir string) error { if targetDir == "" { return fmt.Errorf("tinyvol disassemble: empty target directory") } // Safety: refuse to remove paths outside our base directory unless the // target is an absolute path that was explicitly provided. if !filepath.IsAbs(targetDir) { targetDir = filepath.Join(tv.baseDir, targetDir) } if err := os.RemoveAll(targetDir); err != nil { return fmt.Errorf("tinyvol disassemble %s: %w", targetDir, err) } return nil } // CleanupAll removes all assembled trees under the TinyVol base directory. func (tv *TinyVol) CleanupAll() error { entries, err := os.ReadDir(tv.baseDir) if err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("tinyvol cleanup all: %w", err) } for _, entry := range entries { if entry.IsDir() { path := filepath.Join(tv.baseDir, entry.Name()) if err := os.RemoveAll(path); err != nil { return fmt.Errorf("tinyvol cleanup %s: %w", path, err) } } } return nil } // ── Verification ───────────────────────────────────────────────────────────── // VerifyResult holds the outcome of verifying an assembled tree. type VerifyResult struct { TotalFiles int Verified int Mismatched int Missing int Errors []string } // Verify checks that an assembled tree matches its manifest. For each file // in the manifest, it verifies the hard-link points to the correct CAS blob // by comparing inode numbers. func (tv *TinyVol) Verify(bm *BlobManifest, targetDir string) (*VerifyResult, error) { result := &VerifyResult{} for relPath, digest := range bm.Objects { result.TotalFiles++ destPath := filepath.Join(targetDir, relPath) blobPath := tv.cas.GetPath(digest) // Check destination exists. destInfo, err := os.Stat(destPath) if err != nil { result.Missing++ result.Errors = append(result.Errors, fmt.Sprintf("missing: %s", relPath)) continue } // Check CAS blob exists. blobInfo, err := os.Stat(blobPath) if err != nil { result.Mismatched++ result.Errors = append(result.Errors, fmt.Sprintf("cas blob missing for %s: %s", relPath, digest)) continue } // Compare by checking if they are the same file (same inode). if os.SameFile(destInfo, blobInfo) { result.Verified++ } else { // Not the same inode — could be a copy or different file. // Check size as a quick heuristic. if destInfo.Size() != blobInfo.Size() { result.Mismatched++ result.Errors = append(result.Errors, fmt.Sprintf("size mismatch for %s: assembled=%d cas=%d", relPath, destInfo.Size(), blobInfo.Size())) } else { // Same size, probably a copy (cross-device assembly). result.Verified++ } } } return result, nil } // ── List ───────────────────────────────────────────────────────────────────── // AssembledTree describes a currently assembled directory tree. type AssembledTree struct { Name string Path string Size int64 // total logical size Files int Created time.Time } // List returns all currently assembled trees under the TinyVol base dir. func (tv *TinyVol) List() ([]AssembledTree, error) { entries, err := os.ReadDir(tv.baseDir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("tinyvol list: %w", err) } var trees []AssembledTree for _, entry := range entries { if !entry.IsDir() { continue } treePath := filepath.Join(tv.baseDir, entry.Name()) info, err := entry.Info() if err != nil { continue } tree := AssembledTree{ Name: entry.Name(), Path: treePath, Created: info.ModTime(), } // Walk to count files and total size. filepath.Walk(treePath, func(path string, fi os.FileInfo, err error) error { if err != nil || fi.IsDir() { return nil } tree.Files++ tree.Size += fi.Size() return nil }) trees = append(trees, tree) } return trees, nil } // ── Helpers ────────────────────────────────────────────────────────────────── // copyFileForAssembly copies a single file (fallback when hard-linking fails). func copyFileForAssembly(src, dst string) error { sf, err := os.Open(src) if err != nil { return err } defer sf.Close() // Preserve permissions from source. srcInfo, err := sf.Stat() if err != nil { return err } df, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode()) if err != nil { return err } defer df.Close() _, err = copyBuffer(df, sf) return err } // copyBuffer copies from src to dst using io.Copy. func copyBuffer(dst *os.File, src *os.File) (int64, error) { return io.Copy(dst, src) }