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
338 lines
10 KiB
Go
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)
|
|
}
|