Files
volt/pkg/storage/tinyvol.go
Karl Clinger 81ad0b597c Volt CLI: source-available under AGPSL v5.0
Complete infrastructure platform CLI:
- Container runtime (systemd-nspawn)
- VoltVisor VMs (Neutron Stardust / QEMU)
- Stellarium CAS (content-addressed storage)
- ORAS Registry
- GitOps integration
- Landlock LSM security
- Compose orchestration
- Mesh networking

Copyright (c) Armored Gates LLC. All rights reserved.
Licensed under AGPSL v5.0
2026-03-21 00:31:12 -05:00

338 lines
10 KiB
Go

/*
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)
}