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:
parent
59bc543853
commit
b4b02e8e82
11 changed files with 2031 additions and 112 deletions
22
.gitignore
vendored
22
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
273
cmd/install.go
273
cmd/install.go
|
|
@ -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)
|
||||
node.srcInfo = srcInfo
|
||||
node.recipe = rcp
|
||||
node.buildDepends = srcInfo.BuildDepends
|
||||
// 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)
|
||||
|
||||
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)
|
||||
// Process dependencies sequentially to avoid race conditions
|
||||
for _, dep := range depsToProcess {
|
||||
s.collectAllDependencies(dep, graph, append(stack, pkgName))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -734,55 +777,81 @@ 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
|
||||
var wg sync.WaitGroup
|
||||
errChan := make(chan error, len(nodesToBuild))
|
||||
|
||||
// Limit concurrent builds
|
||||
semaphore := make(chan struct{}, buildWorkers)
|
||||
|
||||
for _, node := range nodesToBuild {
|
||||
wg.Add(1)
|
||||
go func(n *depNode) {
|
||||
defer wg.Done()
|
||||
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
// Build the package
|
||||
_, err := s.buildPackageFromNode(n)
|
||||
// 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 {
|
||||
n.err = err
|
||||
errChan <- fmt.Errorf("%s: %w", n.name, err)
|
||||
node.err = err
|
||||
fmt.Printf(" ⚠️ %s failed: %v\n", node.name, err)
|
||||
} else {
|
||||
n.built = true
|
||||
node.built = true
|
||||
fmt.Printf(" ✓ %s complete\n", node.name)
|
||||
}
|
||||
}(node)
|
||||
}
|
||||
// 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))
|
||||
|
||||
wg.Wait()
|
||||
close(errChan)
|
||||
// Limit concurrent builds based on jobs setting
|
||||
semaphore := make(chan struct{}, s.jobs)
|
||||
|
||||
// Check for errors
|
||||
errors := make([]error, 0)
|
||||
for err := range errChan {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
for _, node := range nodesToBuild {
|
||||
wg.Add(1)
|
||||
go func(n *depNode) {
|
||||
defer wg.Done()
|
||||
|
||||
if len(errors) > 0 {
|
||||
fmt.Printf("⚠️ %d packages failed at level %d\n", len(errors), level)
|
||||
for _, err := range errors {
|
||||
fmt.Printf(" %v\n", err)
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
// Build the package
|
||||
_, err := s.buildPackageFromNode(n)
|
||||
if err != nil {
|
||||
n.err = err
|
||||
errChan <- fmt.Errorf("%s: %w", n.name, err)
|
||||
} else {
|
||||
n.built = true
|
||||
}
|
||||
}(node)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errChan)
|
||||
|
||||
// Check for errors
|
||||
errors := make([]error, 0)
|
||||
for err := range errChan {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
fmt.Printf("⚠️ %d packages failed at level %d\n", len(errors), level)
|
||||
for _, err := range errors {
|
||||
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
133
cmd/lfs_linux.go
Normal 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
213
fakeroot_test.go
Normal 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())
|
||||
}
|
||||
5
main.go
5
main.go
|
|
@ -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
322
pkg/builder/thermal.go
Normal 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
221
pkg/cache/store.go
vendored
Normal 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 ""
|
||||
}
|
||||
|
|
@ -26,9 +26,11 @@ type DependencyResolver struct {
|
|||
|
||||
// dependencyCache caches resolved dependencies
|
||||
type dependencyCache struct {
|
||||
mu sync.RWMutex
|
||||
deps map[string]*PackageDeps
|
||||
srcMap map[string]string // binary pkg name -> source pkg name
|
||||
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
|
||||
|
|
@ -49,8 +51,10 @@ func NewDependencyResolver() *DependencyResolver {
|
|||
return &DependencyResolver{
|
||||
resolver: NewResolver(),
|
||||
cache: &dependencyCache{
|
||||
deps: make(map[string]*PackageDeps),
|
||||
srcMap: make(map[string]string),
|
||||
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
244
pkg/fetcher/parallel.go
Normal 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]--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
227
pkg/sandbox/sandbox_linux.go
Normal file
227
pkg/sandbox/sandbox_linux.go
Normal 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
379
pkg/ui/pacman.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue