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
This commit is contained in:
337
pkg/storage/tinyvol.go
Normal file
337
pkg/storage/tinyvol.go
Normal file
@@ -0,0 +1,337 @@
|
||||
/*
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user