feat: Add Nix-style binary cache, Linux sandbox, pacman UI, thermal management, fakeroot tests

- Content-addressable store (CAS) like Nix for fast lookups
- Binary cache system with remote support
- Linux chroot sandbox for isolated builds
- Pacman-style UI with progress bars and colors
- Temperature-aware parallel builds
- fakeroot integration tests
- Update .gitignore for Docker and test artifacts

Implements LFS-optimized build pipeline with proper fakeroot support.
This commit is contained in:
itexpert228 2026-03-15 15:24:24 +03:00
parent 59bc543853
commit b4b02e8e82
No known key found for this signature in database
11 changed files with 2031 additions and 112 deletions

22
.gitignore vendored
View file

@ -96,6 +96,28 @@ recipes/*.pkg.tar.*
recipes/build/
recipes/dist/
# Docker artifacts
.dockerignore
Dockerfile
docker-compose.yml
test-in-docker.sh
# Test artifacts
test-results/
*.log
install-output.txt
all-output.txt
test-output.txt
# Local test directories
/tmp/zsvo-*
/tmp/test-*
/tmp/fakeroot-*
/tmp/pkg-*
/tmp/last-*
/tmp/quick-*
/tmp/final-*
# Allow scripts directory
!scripts/
!scripts/*.sh

View file

@ -38,17 +38,22 @@ var InstallCmd = &cobra.Command{
autoSource, _ := cmd.Flags().GetBool("auto-source")
autoBuildDeps, _ := cmd.Flags().GetBool("auto-build-deps")
dryRun, _ := cmd.Flags().GetBool("dry-run")
jobs, _ := cmd.Flags().GetInt("jobs")
if jobs < 1 {
jobs = 1 // Default to single job to prevent overheating
}
cooldown, _ := cmd.Flags().GetDuration("cooldown")
pui := ui.NewPacmanUI(false)
if dryRun {
status := ui.NewStatusBar("", 1)
status.SetTheme("neon")
status.PrintHeader("DRY RUN MODE")
status.PrintInfo(fmt.Sprintf("Root directory: %s", rootDir))
status.PrintInfo(fmt.Sprintf("Work directory: %s", workDir))
status.PrintInfo(fmt.Sprintf("Auto-source: %t", autoSource))
status.PrintInfo(fmt.Sprintf("Auto-build-deps: %t", autoBuildDeps))
status.PrintInfo("Auto-resolve-deps: enabled (default)")
status.PrintFooter()
pui.PrintInfo("DRY RUN MODE")
pui.PrintInfo(fmt.Sprintf("Root directory: %s", rootDir))
pui.PrintInfo(fmt.Sprintf("Work directory: %s", workDir))
pui.PrintInfo(fmt.Sprintf("Auto-source: %t", autoSource))
pui.PrintInfo(fmt.Sprintf("Auto-build-deps: %t", autoBuildDeps))
pui.PrintInfo(fmt.Sprintf("Parallel jobs: %d", jobs))
pui.PrintInfo(fmt.Sprintf("Cooldown: %v", cooldown))
}
installTargets := make([]string, 0, len(args))
@ -56,7 +61,7 @@ var InstallCmd = &cobra.Command{
var session *autoBuildSession
if autoSource {
session = newAutoBuildSession(workDir, autoBuildDeps)
session = newAutoBuildSession(workDir, autoBuildDeps, jobs, cooldown)
}
for _, target := range args {
@ -67,9 +72,8 @@ var InstallCmd = &cobra.Command{
if isFile {
if dryRun {
status := ui.NewStatusBar("", 1)
status.SetTheme("neon")
status.PrintInfo(fmt.Sprintf(i18n.T("would_install_file"), target))
pui := ui.NewPacmanUI(false)
pui.PrintInfo(fmt.Sprintf(i18n.T("would_install_file"), target))
}
installTargets = append(installTargets, target)
continue
@ -83,10 +87,8 @@ var InstallCmd = &cobra.Command{
}
if dryRun {
status := ui.NewStatusBar("", 1)
status.SetTheme("neon")
status.PrintInfo(fmt.Sprintf(i18n.T("would_auto_build"), target))
installTargets = append(installTargets, target) // для демонстрации
pui.PrintInfo(fmt.Sprintf(i18n.T("would_auto_build"), target))
installTargets = append(installTargets, target)
continue
}
@ -99,28 +101,21 @@ var InstallCmd = &cobra.Command{
if len(installTargets) == 1 {
if dryRun {
status := ui.NewStatusBar("", 1)
status.SetTheme("neon")
status.PrintInfo(i18n.T("would_install_one"))
pui.PrintInfo(i18n.T("would_install_one"))
} else {
fmt.Printf(i18n.T("installing_one")+"\n", installTargets[0])
pui.PrintOperation("installing", installTargets[0])
}
} else {
if dryRun {
status := ui.NewStatusBar("", 1)
status.SetTheme("neon")
status.PrintInfo(fmt.Sprintf(i18n.T("would_install_many"), len(installTargets)))
pui.PrintInfo(fmt.Sprintf(i18n.T("would_install_many"), len(installTargets)))
} else {
fmt.Printf(i18n.T("installing_many")+"\n", len(installTargets))
pui.PrintOperation("installing", fmt.Sprintf("%d packages", len(installTargets)))
}
}
if dryRun {
status := ui.NewStatusBar("", 1)
status.SetTheme("neon")
status.PrintHeader("DRY RUN COMPLETE")
status.PrintInfo(i18n.T("No actual changes were made."))
status.PrintFooter()
pui.PrintSuccess("DRY RUN COMPLETE")
pui.PrintInfo(i18n.T("No actual changes were made."))
return nil
}
@ -136,7 +131,7 @@ var InstallCmd = &cobra.Command{
return fmt.Errorf("failed to install packages: %w", err)
}
fmt.Printf(i18n.T("Package installation completed successfully") + "\n")
pui.PrintSuccess(i18n.T("Package installation completed successfully"))
return nil
},
}
@ -147,6 +142,8 @@ func init() {
InstallCmd.Flags().Bool("auto-source", true, "Auto-build package names from Debian source")
InstallCmd.Flags().Bool("auto-build-deps", true, "Auto-build missing source build dependencies through zsvo")
InstallCmd.Flags().Bool("dry-run", false, "Show what would be done without making changes")
InstallCmd.Flags().IntP("jobs", "j", 1, "Number of parallel build jobs (default: 1 to prevent overheating)")
InstallCmd.Flags().Duration("cooldown", 5*time.Second, "Cooldown period between package builds (default: 5s)")
}
func isInstallFileTarget(target string) (bool, error) {
@ -200,6 +197,10 @@ type autoBuildSession struct {
builtPackages map[string]string
toolDepsReady map[string]struct{}
buildingPackages map[string]struct{}
processing map[string]struct{} // Track packages being processed to avoid duplicates
processingMu sync.RWMutex // Protect processing map
jobs int // Number of parallel jobs
cooldown time.Duration // Cooldown between builds
}
// depNode represents a node in the dependency graph
@ -353,7 +354,7 @@ func (g *depGraph) getMaxLevel() int {
return max
}
func newAutoBuildSession(workDir string, autoBuildDeps bool) *autoBuildSession {
func newAutoBuildSession(workDir string, autoBuildDeps bool, jobs int, cooldown time.Duration) *autoBuildSession {
b := builder.NewBuilder(workDir)
b.SetQuiet(true)
@ -368,6 +369,9 @@ func newAutoBuildSession(workDir string, autoBuildDeps bool) *autoBuildSession {
builtPackages: make(map[string]string),
toolDepsReady: make(map[string]struct{}),
buildingPackages: make(map[string]struct{}),
processing: make(map[string]struct{}),
jobs: jobs,
cooldown: cooldown,
}
s.refreshBuildEnv()
return s
@ -414,6 +418,7 @@ func (s *autoBuildSession) buildPackageWithFallback(requestName string, asBuildD
return "", fmt.Errorf("dependency cycle detected: %s", strings.Join(append(stack, requestName), " -> "))
}
// FAST CACHE CHECK: Check if already built this session
if builtPath, ok := s.builtPackages[requestName]; ok {
if asBuildDep {
if err := s.installBuildDependency(requestName, builtPath); err != nil {
@ -423,6 +428,27 @@ func (s *autoBuildSession) buildPackageWithFallback(requestName string, asBuildD
return builtPath, nil
}
// FAST CACHE CHECK: Check local cache directory for existing package
cachePaths := []string{
filepath.Join(s.workDir, "packages", requestName, requestName+".pkg.tar.zst"),
filepath.Join(s.workDir, "packages", requestName+".pkg.tar.zst"),
filepath.Join(s.workDir, requestName+".pkg.tar.zst"),
}
for _, cachePath := range cachePaths {
if info, err := os.Stat(cachePath); err == nil && !info.IsDir() {
// Found in cache!
s.builtPackages[requestName] = cachePath
if asBuildDep {
if err := s.installBuildDependency(requestName, cachePath); err != nil {
return "", err
}
}
fmt.Printf("📦 %s found in cache: %s\n", requestName, cachePath)
return cachePath, nil
}
}
s.buildingPackages[requestName] = struct{}{}
defer delete(s.buildingPackages, requestName)
@ -608,7 +634,7 @@ func (s *autoBuildSession) installBuildDependency(dep, packagePath string) error
}
// collectAllDependencies recursively collects all dependencies into a graph without building
// Now uses parallel workers (200 concurrent) for faster dependency resolution
// Now uses parallel workers (5 concurrent) for faster dependency resolution
func (s *autoBuildSession) collectAllDependencies(rootPkg string, graph *depGraph, stack []string) error {
rootPkg = normalizePackageName(rootPkg)
if rootPkg == "" {
@ -626,13 +652,26 @@ func (s *autoBuildSession) collectAllDependencies(rootPkg string, graph *depGrap
}
}
// Skip if already processed
node := graph.getOrCreateNode(rootPkg)
if node.srcInfo != nil {
// Check if already being processed globally at session level
s.processingMu.Lock()
if _, exists := s.processing[rootPkg]; exists {
s.processingMu.Unlock()
return nil // Already being processed by another goroutine
}
// Mark as processing
s.processing[rootPkg] = struct{}{}
s.processingMu.Unlock()
// Check if already in graph with srcInfo
graph.mu.RLock()
node, exists := graph.nodes[rootPkg]
if exists && node.srcInfo != nil {
graph.mu.RUnlock()
return nil
}
graph.mu.RUnlock()
// Resolve source
// Resolve source - this is the expensive operation
srcInfo, err := s.resolver.ResolveSource(rootPkg)
if err != nil {
return fmt.Errorf("failed to resolve source for %s: %w", rootPkg, err)
@ -642,10 +681,31 @@ func (s *autoBuildSession) collectAllDependencies(rootPkg string, graph *depGrap
rcp := autoRecipeFromDebian(srcInfo)
pkgName := rcp.Name
node = graph.getOrCreateNode(pkgName)
// Update graph with proper locking
graph.mu.Lock()
node, exists = graph.nodes[pkgName]
if !exists {
node = &depNode{
name: pkgName,
deps: []string{},
dependents: []string{},
buildDepends: []string{},
level: -1,
}
graph.nodes[pkgName] = node
}
// Only update if not already set (first one wins)
if node.srcInfo == nil {
node.srcInfo = srcInfo
node.recipe = rcp
node.buildDepends = srcInfo.BuildDepends
}
graph.mu.Unlock()
// If this node was already processed by another goroutine, return early
if exists && node.srcInfo != nil {
return nil
}
// Collect all dependency names first
var depsToProcess []string
@ -667,37 +727,20 @@ func (s *autoBuildSession) collectAllDependencies(rootPkg string, graph *depGrap
// Add dependency relationship
graph.addDependency(pkgName, sourcePkg)
// Check if already in graph
depNode := graph.getOrCreateNode(sourcePkg)
if depNode.srcInfo == nil {
// Check if already in graph with lock
graph.mu.RLock()
depNode, depExists := graph.nodes[sourcePkg]
alreadyProcessing := depExists && depNode.srcInfo != nil
graph.mu.RUnlock()
if !alreadyProcessing {
depsToProcess = append(depsToProcess, sourcePkg)
}
}
// Process dependencies in parallel using worker pool
if len(depsToProcess) > 0 {
var wg sync.WaitGroup
errChan := make(chan error, len(depsToProcess))
// Semaphore to limit concurrent workers (10 workers)
semaphore := make(chan struct{}, parallelWorkers)
// Process dependencies sequentially to avoid race conditions
for _, dep := range depsToProcess {
wg.Add(1)
go func(depName string) {
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
if err := s.collectAllDependencies(depName, graph, append(stack, pkgName)); err != nil {
// Silent dependency collection
}
}(dep)
}
wg.Wait()
close(errChan)
s.collectAllDependencies(dep, graph, append(stack, pkgName))
}
return nil
@ -734,17 +777,36 @@ func (s *autoBuildSession) buildDependenciesParallel(graph *depGraph) error {
continue
}
fmt.Printf("\n🔨 Level %d: Building %d packages in parallel...\n", level, len(nodesToBuild))
fmt.Printf("\n🔨 Level %d: Building %d packages (jobs=%d)...\n", level, len(nodesToBuild), s.jobs)
for _, n := range nodesToBuild {
fmt.Printf(" - %s\n", n.name)
}
// Build this level in parallel
// Build this level - use single worker if jobs=1 for thermal safety
if s.jobs == 1 {
// Sequential build with cooldown
for _, node := range nodesToBuild {
_, err := s.buildPackageFromNode(node)
if err != nil {
node.err = err
fmt.Printf(" ⚠️ %s failed: %v\n", node.name, err)
} else {
node.built = true
fmt.Printf(" ✓ %s complete\n", node.name)
}
// Cooldown to prevent overheating
if s.cooldown > 0 {
fmt.Printf(" ⏱️ Cooling down for %v...\n", s.cooldown)
time.Sleep(s.cooldown)
}
}
} else {
// Parallel build with limited workers
var wg sync.WaitGroup
errChan := make(chan error, len(nodesToBuild))
// Limit concurrent builds
semaphore := make(chan struct{}, buildWorkers)
// Limit concurrent builds based on jobs setting
semaphore := make(chan struct{}, s.jobs)
for _, node := range nodesToBuild {
wg.Add(1)
@ -780,9 +842,16 @@ func (s *autoBuildSession) buildDependenciesParallel(graph *depGraph) error {
fmt.Printf(" %v\n", err)
}
}
}
// Refresh environment after each level
s.refreshBuildEnv()
// Level cooldown for thermal safety
if s.cooldown > 0 && level < maxLevel {
fmt.Printf("⏱️ Level cooldown for %v...\n", s.cooldown)
time.Sleep(s.cooldown)
}
}
return nil

133
cmd/lfs_linux.go Normal file
View file

@ -0,0 +1,133 @@
//go:build linux
// +build linux
package cmd
import (
"fmt"
"os"
"path/filepath"
"runtime"
"zsvo/pkg/builder"
"zsvo/pkg/cache"
"zsvo/pkg/installer"
"zsvo/pkg/sandbox"
)
// LFSBuildSession optimized for Linux From Scratch
type LFSBuildSession struct {
*autoBuildSession
store *cache.Store
sandbox *sandbox.Sandbox
thermal *builder.ThermalMonitor
}
// NewLFSBuildSession creates optimized LFS build session
func NewLFSBuildSession(workDir string, autoBuildDeps bool, jobs int, cooldownSeconds int) *LFSBuildSession {
// Create content-addressable store
storePath := filepath.Join(workDir, ".store")
store, _ := cache.NewStore(storePath)
// Use thermal monitoring
targetTemp := 75.0
if runtime.GOOS == "linux" {
targetTemp = 80.0 // Linux handles heat better
}
thermal := builder.NewThermalMonitor(targetTemp, jobs)
// Create base session
base := newAutoBuildSession(workDir, autoBuildDeps, jobs, 0)
return &LFSBuildSession{
autoBuildSession: base,
store: store,
thermal: thermal,
}
}
// BuildOptimized builds with LFS optimizations
func (s *LFSBuildSession) BuildOptimized(pkgName string) (string, error) {
// Check store first (content-addressable)
if s.store != nil {
// Compute expected hash from package definition
// For now, use name-based lookup
cachePaths := []string{
filepath.Join(s.workDir, ".store", pkgName[:2], pkgName),
}
for _, path := range cachePaths {
if _, err := os.Stat(path); err == nil {
fmt.Printf("📦 %s found in content-addressable store\n", pkgName)
return path, nil
}
}
}
// Update thermal limits before build
if err := s.thermal.Update(); err == nil {
s.jobs = s.thermal.GetJobs()
if s.thermal.ShouldCooldown() {
fmt.Printf("🌡️ Thermal limit reached, using %d jobs with cooldown\n", s.jobs)
}
}
// Build with fallback
return s.buildPackageWithFallback(pkgName, false, false, []string{})
}
// InstallToFakeroot installs built package to fakeroot
func (s *LFSBuildSession) InstallToFakeroot(pkgPath, fakeroot string) error {
// Use our installer
i := s.toolInstaller
i = installer.NewInstaller(fakeroot)
return i.Install(pkgPath)
}
// VerifyFakeroot verifies fakeroot installation
func VerifyFakeroot(fakeroot string) error {
// Check essential directories exist
essentialDirs := []string{
filepath.Join(fakeroot, "usr", "bin"),
filepath.Join(fakeroot, "usr", "lib"),
filepath.Join(fakeroot, "var", "lib", "pkgdb"),
}
for _, dir := range essentialDirs {
if _, err := os.Stat(dir); os.IsNotExist(err) {
// Create if missing
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create %s: %w", dir, err)
}
}
}
return nil
}
// QuickInstall performs optimized install for LFS
func QuickInstall(pkgName, workDir, fakeroot string) error {
// Verify fakeroot
if err := VerifyFakeroot(fakeroot); err != nil {
return fmt.Errorf("fakeroot verification failed: %w", err)
}
// Create optimized session
session := NewLFSBuildSession(workDir, true, 2, 0)
// Build
pkgPath, err := session.BuildOptimized(pkgName)
if err != nil {
return fmt.Errorf("build failed: %w", err)
}
// Install to fakeroot
if err := session.InstallToFakeroot(pkgPath, fakeroot); err != nil {
return fmt.Errorf("install failed: %w", err)
}
fmt.Printf("✅ %s installed to %s\n", pkgName, fakeroot)
return nil
}

213
fakeroot_test.go Normal file
View file

@ -0,0 +1,213 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// FakerootTestRunner runs tests in fakeroot environment
type FakerootTestRunner struct {
workDir string
rootDir string
zsvoBin string
}
// NewFakerootTestRunner creates test runner
func NewFakerootTestRunner() (*FakerootTestRunner, error) {
workDir, err := os.MkdirTemp("", "zsvo-test-")
if err != nil {
return nil, err
}
rootDir := filepath.Join(workDir, "fakeroot")
if err := os.MkdirAll(rootDir, 0755); err != nil {
return nil, err
}
// Find zsvo binary
zsvoBin := "./zsvo"
if _, err := os.Stat(zsvoBin); err != nil {
// Try to build
cmd := exec.Command("go", "build", "-o", "zsvo", ".")
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("failed to build zsvo: %w", err)
}
}
return &FakerootTestRunner{
workDir: workDir,
rootDir: rootDir,
zsvoBin: zsvoBin,
}, nil
}
// Cleanup removes test directory
func (f *FakerootTestRunner) Cleanup() {
os.RemoveAll(f.workDir)
}
// RunInFakeroot executes command in fakeroot environment
func (f *FakerootTestRunner) RunInFakeroot(args ...string) (string, string, error) {
// Check if fakeroot is available
fakerootPath, err := exec.LookPath("fakeroot")
if err != nil {
// Run without fakeroot but with custom root
cmd := exec.Command(f.zsvoBin, args...)
cmd.Env = append(os.Environ(),
"FAKEROOT=true",
"ZSVO_ROOT="+f.rootDir,
)
output, err := cmd.CombinedOutput()
return string(output), "", err
}
// Run with fakeroot
cmdArgs := append([]string{f.zsvoBin}, args...)
cmd := exec.Command(fakerootPath, cmdArgs...)
cmd.Env = append(os.Environ(), "ZSVO_ROOT="+f.rootDir)
output, err := cmd.CombinedOutput()
return string(output), "", err
}
// TestInstallSimplePackage tests installing simple package
func TestInstallSimplePackage(t *testing.T) {
runner, err := NewFakerootTestRunner()
if err != nil {
t.Fatalf("Failed to create test runner: %v", err)
}
defer runner.Cleanup()
// Test installing expat (simple C library)
stdout, stderr, err := runner.RunInFakeroot(
"install", "expat",
"--work-dir", runner.workDir,
"--root", runner.rootDir,
"--jobs", "2",
"--cooldown", "0s",
)
t.Logf("stdout: %s", stdout)
t.Logf("stderr: %s", stderr)
if err != nil {
t.Fatalf("Install failed: %v\nOutput: %s", err, stdout)
}
// Verify installation
if !strings.Contains(stdout, "completed") && !strings.Contains(stdout, "successfully") {
t.Errorf("Installation may have failed, output: %s", stdout)
}
}
// TestCacheHit tests that cached packages are not rebuilt
func TestCacheHit(t *testing.T) {
runner, err := NewFakerootTestRunner()
if err != nil {
t.Fatalf("Failed to create test runner: %v", err)
}
defer runner.Cleanup()
// First install - should build
stdout1, _, _ := runner.RunInFakeroot(
"install", "zlib",
"--work-dir", runner.workDir,
"--root", runner.rootDir,
"--dry-run",
)
if !strings.Contains(stdout1, "Would auto-build") {
t.Skip("Dry-run mode doesn't show cache hit info")
}
}
// TestDependencyResolution tests dependency resolution
func TestDependencyResolution(t *testing.T) {
runner, err := NewFakerootTestRunner()
if err != nil {
t.Fatalf("Failed to create test runner: %v", err)
}
defer runner.Cleanup()
stdout, _, err := runner.RunInFakeroot(
"search", "libssl-dev",
)
if err != nil {
t.Logf("Search output: %s", stdout)
}
}
// TestFakerootIsolation tests that fakeroot provides proper isolation
func TestFakerootIsolation(t *testing.T) {
runner, err := NewFakerootTestRunner()
if err != nil {
t.Fatalf("Failed to create test runner: %v", err)
}
defer runner.Cleanup()
// Install a package
_, _, err = runner.RunInFakeroot(
"install", "expat",
"--work-dir", runner.workDir,
"--root", runner.rootDir,
"--jobs", "2",
"--cooldown", "0s",
)
if err != nil {
t.Skipf("Install failed, skipping isolation test: %v", err)
}
// Verify files are in fakeroot, not system
fakerootFile := filepath.Join(runner.rootDir, "usr", "lib", "libexpat.so")
systemFile := "/usr/lib/libexpat.so"
if _, err := os.Stat(fakerootFile); err == nil {
t.Logf("✓ Package installed in fakeroot: %s", fakerootFile)
} else {
t.Errorf("Package not found in fakeroot: %v", err)
}
// System should not have the file (or it's different)
if _, err := os.Stat(systemFile); err == nil {
t.Logf(" System already has %s (may be different version)", systemFile)
}
}
// BenchmarkInstall measures install performance
func BenchmarkInstall(b *testing.B) {
for i := 0; i < b.N; i++ {
runner, err := NewFakerootTestRunner()
if err != nil {
b.Fatalf("Failed to create test runner: %v", err)
}
// Use dry-run for speed
runner.RunInFakeroot(
"install", "zlib",
"--work-dir", runner.workDir,
"--root", runner.rootDir,
"--dry-run",
)
runner.Cleanup()
}
}
// TestMain runs all tests
func TestMain(m *testing.M) {
// Skip if not Linux
if os.Getenv("GOOS") != "" && os.Getenv("GOOS") != "linux" {
if _, err := exec.LookPath("go"); err != nil {
fmt.Println("Skipping tests: not Linux environment")
os.Exit(0)
}
}
os.Exit(m.Run())
}

View file

@ -4,15 +4,16 @@ import (
"log"
"os"
"github.com/spf13/cobra"
"zsvo/cmd"
"zsvo/pkg/i18n"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "zsvo",
Short: "A simple source-based package manager",
SilenceErrors: true,
SilenceErrors: false,
SilenceUsage: true,
Long: `A minimal package manager for custom Linux distributions based on LFS

322
pkg/builder/thermal.go Normal file
View file

@ -0,0 +1,322 @@
package builder
import (
"context"
"fmt"
"os/exec"
"runtime"
"sync"
"time"
)
// ThermalMonitor monitors CPU temperature and adjusts parallelism
type ThermalMonitor struct {
targetTemp float64 // target CPU temperature (Celsius)
currentTemp float64
maxJobs int
currentJobs int
cooldownTime time.Duration
mu sync.RWMutex
tempSensor TempSensor
}
// TempSensor reads CPU temperature
type TempSensor interface {
Read() (float64, error)
}
// NewThermalMonitor creates thermal monitor
func NewThermalMonitor(targetTemp float64, maxJobs int) *ThermalMonitor {
return &ThermalMonitor{
targetTemp: targetTemp,
maxJobs: maxJobs,
currentJobs: 1, // Start conservative
cooldownTime: 5 * time.Second,
tempSensor: &MacOSTempSensor{},
}
}
// GetJobs returns current safe job count
func (tm *ThermalMonitor) GetJobs() int {
tm.mu.RLock()
defer tm.mu.RUnlock()
return tm.currentJobs
}
// Update reads temperature and adjusts parallelism
func (tm *ThermalMonitor) Update() error {
temp, err := tm.tempSensor.Read()
if err != nil {
return err
}
tm.mu.Lock()
defer tm.mu.Unlock()
tm.currentTemp = temp
// Adjust job count based on temperature
if temp > tm.targetTemp+10 {
// Too hot - reduce jobs
if tm.currentJobs > 1 {
tm.currentJobs--
tm.cooldownTime = 10 * time.Second
}
} else if temp < tm.targetTemp-5 {
// Cool enough - increase jobs
if tm.currentJobs < tm.maxJobs {
tm.currentJobs++
tm.cooldownTime = 2 * time.Second
}
}
return nil
}
// ShouldCooldown returns true if we need to cool down
func (tm *ThermalMonitor) ShouldCooldown() bool {
tm.mu.RLock()
defer tm.mu.RUnlock()
return tm.currentTemp > tm.targetTemp
}
// Cooldown returns current cooldown duration
func (tm *ThermalMonitor) Cooldown() time.Duration {
tm.mu.RLock()
defer tm.mu.RUnlock()
return tm.cooldownTime
}
// MacOSTempSensor reads temperature on macOS
type MacOSTempSensor struct{}
func (m *MacOSTempSensor) Read() (float64, error) {
// Use powermetrics or thermal tools on macOS
// Fallback to simple load-based estimate
// Try to read from SMC (requires priviliges)
cmd := exec.Command("powermetrics", "-n", "1", "--samplers", "smc")
output, err := cmd.Output()
if err == nil {
// Parse temperature from output
// This is simplified - real implementation would parse SMC output
_ = output
return 70.0, nil // Default estimate
}
// Estimate based on load
load := runtime.NumCPU()
baseTemp := 45.0
return baseTemp + float64(load)*2.5, nil
}
// ParallelBuilder builds packages with thermal management
type ParallelBuilder struct {
monitor *ThermalMonitor
semaphore chan struct{}
jobs map[string]*BuildJob
mu sync.RWMutex
}
// BuildJob represents a build job
type BuildJob struct {
Name string
RecipePath string
Dependencies []string
Status BuildStatus
Result error
}
// BuildStatus represents job status
type BuildStatus int
const (
BuildPending BuildStatus = iota
BuildRunning
BuildDone
BuildFailed
)
// NewParallelBuilder creates builder with thermal management
func NewParallelBuilder(targetTemp float64, maxJobs int) *ParallelBuilder {
monitor := NewThermalMonitor(targetTemp, maxJobs)
return &ParallelBuilder{
monitor: monitor,
semaphore: make(chan struct{}, maxJobs),
jobs: make(map[string]*BuildJob),
}
}
// AddJob adds build job
func (pb *ParallelBuilder) AddJob(job *BuildJob) {
pb.mu.Lock()
defer pb.mu.Unlock()
pb.jobs[job.Name] = job
}
// BuildAll builds all jobs respecting dependencies and thermal limits
func (pb *ParallelBuilder) BuildAll(ctx context.Context) error {
for {
// Update thermal status
pb.monitor.Update()
// Get available job slots
slots := pb.monitor.GetJobs()
// Find ready jobs
ready := pb.getReadyJobs()
if len(ready) == 0 && pb.allDone() {
break
}
// Launch jobs within thermal limits
for i := 0; i < min(len(ready), slots); i++ {
job := ready[i]
go pb.buildJob(ctx, job)
}
// Cool down if needed
if pb.monitor.ShouldCooldown() {
time.Sleep(pb.monitor.Cooldown())
} else {
time.Sleep(100 * time.Millisecond)
}
}
return nil
}
func (pb *ParallelBuilder) getReadyJobs() []*BuildJob {
pb.mu.RLock()
defer pb.mu.RUnlock()
var ready []*BuildJob
for _, job := range pb.jobs {
if job.Status != BuildPending {
continue
}
// Check if dependencies are done
depsDone := true
for _, dep := range job.Dependencies {
if depJob, ok := pb.jobs[dep]; ok {
if depJob.Status != BuildDone {
depsDone = false
break
}
}
}
if depsDone {
ready = append(ready, job)
}
}
return ready
}
func (pb *ParallelBuilder) allDone() bool {
pb.mu.RLock()
defer pb.mu.RUnlock()
for _, job := range pb.jobs {
if job.Status == BuildPending || job.Status == BuildRunning {
return false
}
}
return true
}
func (pb *ParallelBuilder) buildJob(ctx context.Context, job *BuildJob) {
pb.mu.Lock()
job.Status = BuildRunning
pb.mu.Unlock()
// Acquire semaphore slot
pb.semaphore <- struct{}{}
defer func() { <-pb.semaphore }()
// Build
// TODO: actual build
pb.mu.Lock()
job.Status = BuildDone
pb.mu.Unlock()
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// AdaptiveParallelBuilder is the main interface for parallel thermal-aware builds
type AdaptiveParallelBuilder struct {
targetTemp float64
maxJobs int
workDir string
}
// NewAdaptiveParallelBuilder creates adaptive builder
func NewAdaptiveParallelBuilder(workDir string, targetTemp float64, maxJobs int) *AdaptiveParallelBuilder {
return &AdaptiveParallelBuilder{
targetTemp: targetTemp,
maxJobs: maxJobs,
workDir: workDir,
}
}
// Build executes parallel thermal-aware build
func (apb *AdaptiveParallelBuilder) Build(packages []string) error {
monitor := NewThermalMonitor(apb.targetTemp, apb.maxJobs)
fmt.Printf("🌡️ Thermal target: %.1f°C, Max jobs: %d\n", apb.targetTemp, apb.maxJobs)
for i, pkg := range packages {
// Update thermal status
monitor.Update()
// Build with current job limit
jobs := monitor.GetJobs()
fmt.Printf("📦 [%d/%d] Building %s (jobs=%d, temp=%.1f°C)\n",
i+1, len(packages), pkg, jobs, monitor.currentTemp)
// Simulate build (replace with actual)
time.Sleep(2 * time.Second)
// Cool down if needed
if monitor.ShouldCooldown() {
fmt.Printf(" ⏱️ Cooling down for %v...\n", monitor.Cooldown())
time.Sleep(monitor.Cooldown())
}
}
return nil
}
// GetRecommendedJobs returns recommended job count for system
func GetRecommendedJobs() int {
cpus := runtime.NumCPU()
// Conservative for thermals
if cpus <= 4 {
return 1
} else if cpus <= 8 {
return 2
}
return cpus / 4
}
// GetRecommendedTargetTemp returns recommended temperature limit
func GetRecommendedTargetTemp() float64 {
switch runtime.GOOS {
case "darwin":
return 75.0 // Macs run hot
case "linux":
return 80.0 // Linux typically has better cooling
default:
return 75.0
}
}

221
pkg/cache/store.go vendored Normal file
View file

@ -0,0 +1,221 @@
package cache
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
)
// Store implements content-addressable storage like Nix
type Store struct {
basePath string
mu sync.RWMutex
remote RemoteCache
}
// RemoteCache interface for binary substitution
type RemoteCache interface {
Get(narHash string) (string, error) // Download to local path
Put(localPath, narHash string) error
Has(narHash string) bool
}
// NewStore creates content-addressable store
func NewStore(basePath string) (*Store, error) {
if err := os.MkdirAll(basePath, 0755); err != nil {
return nil, err
}
// Create subdirectories like Nix: /nix/store/xx/xxxxx...
for i := 0; i < 256; i++ {
dir := filepath.Join(basePath, fmt.Sprintf("%02x", i))
os.MkdirAll(dir, 0755)
}
return &Store{basePath: basePath}, nil
}
// ComputeHash computes content hash like Nix narHash
func (s *Store) ComputeHash(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
h := sha256.New()
if _, err := io.Copy(h, file); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// StorePath returns store path for content hash
func (s *Store) StorePath(narHash string) string {
// Nix-style: /store/xx/xxxxxxxxxxxx...
prefix := narHash[:2]
return filepath.Join(s.basePath, prefix, narHash)
}
// Add adds file to store, returns store path
func (s *Store) Add(srcPath string) (string, error) {
hash, err := s.ComputeHash(srcPath)
if err != nil {
return "", err
}
dstPath := s.StorePath(hash)
// Atomic add: create temp, rename
tempPath := dstPath + ".tmp"
src, err := os.Open(srcPath)
if err != nil {
return "", err
}
defer src.Close()
dst, err := os.Create(tempPath)
if err != nil {
return "", err
}
if _, err := io.Copy(dst, src); err != nil {
dst.Close()
os.Remove(tempPath)
return "", err
}
dst.Close()
// Atomic rename
if err := os.Rename(tempPath, dstPath); err != nil {
os.Remove(tempPath)
return "", err
}
return dstPath, nil
}
// Has checks if content exists in store
func (s *Store) Has(narHash string) bool {
path := s.StorePath(narHash)
_, err := os.Stat(path)
return err == nil
}
// Get retrieves file from store or remote cache
func (s *Store) Get(narHash string) (string, error) {
localPath := s.StorePath(narHash)
// Check local store
if _, err := os.Stat(localPath); err == nil {
return localPath, nil
}
// Try remote cache
if s.remote != nil && s.remote.Has(narHash) {
if _, err := s.remote.Get(narHash); err == nil {
return localPath, nil
}
}
return "", fmt.Errorf("content not found: %s", narHash)
}
// BinaryCache implements remote binary cache
type BinaryCache struct {
urls []string
keys []string // signing keys
}
// NewBinaryCache creates binary cache client
func NewBinaryCache(urls []string) *BinaryCache {
return &BinaryCache{urls: urls}
}
// Query queries remote cache for package availability
func (b *BinaryCache) Query(pkgName, version, platform string) (string, bool) {
// Check all configured caches
for _, url := range b.urls {
narPath := fmt.Sprintf("%s/%s-%s-%s.nar.zst", url, pkgName, version, platform)
// HEAD request to check existence
if exists(narPath) {
return narPath, true
}
}
return "", false
}
func exists(url string) bool {
// Simplified - would do actual HTTP HEAD
return false
}
// Download downloads and verifies package
func (b *BinaryCache) Download(narUrl, dstPath string) error {
// Download with progress
// Verify signature
// Extract to store
return nil
}
// Upload uploads to cache (for CI/build farm)
func (b *BinaryCache) Upload(srcPath, narUrl string) error {
return nil
}
// SubstitutionPlan plans binary substitution like Nix
type SubstitutionPlan struct {
store *Store
cache *BinaryCache
needed []string // hashes needed
available map[string]string // hash -> store path
}
// NewSubstitutionPlan creates substitution plan
func NewSubstitutionPlan(store *Store, cache *BinaryCache) *SubstitutionPlan {
return &SubstitutionPlan{
store: store,
cache: cache,
available: make(map[string]string),
}
}
// AddDependency adds dependency to plan
func (p *SubstitutionPlan) AddDependency(pkgName, version, platform string) {
if path, ok := p.cache.Query(pkgName, version, platform); ok {
hash := extractHashFromPath(path)
p.available[hash] = path
}
}
// CanSubstitute checks if we can avoid building
func (p *SubstitutionPlan) CanSubstitute() bool {
return len(p.needed) == len(p.available)
}
// Execute executes substitution plan
func (p *SubstitutionPlan) Execute() error {
for hash, remotePath := range p.available {
localPath := p.store.StorePath(hash)
if err := p.cache.Download(remotePath, localPath); err != nil {
return err
}
}
return nil
}
func extractHashFromPath(path string) string {
parts := strings.Split(path, "/")
if len(parts) > 0 {
return parts[len(parts)-1]
}
return ""
}

View file

@ -28,7 +28,9 @@ type DependencyResolver struct {
type dependencyCache struct {
mu sync.RWMutex
deps map[string]*PackageDeps
notFound map[string]bool // cache of packages that were not found
srcMap map[string]string // binary pkg name -> source pkg name
inFlight map[string]*sync.WaitGroup // track in-flight requests
}
// PackageDeps represents Debian package dependencies
@ -50,7 +52,9 @@ func NewDependencyResolver() *DependencyResolver {
resolver: NewResolver(),
cache: &dependencyCache{
deps: make(map[string]*PackageDeps),
notFound: make(map[string]bool),
srcMap: make(map[string]string),
inFlight: make(map[string]*sync.WaitGroup),
},
httpClient: &http.Client{
Timeout: 30 * time.Second,
@ -68,6 +72,11 @@ func NewDependencyResolver() *DependencyResolver {
// ResolveDependencies resolves all dependencies for a Debian binary package
func (r *DependencyResolver) ResolveDependencies(pkgName string) (*PackageDeps, error) {
// Check "not found" cache first to avoid repeated lookups
if r.cache.getNotFound(pkgName) {
return nil, fmt.Errorf("package %s not found (cached)", pkgName)
}
// Check cache first
if cached := r.cache.get(pkgName); cached != nil {
return cached, nil
@ -76,6 +85,8 @@ func (r *DependencyResolver) ResolveDependencies(pkgName string) (*PackageDeps,
// Try to find in Sources files (for build-deps) or Packages files (for runtime deps)
deps, err := r.resolveFromDebian(pkgName)
if err != nil {
// Cache the failure to avoid repeated lookups
r.cache.setNotFound(pkgName)
return nil, err
}
@ -87,7 +98,12 @@ func (r *DependencyResolver) ResolveDependencies(pkgName string) (*PackageDeps,
// BinaryToSource maps a Debian binary package name to its source package name
func (r *DependencyResolver) BinaryToSource(binaryPkg string) (string, error) {
// Check cache
// Check "not found" cache first
if r.cache.getNotFound(binaryPkg) {
return "", fmt.Errorf("package %s not found (cached)", binaryPkg)
}
// Check srcMap cache
if src := r.cache.getSource(binaryPkg); src != "" {
return src, nil
}
@ -98,13 +114,37 @@ func (r *DependencyResolver) BinaryToSource(binaryPkg string) (string, error) {
return src, nil
}
// Check if request is already in flight
wg, inFlight := r.cache.startInFlight(binaryPkg)
if inFlight {
// Wait for the in-flight request to complete
wg.Wait()
// Check cache again after waiting
if r.cache.getNotFound(binaryPkg) {
return "", fmt.Errorf("package %s not found (cached after wait)", binaryPkg)
}
if src := r.cache.getSource(binaryPkg); src != "" {
return src, nil
}
return "", fmt.Errorf("package %s not found after wait", binaryPkg)
}
// We are the first request for this package
defer func() {
// finishInFlight will be called after we complete the lookup
}()
// Look up in Debian Sources
srcInfo, err := r.resolver.ResolveSource(binaryPkg)
if err != nil {
// Cache the failure and mark in-flight as complete
r.cache.finishInFlight(binaryPkg, true)
return "", fmt.Errorf("cannot resolve source for %s: %w", binaryPkg, err)
}
// Cache success and mark in-flight as complete
r.cache.setSource(binaryPkg, srcInfo.SourcePackage)
r.cache.finishInFlight(binaryPkg, false)
return srcInfo.SourcePackage, nil
}
@ -299,6 +339,54 @@ func (c *dependencyCache) get(pkg string) *PackageDeps {
return c.deps[pkg]
}
func (c *dependencyCache) getNotFound(pkg string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.notFound[pkg]
}
func (c *dependencyCache) setNotFound(pkg string) {
c.mu.Lock()
defer c.mu.Unlock()
c.notFound[pkg] = true
}
// startInFlight starts tracking an in-flight request, returns wait group if already in flight
func (c *dependencyCache) startInFlight(pkg string) (*sync.WaitGroup, bool) {
c.mu.Lock()
defer c.mu.Unlock()
// Check if already cached as not found
if c.notFound[pkg] {
return nil, false
}
// Check if already in flight
if wg, exists := c.inFlight[pkg]; exists {
return wg, true
}
// Create new wait group for this request
wg := &sync.WaitGroup{}
wg.Add(1)
c.inFlight[pkg] = wg
return wg, false
}
// finishInFlight marks in-flight request as complete
func (c *dependencyCache) finishInFlight(pkg string, notFound bool) {
c.mu.Lock()
defer c.mu.Unlock()
if wg, exists := c.inFlight[pkg]; exists {
delete(c.inFlight, pkg)
if notFound {
c.notFound[pkg] = true
}
wg.Done()
}
}
func (c *dependencyCache) set(pkg string, deps *PackageDeps) {
c.mu.Lock()
defer c.mu.Unlock()

244
pkg/fetcher/parallel.go Normal file
View file

@ -0,0 +1,244 @@
package fetcher
import (
"context"
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
)
// ParallelDownloader downloads multiple files concurrently with dependency ordering
type ParallelDownloader struct {
client *http.Client
maxParallel int
progress ProgressCallback
}
// ProgressCallback reports download progress
type ProgressCallback func(name string, downloaded, total int64)
// DownloadTask represents a download task with dependencies
type DownloadTask struct {
Name string
URL string
DstPath string
SHA256 string
Dependencies []string // names of tasks that must complete first
Size int64 // expected size for progress
}
// NewParallelDownloader creates downloader
func NewParallelDownloader(maxParallel int) *ParallelDownloader {
return &ParallelDownloader{
client: &http.Client{
Timeout: 5 * time.Minute,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
},
maxParallel: maxParallel,
}
}
// SetProgressCallback sets progress callback
func (pd *ParallelDownloader) SetProgressCallback(cb ProgressCallback) {
pd.progress = cb
}
// DownloadAll downloads all tasks respecting dependencies
func (pd *ParallelDownloader) DownloadAll(ctx context.Context, tasks []DownloadTask) error {
// Build dependency graph
graph := newDownloadGraph(tasks)
// Execute in waves (topological levels)
for {
level := graph.NextLevel()
if len(level) == 0 {
break
}
// Download this level in parallel
if err := pd.downloadLevel(ctx, level); err != nil {
return err
}
graph.MarkDone(level)
}
return nil
}
// downloadLevel downloads one level concurrently
func (pd *ParallelDownloader) downloadLevel(ctx context.Context, tasks []*DownloadTask) error {
var wg sync.WaitGroup
errChan := make(chan error, len(tasks))
semaphore := make(chan struct{}, pd.maxParallel)
for _, task := range tasks {
wg.Add(1)
go func(t *DownloadTask) {
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
if err := pd.downloadOne(ctx, t); err != nil {
errChan <- fmt.Errorf("%s: %w", t.Name, err)
}
}(task)
}
wg.Wait()
close(errChan)
// Check for errors
for err := range errChan {
return err
}
return nil
}
// downloadOne downloads single file with resume support
func (pd *ParallelDownloader) downloadOne(ctx context.Context, task *DownloadTask) error {
// Check if already downloaded
if _, err := os.Stat(task.DstPath); err == nil {
// Verify hash
if task.SHA256 != "" {
// TODO: verify hash
}
return nil
}
// Create temp file
tempPath := task.DstPath + ".download"
// Open file for writing (append if resuming)
file, err := os.OpenFile(tempPath, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer file.Close()
// Get current size for resume
stat, _ := file.Stat()
currentSize := stat.Size()
// Create request with resume header
req, err := http.NewRequestWithContext(ctx, "GET", task.URL, nil)
if err != nil {
return err
}
if currentSize > 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", currentSize))
}
// Execute request
resp, err := pd.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
// Copy with progress
var downloaded int64 = currentSize
reader := &progressReader{
r: resp.Body,
total: task.Size,
current: &downloaded,
callback: pd.progress,
name: task.Name,
}
if _, err := io.Copy(file, reader); err != nil {
return err
}
file.Close()
// Atomic rename
return os.Rename(tempPath, task.DstPath)
}
// progressReader wraps reader for progress tracking
type progressReader struct {
r io.Reader
total int64
current *int64
callback ProgressCallback
name string
}
func (pr *progressReader) Read(p []byte) (n int, err error) {
n, err = pr.r.Read(p)
*pr.current += int64(n)
if pr.callback != nil {
pr.callback(pr.name, *pr.current, pr.total)
}
return n, err
}
// downloadGraph tracks download dependencies
type downloadGraph struct {
tasks map[string]*DownloadTask
remaining map[string]bool
depsLeft map[string]int
}
func newDownloadGraph(tasks []DownloadTask) *downloadGraph {
g := &downloadGraph{
tasks: make(map[string]*DownloadTask),
remaining: make(map[string]bool),
depsLeft: make(map[string]int),
}
for i := range tasks {
t := &tasks[i]
g.tasks[t.Name] = t
g.remaining[t.Name] = true
g.depsLeft[t.Name] = len(t.Dependencies)
}
return g
}
func (g *downloadGraph) NextLevel() []*DownloadTask {
var ready []*DownloadTask
for name, remaining := range g.remaining {
if !remaining {
continue
}
if g.depsLeft[name] == 0 {
ready = append(ready, g.tasks[name])
}
}
return ready
}
func (g *downloadGraph) MarkDone(tasks []*DownloadTask) {
for _, t := range tasks {
delete(g.remaining, t.Name)
// Update dependents
for name, task := range g.tasks {
for _, dep := range task.Dependencies {
if dep == t.Name {
g.depsLeft[name]--
}
}
}
}
}

View file

@ -0,0 +1,227 @@
package sandbox
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
)
// Sandbox provides isolated Linux build environment using chroot
type Sandbox struct {
rootDir string
workDir string
bindMounts []BindMount
environment map[string]string
}
// BindMount represents a bind mount
type BindMount struct {
Source string
Target string
ReadOnly bool
}
// NewSandbox creates Linux chroot sandbox
func NewSandbox(rootDir string) (*Sandbox, error) {
if err := os.MkdirAll(rootDir, 0755); err != nil {
return nil, err
}
return &Sandbox{
rootDir: rootDir,
workDir: rootDir,
bindMounts: []BindMount{},
environment: make(map[string]string),
}, nil
}
// AddBindMount adds bind mount to sandbox
func (s *Sandbox) AddBindMount(source, target string, readOnly bool) {
s.bindMounts = append(s.bindMounts, BindMount{
Source: source,
Target: target,
ReadOnly: readOnly,
})
}
// SetEnv sets environment variable in sandbox
func (s *Sandbox) SetEnv(key, value string) {
s.environment[key] = value
}
// Setup prepares sandbox environment
func (s *Sandbox) Setup() error {
// Create essential directories
dirs := []string{"bin", "lib", "lib64", "usr", "tmp", "dev", "proc", "sys"}
for _, dir := range dirs {
if err := os.MkdirAll(filepath.Join(s.rootDir, dir), 0755); err != nil {
return fmt.Errorf("failed to create %s: %w", dir, err)
}
}
// Create essential device nodes
if err := s.createDevices(); err != nil {
return fmt.Errorf("failed to create devices: %w", err)
}
// Perform bind mounts
for _, mount := range s.bindMounts {
target := filepath.Join(s.rootDir, mount.Target)
if err := os.MkdirAll(target, 0755); err != nil {
return err
}
// Bind mount
flags := syscall.MS_BIND
if mount.ReadOnly {
flags |= syscall.MS_RDONLY
}
if err := syscall.Mount(mount.Source, target, "", uintptr(flags), ""); err != nil {
return fmt.Errorf("failed to mount %s: %w", mount.Source, err)
}
}
return nil
}
// createDevices creates essential device nodes
func (s *Sandbox) createDevices() error {
devDir := filepath.Join(s.rootDir, "dev")
// Create null device
if err := syscall.Mknod(filepath.Join(devDir, "null"), syscall.S_IFCHR|0666, int(1<<8)|3); err != nil {
return err
}
// Create zero device
if err := syscall.Mknod(filepath.Join(devDir, "zero"), syscall.S_IFCHR|0666, int(1<<8)|5); err != nil {
return err
}
// Create random device
if err := syscall.Mknod(filepath.Join(devDir, "random"), syscall.S_IFCHR|0666, int(1<<8)|8); err != nil {
return err
}
// Create urandom device
if err := syscall.Mknod(filepath.Join(devDir, "urandom"), syscall.S_IFCHR|0666, int(1<<8)|9); err != nil {
return err
}
return nil
}
// Execute runs command in chroot sandbox
func (s *Sandbox) Execute(command string, args ...string) error {
cmd := exec.Command(command, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Dir = "/"
cmd.Env = s.buildEnv()
// Use chroot
cmd.SysProcAttr = &syscall.SysProcAttr{
Chroot: s.rootDir,
}
return cmd.Run()
}
// ExecuteAsUser runs command as specific user in sandbox
func (s *Sandbox) ExecuteAsUser(uid, gid int, command string, args ...string) error {
cmd := exec.Command(command, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Dir = "/"
cmd.Env = s.buildEnv()
cmd.SysProcAttr = &syscall.SysProcAttr{
Chroot: s.rootDir,
Credential: &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)},
}
return cmd.Run()
}
// buildEnv builds environment for sandbox
func (s *Sandbox) buildEnv() []string {
base := []string{
"PATH=/usr/bin:/bin:/usr/sbin:/sbin",
"HOME=/root",
"TMPDIR=/tmp",
"TERM=xterm",
}
for key, value := range s.environment {
base = append(base, fmt.Sprintf("%s=%s", key, value))
}
return base
}
// Cleanup removes sandbox resources
func (s *Sandbox) Cleanup() error {
// Unmount all bind mounts (in reverse order)
for i := len(s.bindMounts) - 1; i >= 0; i-- {
mount := s.bindMounts[i]
target := filepath.Join(s.rootDir, mount.Target)
syscall.Unmount(target, 0)
}
return os.RemoveAll(s.rootDir)
}
// BuilderSandbox wraps builder with Linux sandbox
type BuilderSandbox struct {
sandbox *Sandbox
workDir string
}
// NewBuilderSandbox creates sandboxed builder for Linux
func NewBuilderSandbox(workDir string) (*BuilderSandbox, error) {
sandboxDir := filepath.Join(workDir, ".sandbox")
sb, err := NewSandbox(sandboxDir)
if err != nil {
return nil, err
}
// Bind mount essential system directories
sb.AddBindMount("/bin", "bin", true)
sb.AddBindMount("/lib", "lib", true)
sb.AddBindMount("/lib64", "lib64", true)
sb.AddBindMount("/usr", "usr", true)
sb.AddBindMount("/dev", "dev", false)
// Bind mount work directory for writing
sb.AddBindMount(workDir, "work", false)
return &BuilderSandbox{
sandbox: sb,
workDir: workDir,
}, nil
}
// Build executes build in sandbox
func (bs *BuilderSandbox) Build(recipePath string) error {
// Set build environment
bs.sandbox.SetEnv("HOME", "/root")
bs.sandbox.SetEnv("TMPDIR", "/tmp")
bs.sandbox.SetEnv("SOURCE_DATE_EPOCH", "0")
// Setup sandbox
if err := bs.sandbox.Setup(); err != nil {
return fmt.Errorf("failed to setup sandbox: %w", err)
}
// Execute build in sandbox
return bs.sandbox.Execute("/usr/bin/zsvo", "build", "/work/recipe.yaml")
}
// Cleanup cleans up sandbox
func (bs *BuilderSandbox) Cleanup() error {
return bs.sandbox.Cleanup()
}

379
pkg/ui/pacman.go Normal file
View file

@ -0,0 +1,379 @@
package ui
import (
"fmt"
"os"
"strings"
"time"
)
// Colors for terminal output
const (
Reset = "\033[0m"
Red = "\033[31m"
Green = "\033[32m"
Yellow = "\033[33m"
Blue = "\033[34m"
Magenta = "\033[35m"
Cyan = "\033[36m"
White = "\033[37m"
Bold = "\033[1m"
)
// PacmanUI provides pacman-style interface
type PacmanUI struct {
quiet bool
noColor bool
termWidth int
}
// NewPacmanUI creates new UI instance
func NewPacmanUI(quiet bool) *PacmanUI {
return &PacmanUI{
quiet: quiet,
termWidth: getTerminalWidth(),
}
}
// DisableColor disables colored output
func (p *PacmanUI) DisableColor() {
p.noColor = true
}
// color returns color code or empty string if disabled
func (p *PacmanUI) color(c string) string {
if p.noColor || p.quiet {
return ""
}
return c
}
// PrintOperation prints operation header like pacman
func (p *PacmanUI) PrintOperation(op, target string) {
if p.quiet {
return
}
opColor := p.color(Cyan + Bold)
targetColor := p.color(Reset)
switch op {
case "resolving":
opColor = p.color(Yellow + Bold)
case "downloading":
opColor = p.color(Cyan + Bold)
case "building":
opColor = p.color(Yellow + Bold)
case "installing":
opColor = p.color(Green + Bold)
case "removing":
opColor = p.color(Red + Bold)
}
fmt.Printf("%s%s%s %s%s%s\n",
opColor, op, p.color(Reset),
targetColor, target, p.color(Reset))
}
// PrintProgress prints pacman-style progress line
func (p *PacmanUI) PrintProgress(current, total int, pkgName, action string) {
if p.quiet {
return
}
percent := float64(current) / float64(total) * 100
// Build progress bar
barWidth := 30
filled := int(float64(barWidth) * percent / 100)
empty := barWidth - filled
bar := strings.Repeat("#", filled) + strings.Repeat("-", empty)
color := p.color(Green)
if percent < 30 {
color = p.color(Red)
} else if percent < 70 {
color = p.color(Yellow)
}
reset := p.color(Reset)
// Clear line and print
fmt.Printf("\r\033[K[%s%s%s] %s%d/%d%s (%s%.0f%%s) %s %s",
color, bar, reset,
p.color(White+Bold), current, total, reset,
p.color(Cyan), percent, reset,
pkgName, action)
if current == total {
fmt.Println() // New line on completion
}
}
// PrintDownloadProgress prints download progress with speed and ETA
func (p *PacmanUI) PrintDownloadProgress(pkgName string, downloaded, total int64, speed float64) {
if p.quiet {
return
}
percent := float64(downloaded) / float64(total) * 100
// Format sizes
downloadedStr := formatSize(downloaded)
totalStr := formatSize(total)
speedStr := formatSpeed(speed)
// Calculate ETA
eta := ""
if speed > 0 {
remaining := float64(total-downloaded) / speed
eta = formatDuration(time.Duration(remaining) * time.Second)
}
barWidth := 25
filled := int(float64(barWidth) * percent / 100)
empty := barWidth - filled
bar := strings.Repeat("#", filled) + strings.Repeat("-", empty)
color := p.color(Cyan)
reset := p.color(Reset)
fmt.Printf("\r\033[K %s %s[%s%s%s] %s%s/%s%s %s%s/s%s ETA: %s",
pkgName,
color, bar, reset,
p.color(White), downloadedStr, totalStr, reset,
p.color(Yellow), speedStr, reset,
eta)
if downloaded >= total {
fmt.Println()
}
}
// PrintSuccess prints success message
func (p *PacmanUI) PrintSuccess(msg string) {
if p.quiet {
return
}
fmt.Printf("%s✓%s %s\n", p.color(Green+Bold), p.color(Reset), msg)
}
// PrintError prints error message
func (p *PacmanUI) PrintError(msg string) {
fmt.Fprintf(os.Stderr, "%s✗%s %s\n", p.color(Red+Bold), p.color(Reset), msg)
}
// PrintWarning prints warning message
func (p *PacmanUI) PrintWarning(msg string) {
if p.quiet {
return
}
fmt.Printf("%s⚠%s %s\n", p.color(Yellow+Bold), p.color(Reset), msg)
}
// PrintInfo prints info message
func (p *PacmanUI) PrintInfo(msg string) {
if p.quiet {
return
}
fmt.Printf("%s%s %s\n", p.color(Blue), p.color(Reset), msg)
}
// PrintPackageList prints package list like pacman -Q
func (p *PacmanUI) PrintPackageList(packages []PackageInfo) {
if p.quiet {
return
}
maxNameLen := 0
for _, pkg := range packages {
if len(pkg.Name) > maxNameLen {
maxNameLen = len(pkg.Name)
}
}
for _, pkg := range packages {
fmt.Printf("%s%s%s %s%s%s\n",
p.color(Green+Bold), padRight(pkg.Name, maxNameLen+2), p.color(Reset),
p.color(Cyan), pkg.Version, p.color(Reset))
}
}
// PrintTransactionSummary prints transaction summary
func (p *PacmanUI) PrintTransactionSummary(toInstall, toRemove, toUpgrade []string) {
if p.quiet {
return
}
fmt.Println()
fmt.Printf("%s%sTransaction Summary:%s\n", p.color(Bold), p.color(White), p.color(Reset))
if len(toInstall) > 0 {
fmt.Printf("%sInstall:%s %d packages\n", p.color(Green), p.color(Reset), len(toInstall))
for _, pkg := range toInstall {
fmt.Printf(" %s+%s %s\n", p.color(Green), p.color(Reset), pkg)
}
}
if len(toRemove) > 0 {
fmt.Printf("%sRemove:%s %d packages\n", p.color(Red), p.color(Reset), len(toRemove))
for _, pkg := range toRemove {
fmt.Printf(" %s-%s %s\n", p.color(Red), p.color(Reset), pkg)
}
}
if len(toUpgrade) > 0 {
fmt.Printf("%sUpgrade:%s %d packages\n", p.color(Yellow), p.color(Reset), len(toUpgrade))
for _, pkg := range toUpgrade {
fmt.Printf(" %s*%s %s\n", p.color(Yellow), p.color(Reset), pkg)
}
}
fmt.Println()
}
// MultiProgress tracks multiple parallel operations
type MultiProgress struct {
ui *PacmanUI
items []ProgressItem
mu chan struct{} // semaphore for thread-safe updates
}
// ProgressItem represents single progress item
type ProgressItem struct {
Name string
Progress float64
Status string
Speed string
Completed bool
}
// NewMultiProgress creates multi-item progress tracker
func (p *PacmanUI) NewMultiProgress() *MultiProgress {
return &MultiProgress{
ui: p,
mu: make(chan struct{}, 1),
items: []ProgressItem{},
}
}
// AddItem adds progress item
func (mp *MultiProgress) AddItem(name string) int {
mp.mu <- struct{}{}
defer func() { <-mp.mu }()
idx := len(mp.items)
mp.items = append(mp.items, ProgressItem{Name: name})
return idx
}
// Update updates progress item
func (mp *MultiProgress) Update(idx int, progress float64, status, speed string) {
if idx < 0 || idx >= len(mp.items) {
return
}
mp.mu <- struct{}{}
defer func() { <-mp.mu }()
mp.items[idx].Progress = progress
mp.items[idx].Status = status
mp.items[idx].Speed = speed
mp.items[idx].Completed = progress >= 100
mp.redraw()
}
// redraw refreshes display
func (mp *MultiProgress) redraw() {
if mp.ui.quiet {
return
}
// Clear previous lines
for i := 0; i < len(mp.items)+2; i++ {
fmt.Printf("\033[A\033[K")
}
// Print header
fmt.Printf("%s%sProgress:%s\n", mp.ui.color(Bold), mp.ui.color(White), mp.ui.color(Reset))
// Print items
for _, item := range mp.items {
bar := mp.renderBar(item.Progress, 20)
statusColor := mp.ui.color(Yellow)
if item.Completed {
statusColor = mp.ui.color(Green)
} else if item.Progress < 20 {
statusColor = mp.ui.color(Red)
}
speedStr := ""
if item.Speed != "" {
speedStr = fmt.Sprintf(" %s%s%s", mp.ui.color(Cyan), item.Speed, mp.ui.color(Reset))
}
fmt.Printf(" %s %s%s%s%s\n",
bar,
padRight(item.Name, 25),
statusColor, item.Status, mp.ui.color(Reset),
speedStr)
}
}
// renderBar renders progress bar
func (mp *MultiProgress) renderBar(percent float64, width int) string {
filled := int(float64(width) * percent / 100)
empty := width - filled
bar := strings.Repeat("█", filled) + strings.Repeat("░", empty)
color := mp.ui.color(Green)
if percent < 30 {
color = mp.ui.color(Red)
} else if percent < 70 {
color = mp.ui.color(Yellow)
}
return fmt.Sprintf("%s[%s]%s %3.0f%%", color, bar, mp.ui.color(Reset), percent)
}
// PackageInfo for list display
type PackageInfo struct {
Name string
Version string
Desc string
}
// Helper functions
func formatSize(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
func formatSpeed(bytesPerSec float64) string {
return formatSize(int64(bytesPerSec))
}
func padRight(s string, length int) string {
if len(s) >= length {
return s
}
return s + strings.Repeat(" ", length-len(s))
}
func getTerminalWidth() int {
// Default to 80 if can't determine
return 80
}