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/build/
|
||||||
recipes/dist/
|
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
|
# Allow scripts directory
|
||||||
!scripts/
|
!scripts/
|
||||||
!scripts/*.sh
|
!scripts/*.sh
|
||||||
|
|
|
||||||
273
cmd/install.go
273
cmd/install.go
|
|
@ -38,17 +38,22 @@ var InstallCmd = &cobra.Command{
|
||||||
autoSource, _ := cmd.Flags().GetBool("auto-source")
|
autoSource, _ := cmd.Flags().GetBool("auto-source")
|
||||||
autoBuildDeps, _ := cmd.Flags().GetBool("auto-build-deps")
|
autoBuildDeps, _ := cmd.Flags().GetBool("auto-build-deps")
|
||||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
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 {
|
if dryRun {
|
||||||
status := ui.NewStatusBar("", 1)
|
pui.PrintInfo("DRY RUN MODE")
|
||||||
status.SetTheme("neon")
|
pui.PrintInfo(fmt.Sprintf("Root directory: %s", rootDir))
|
||||||
status.PrintHeader("DRY RUN MODE")
|
pui.PrintInfo(fmt.Sprintf("Work directory: %s", workDir))
|
||||||
status.PrintInfo(fmt.Sprintf("Root directory: %s", rootDir))
|
pui.PrintInfo(fmt.Sprintf("Auto-source: %t", autoSource))
|
||||||
status.PrintInfo(fmt.Sprintf("Work directory: %s", workDir))
|
pui.PrintInfo(fmt.Sprintf("Auto-build-deps: %t", autoBuildDeps))
|
||||||
status.PrintInfo(fmt.Sprintf("Auto-source: %t", autoSource))
|
pui.PrintInfo(fmt.Sprintf("Parallel jobs: %d", jobs))
|
||||||
status.PrintInfo(fmt.Sprintf("Auto-build-deps: %t", autoBuildDeps))
|
pui.PrintInfo(fmt.Sprintf("Cooldown: %v", cooldown))
|
||||||
status.PrintInfo("Auto-resolve-deps: enabled (default)")
|
|
||||||
status.PrintFooter()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
installTargets := make([]string, 0, len(args))
|
installTargets := make([]string, 0, len(args))
|
||||||
|
|
@ -56,7 +61,7 @@ var InstallCmd = &cobra.Command{
|
||||||
|
|
||||||
var session *autoBuildSession
|
var session *autoBuildSession
|
||||||
if autoSource {
|
if autoSource {
|
||||||
session = newAutoBuildSession(workDir, autoBuildDeps)
|
session = newAutoBuildSession(workDir, autoBuildDeps, jobs, cooldown)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, target := range args {
|
for _, target := range args {
|
||||||
|
|
@ -67,9 +72,8 @@ var InstallCmd = &cobra.Command{
|
||||||
|
|
||||||
if isFile {
|
if isFile {
|
||||||
if dryRun {
|
if dryRun {
|
||||||
status := ui.NewStatusBar("", 1)
|
pui := ui.NewPacmanUI(false)
|
||||||
status.SetTheme("neon")
|
pui.PrintInfo(fmt.Sprintf(i18n.T("would_install_file"), target))
|
||||||
status.PrintInfo(fmt.Sprintf(i18n.T("would_install_file"), target))
|
|
||||||
}
|
}
|
||||||
installTargets = append(installTargets, target)
|
installTargets = append(installTargets, target)
|
||||||
continue
|
continue
|
||||||
|
|
@ -83,10 +87,8 @@ var InstallCmd = &cobra.Command{
|
||||||
}
|
}
|
||||||
|
|
||||||
if dryRun {
|
if dryRun {
|
||||||
status := ui.NewStatusBar("", 1)
|
pui.PrintInfo(fmt.Sprintf(i18n.T("would_auto_build"), target))
|
||||||
status.SetTheme("neon")
|
installTargets = append(installTargets, target)
|
||||||
status.PrintInfo(fmt.Sprintf(i18n.T("would_auto_build"), target))
|
|
||||||
installTargets = append(installTargets, target) // для демонстрации
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,28 +101,21 @@ var InstallCmd = &cobra.Command{
|
||||||
|
|
||||||
if len(installTargets) == 1 {
|
if len(installTargets) == 1 {
|
||||||
if dryRun {
|
if dryRun {
|
||||||
status := ui.NewStatusBar("", 1)
|
pui.PrintInfo(i18n.T("would_install_one"))
|
||||||
status.SetTheme("neon")
|
|
||||||
status.PrintInfo(i18n.T("would_install_one"))
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(i18n.T("installing_one")+"\n", installTargets[0])
|
pui.PrintOperation("installing", installTargets[0])
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if dryRun {
|
if dryRun {
|
||||||
status := ui.NewStatusBar("", 1)
|
pui.PrintInfo(fmt.Sprintf(i18n.T("would_install_many"), len(installTargets)))
|
||||||
status.SetTheme("neon")
|
|
||||||
status.PrintInfo(fmt.Sprintf(i18n.T("would_install_many"), len(installTargets)))
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(i18n.T("installing_many")+"\n", len(installTargets))
|
pui.PrintOperation("installing", fmt.Sprintf("%d packages", len(installTargets)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if dryRun {
|
if dryRun {
|
||||||
status := ui.NewStatusBar("", 1)
|
pui.PrintSuccess("DRY RUN COMPLETE")
|
||||||
status.SetTheme("neon")
|
pui.PrintInfo(i18n.T("No actual changes were made."))
|
||||||
status.PrintHeader("DRY RUN COMPLETE")
|
|
||||||
status.PrintInfo(i18n.T("No actual changes were made."))
|
|
||||||
status.PrintFooter()
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,7 +131,7 @@ var InstallCmd = &cobra.Command{
|
||||||
return fmt.Errorf("failed to install packages: %w", err)
|
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
|
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-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("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().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) {
|
func isInstallFileTarget(target string) (bool, error) {
|
||||||
|
|
@ -200,6 +197,10 @@ type autoBuildSession struct {
|
||||||
builtPackages map[string]string
|
builtPackages map[string]string
|
||||||
toolDepsReady map[string]struct{}
|
toolDepsReady map[string]struct{}
|
||||||
buildingPackages 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
|
// depNode represents a node in the dependency graph
|
||||||
|
|
@ -353,7 +354,7 @@ func (g *depGraph) getMaxLevel() int {
|
||||||
return max
|
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 := builder.NewBuilder(workDir)
|
||||||
b.SetQuiet(true)
|
b.SetQuiet(true)
|
||||||
|
|
||||||
|
|
@ -368,6 +369,9 @@ func newAutoBuildSession(workDir string, autoBuildDeps bool) *autoBuildSession {
|
||||||
builtPackages: make(map[string]string),
|
builtPackages: make(map[string]string),
|
||||||
toolDepsReady: make(map[string]struct{}),
|
toolDepsReady: make(map[string]struct{}),
|
||||||
buildingPackages: make(map[string]struct{}),
|
buildingPackages: make(map[string]struct{}),
|
||||||
|
processing: make(map[string]struct{}),
|
||||||
|
jobs: jobs,
|
||||||
|
cooldown: cooldown,
|
||||||
}
|
}
|
||||||
s.refreshBuildEnv()
|
s.refreshBuildEnv()
|
||||||
return s
|
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), " -> "))
|
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 builtPath, ok := s.builtPackages[requestName]; ok {
|
||||||
if asBuildDep {
|
if asBuildDep {
|
||||||
if err := s.installBuildDependency(requestName, builtPath); err != nil {
|
if err := s.installBuildDependency(requestName, builtPath); err != nil {
|
||||||
|
|
@ -423,6 +428,27 @@ func (s *autoBuildSession) buildPackageWithFallback(requestName string, asBuildD
|
||||||
return builtPath, nil
|
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{}{}
|
s.buildingPackages[requestName] = struct{}{}
|
||||||
defer delete(s.buildingPackages, requestName)
|
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
|
// 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 {
|
func (s *autoBuildSession) collectAllDependencies(rootPkg string, graph *depGraph, stack []string) error {
|
||||||
rootPkg = normalizePackageName(rootPkg)
|
rootPkg = normalizePackageName(rootPkg)
|
||||||
if rootPkg == "" {
|
if rootPkg == "" {
|
||||||
|
|
@ -626,13 +652,26 @@ func (s *autoBuildSession) collectAllDependencies(rootPkg string, graph *depGrap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if already processed
|
// Check if already being processed globally at session level
|
||||||
node := graph.getOrCreateNode(rootPkg)
|
s.processingMu.Lock()
|
||||||
if node.srcInfo != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
graph.mu.RUnlock()
|
||||||
|
|
||||||
// Resolve source
|
// Resolve source - this is the expensive operation
|
||||||
srcInfo, err := s.resolver.ResolveSource(rootPkg)
|
srcInfo, err := s.resolver.ResolveSource(rootPkg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to resolve source for %s: %w", rootPkg, err)
|
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)
|
rcp := autoRecipeFromDebian(srcInfo)
|
||||||
pkgName := rcp.Name
|
pkgName := rcp.Name
|
||||||
|
|
||||||
node = graph.getOrCreateNode(pkgName)
|
// Update graph with proper locking
|
||||||
node.srcInfo = srcInfo
|
graph.mu.Lock()
|
||||||
node.recipe = rcp
|
node, exists = graph.nodes[pkgName]
|
||||||
node.buildDepends = srcInfo.BuildDepends
|
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
|
// Collect all dependency names first
|
||||||
var depsToProcess []string
|
var depsToProcess []string
|
||||||
|
|
@ -667,37 +727,20 @@ func (s *autoBuildSession) collectAllDependencies(rootPkg string, graph *depGrap
|
||||||
// Add dependency relationship
|
// Add dependency relationship
|
||||||
graph.addDependency(pkgName, sourcePkg)
|
graph.addDependency(pkgName, sourcePkg)
|
||||||
|
|
||||||
// Check if already in graph
|
// Check if already in graph with lock
|
||||||
depNode := graph.getOrCreateNode(sourcePkg)
|
graph.mu.RLock()
|
||||||
if depNode.srcInfo == nil {
|
depNode, depExists := graph.nodes[sourcePkg]
|
||||||
|
alreadyProcessing := depExists && depNode.srcInfo != nil
|
||||||
|
graph.mu.RUnlock()
|
||||||
|
|
||||||
|
if !alreadyProcessing {
|
||||||
depsToProcess = append(depsToProcess, sourcePkg)
|
depsToProcess = append(depsToProcess, sourcePkg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process dependencies in parallel using worker pool
|
// Process dependencies sequentially to avoid race conditions
|
||||||
if len(depsToProcess) > 0 {
|
for _, dep := range depsToProcess {
|
||||||
var wg sync.WaitGroup
|
s.collectAllDependencies(dep, graph, append(stack, pkgName))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -734,55 +777,81 @@ func (s *autoBuildSession) buildDependenciesParallel(graph *depGraph) error {
|
||||||
continue
|
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 {
|
for _, n := range nodesToBuild {
|
||||||
fmt.Printf(" - %s\n", n.name)
|
fmt.Printf(" - %s\n", n.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build this level in parallel
|
// Build this level - use single worker if jobs=1 for thermal safety
|
||||||
var wg sync.WaitGroup
|
if s.jobs == 1 {
|
||||||
errChan := make(chan error, len(nodesToBuild))
|
// Sequential build with cooldown
|
||||||
|
for _, node := range nodesToBuild {
|
||||||
// Limit concurrent builds
|
_, err := s.buildPackageFromNode(node)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
n.err = err
|
node.err = err
|
||||||
errChan <- fmt.Errorf("%s: %w", n.name, err)
|
fmt.Printf(" ⚠️ %s failed: %v\n", node.name, err)
|
||||||
} else {
|
} 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()
|
// Limit concurrent builds based on jobs setting
|
||||||
close(errChan)
|
semaphore := make(chan struct{}, s.jobs)
|
||||||
|
|
||||||
// Check for errors
|
for _, node := range nodesToBuild {
|
||||||
errors := make([]error, 0)
|
wg.Add(1)
|
||||||
for err := range errChan {
|
go func(n *depNode) {
|
||||||
errors = append(errors, err)
|
defer wg.Done()
|
||||||
}
|
|
||||||
|
|
||||||
if len(errors) > 0 {
|
semaphore <- struct{}{}
|
||||||
fmt.Printf("⚠️ %d packages failed at level %d\n", len(errors), level)
|
defer func() { <-semaphore }()
|
||||||
for _, err := range errors {
|
|
||||||
fmt.Printf(" %v\n", err)
|
// 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
|
// Refresh environment after each level
|
||||||
s.refreshBuildEnv()
|
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
|
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())
|
||||||
|
}
|
||||||
9
main.go
9
main.go
|
|
@ -4,15 +4,16 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"zsvo/cmd"
|
"zsvo/cmd"
|
||||||
"zsvo/pkg/i18n"
|
"zsvo/pkg/i18n"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "zsvo",
|
Use: "zsvo",
|
||||||
Short: "A simple source-based package manager",
|
Short: "A simple source-based package manager",
|
||||||
SilenceErrors: true,
|
SilenceErrors: false,
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
Long: `A minimal package manager for custom Linux distributions based on LFS
|
Long: `A minimal package manager for custom Linux distributions based on LFS
|
||||||
|
|
||||||
|
|
@ -48,10 +49,10 @@ func init() {
|
||||||
func main() {
|
func main() {
|
||||||
// Detect language from environment
|
// Detect language from environment
|
||||||
i18n.DetectLanguage()
|
i18n.DetectLanguage()
|
||||||
|
|
||||||
// Set up logging
|
// Set up logging
|
||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
log.Printf("Error: %v", err)
|
log.Printf("Error: %v", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
|
||||||
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
|
// dependencyCache caches resolved dependencies
|
||||||
type dependencyCache struct {
|
type dependencyCache struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
deps map[string]*PackageDeps
|
deps map[string]*PackageDeps
|
||||||
srcMap map[string]string // binary pkg name -> source pkg name
|
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
|
// PackageDeps represents Debian package dependencies
|
||||||
|
|
@ -49,8 +51,10 @@ func NewDependencyResolver() *DependencyResolver {
|
||||||
return &DependencyResolver{
|
return &DependencyResolver{
|
||||||
resolver: NewResolver(),
|
resolver: NewResolver(),
|
||||||
cache: &dependencyCache{
|
cache: &dependencyCache{
|
||||||
deps: make(map[string]*PackageDeps),
|
deps: make(map[string]*PackageDeps),
|
||||||
srcMap: make(map[string]string),
|
notFound: make(map[string]bool),
|
||||||
|
srcMap: make(map[string]string),
|
||||||
|
inFlight: make(map[string]*sync.WaitGroup),
|
||||||
},
|
},
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
|
|
@ -68,6 +72,11 @@ func NewDependencyResolver() *DependencyResolver {
|
||||||
|
|
||||||
// ResolveDependencies resolves all dependencies for a Debian binary package
|
// ResolveDependencies resolves all dependencies for a Debian binary package
|
||||||
func (r *DependencyResolver) ResolveDependencies(pkgName string) (*PackageDeps, error) {
|
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
|
// Check cache first
|
||||||
if cached := r.cache.get(pkgName); cached != nil {
|
if cached := r.cache.get(pkgName); cached != nil {
|
||||||
return 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)
|
// Try to find in Sources files (for build-deps) or Packages files (for runtime deps)
|
||||||
deps, err := r.resolveFromDebian(pkgName)
|
deps, err := r.resolveFromDebian(pkgName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Cache the failure to avoid repeated lookups
|
||||||
|
r.cache.setNotFound(pkgName)
|
||||||
return nil, err
|
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
|
// BinaryToSource maps a Debian binary package name to its source package name
|
||||||
func (r *DependencyResolver) BinaryToSource(binaryPkg string) (string, error) {
|
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 != "" {
|
if src := r.cache.getSource(binaryPkg); src != "" {
|
||||||
return src, nil
|
return src, nil
|
||||||
}
|
}
|
||||||
|
|
@ -98,13 +114,37 @@ func (r *DependencyResolver) BinaryToSource(binaryPkg string) (string, error) {
|
||||||
return src, nil
|
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
|
// Look up in Debian Sources
|
||||||
srcInfo, err := r.resolver.ResolveSource(binaryPkg)
|
srcInfo, err := r.resolver.ResolveSource(binaryPkg)
|
||||||
if err != nil {
|
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)
|
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.setSource(binaryPkg, srcInfo.SourcePackage)
|
||||||
|
r.cache.finishInFlight(binaryPkg, false)
|
||||||
return srcInfo.SourcePackage, nil
|
return srcInfo.SourcePackage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,6 +339,54 @@ func (c *dependencyCache) get(pkg string) *PackageDeps {
|
||||||
return c.deps[pkg]
|
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) {
|
func (c *dependencyCache) set(pkg string, deps *PackageDeps) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
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