package main import ( "bufio" "bytes" "context" "encoding/xml" "fmt" "io" "os" "os/exec" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "syscall" "time" ) // zypper host execution (no shell, no bash) func zypperHostExec(args []string) (string, int) { cmd := exec.Command("zypper", args...) cmd.Env = []string{ "PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", "HOME=/root", "LANG=C.UTF-8", } output, err := cmd.Output() out := strings.TrimSpace(string(output)) if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { return out, exitErr.ExitCode() } return out, -1 } return out, 0 } func zypperDryRun(extraArgs []string) (string, int) { args := []string{"--non-interactive", "--no-cd", "--xmlout"} args = append(args, extraArgs...) cmd := exec.Command("zypper", args...) output, err := cmd.Output() out := strings.TrimSpace(string(output)) if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { return out, exitErr.ExitCode() } return out, -1 } return out, 0 } // zypper chroot execution func runZypper(ctx context.Context, rootfs string, args ...string) (stdout, stderr string, exitCode int, err error) { info, err := os.Stat(rootfs) if err != nil { return "", "", -1, fmt.Errorf("rootfs does not exist: %w", err) } if !info.IsDir() { return "", "", -1, fmt.Errorf("rootfs is a not a directory") } cmd := exec.CommandContext(ctx, "/usr/bin/zypper", args...) cmd.SysProcAttr = &syscall.SysProcAttr{Chroot: rootfs} cmd.Dir = "/" cmd.Env = []string{ "PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", "ZYPP_CURL2=1", "ZYPP_PCK_PRELOAD=1", "ZYPP_SINGLE_RPMTRANS=1", "HOME=/root", "LANG=C.UTF-8", } cmd.Stdin = nil var outBuf, errBuf bytes.Buffer cmd.Stdout = &outBuf cmd.Stderr = &errBuf err = cmd.Run() if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { exitCode = exitErr.ExitCode() } else { exitCode = -1 } } return outBuf.String(), errBuf.String(), exitCode, err } // handover (terminal passthrough) func handover(command string) int { cmd := exec.Command("bash", "-c", command) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { if exitErr, ok := err.(*exec.ExitError); ok { return exitErr.ExitCode() } return 1 } return 0 } // xml parsing helpers func findElements(xmlData, name string) []map[string]string { decoder := xml.NewDecoder(strings.NewReader(xmlData)) var results []map[string]string for { tok, err := decoder.Token() if err == io.EOF { break } if err != nil { continue } if se, ok := tok.(xml.StartElement); ok && se.Name.Local == name { attrs := make(map[string]string) for _, attr := range se.Attr { attrs[attr.Name.Local] = attr.Value } results = append(results, attrs) } } return results } func findMessages(xmlData string) []string { decoder := xml.NewDecoder(strings.NewReader(xmlData)) var results []string for { tok, err := decoder.Token() if err == io.EOF { break } if err != nil { continue } if se, ok := tok.(xml.StartElement); ok && se.Name.Local == "message" { var text strings.Builder for { innerTok, err := decoder.Token() if err == io.EOF || err != nil { break } if ee, ok := innerTok.(xml.EndElement); ok && ee.Name.Local == "message" { break } if cd, ok := innerTok.(xml.CharData); ok { text.Write(cd) } } results = append(results, text.String()) } } return results } func parseInstallSummary(xmlData string) (downloadSize, diffBytes float64, numPkgs int, found bool) { for _, s := range findElements(xmlData, "install-summary") { debugf("install-summary attrs: download-size=%q space-usage-diff=%q packages-to-change=%q", s["download-size"], s["space-usage-diff"], s["packages-to-change"]) fmt.Sscanf(s["download-size"], "%f", &downloadSize) fmt.Sscanf(s["space-usage-diff"], "%f", &diffBytes) fmt.Sscanf(s["packages-to-change"], "%d", &numPkgs) found = true } if !found { return } if downloadSize == 0 { downloadSize = findChildTextFloat(xmlData, "install-summary", "download-size") } if diffBytes == 0 { diffBytes = findChildTextFloat(xmlData, "install-summary", "space-usage-diff") } if numPkgs == 0 { numPkgs = int(findChildTextFloat(xmlData, "install-summary", "packages-to-change")) } return } func findChildTextFloat(xmlData, parent, child string) float64 { decoder := xml.NewDecoder(strings.NewReader(xmlData)) inParent := 0 for { tok, err := decoder.Token() if err != nil { break } switch t := tok.(type) { case xml.StartElement: if t.Name.Local == parent { inParent++ } else if inParent > 0 && t.Name.Local == child { var text strings.Builder for { innerTok, err := decoder.Token() if err != nil { break } if ee, ok := innerTok.(xml.EndElement); ok && ee.Name.Local == child { break } if cd, ok := innerTok.(xml.CharData); ok { text.Write(cd) } } var val float64 fmt.Sscanf(strings.TrimSpace(text.String()), "%f", &val) return val } case xml.EndElement: if t.Name.Local == parent && inParent > 0 { inParent-- } } } return 0 } func parseAllSolvables(xmlData string) []packageInfo { var pkgs []packageInfo for _, s := range findElements(xmlData, "solvable") { if s["type"] == "package" { var size float64 fmt.Sscanf(s["size"], "%f", &size) pkgs = append(pkgs, packageInfo{ Name: s["name"], Edition: s["edition"], Arch: s["arch"], Action: "install", Size: size, }) } } return pkgs } func parseToUpdate(xmlData string) []packageInfo { var pkgs []packageInfo for _, u := range findElements(xmlData, "update") { if u["kind"] == "package" { var size float64 fmt.Sscanf(u["size"], "%f", &size) pkgs = append(pkgs, packageInfo{ Name: u["name"], Edition: u["edition"], Arch: u["arch"], Action: "upgrade", Size: size, }) } } return pkgs } func parseToRemove(xmlData string) []packageInfo { decoder := xml.NewDecoder(strings.NewReader(xmlData)) var results []packageInfo inToRemove := 0 for { tok, err := decoder.Token() if err == io.EOF { break } if err != nil { continue } switch t := tok.(type) { case xml.StartElement: if t.Name.Local == "to-remove" { inToRemove++ } else if inToRemove > 0 && t.Name.Local == "solvable" { attrs := make(map[string]string) for _, attr := range t.Attr { attrs[attr.Name.Local] = attr.Value } if attrs["type"] == "package" { results = append(results, packageInfo{ Name: attrs["name"], Edition: attrs["edition"], Arch: attrs["arch"], Action: "remove", }) } } case xml.EndElement: if t.Name.Local == "to-remove" { inToRemove-- } } } return results } func filterPackages(allPkgs, rmPkgs []packageInfo) []packageInfo { rmSet := make(map[string]bool, len(rmPkgs)) for _, p := range rmPkgs { rmSet[p.String()] = true } var result []packageInfo for _, p := range allPkgs { if !rmSet[p.String()] { result = append(result, p) } } sort.Slice(result, func(i, j int) bool { return result[i].String() < result[j].String() }) return result } // shared handler logic type transactionInfo struct { installs []packageInfo removes []packageInfo downloadSize float64 diffBytes float64 numPkgs int nothingToDo bool } func dryRunAndParse(extraArgs []string) (*transactionInfo, error) { xmlOutput, ret := zypperDryRun(extraArgs) debugf("%s", xmlOutput) if strings.Contains(xmlOutput, "Nothing to do") { return &transactionInfo{nothingToDo: true}, nil } downloadSize, diffBytes, numPkgs, found := parseInstallSummary(xmlOutput) if ret != 0 { if !found || numPkgs == 0 { msgs := findMessages(xmlOutput) if len(msgs) > 0 { return nil, fmt.Errorf("zypper error:\n%s", strings.Join(msgs, "\n")) } return nil, fmt.Errorf("zypper dry-run failed with exit code %d", ret) } } if !found || numPkgs == 0 { return &transactionInfo{nothingToDo: true}, nil } installs := parseAllSolvables(xmlOutput) removes := parseToRemove(xmlOutput) filtered := filterPackages(installs, removes) return &transactionInfo{ installs: filtered, removes: removes, downloadSize: downloadSize, diffBytes: diffBytes, numPkgs: numPkgs, }, nil } func showTransactionAndConfirm(info *transactionInfo) bool { if info.nothingToDo { infof("%s", colorText(colorInfo, "Nothing to do. Exiting...")) return false } printTransactionSummary(info.installs, info.removes, info.downloadSize, info.diffBytes, info.numPkgs) fmt.Println() if cfg.noConfirm { return true } return queryYesNo("Do you want to continue?", "yes") } func pkgNames(pkgs []packageInfo) []string { names := make([]string, len(pkgs)) for i, p := range pkgs { names[i] = p.String() } return names } // worker pool type job struct { item string itemCounter int totalItems int workerIdx int size float64 } func makeLogMessages(item string, counter, total int) map[string]string { m := make(map[string]string) forceStr := "" if cfg.force { forceStr = "force " } switch cfg.command { case "refresh", "ref": m["error"] = fmt.Sprintf("Error %srefreshing repo [%d/%d] %q", forceStr, counter, total, item) m["exception"] = fmt.Sprintf("SIGINT while %srefreshing repo [%d/%d] %q", forceStr, counter, total, item) default: m["error"] = fmt.Sprintf("Error downloading package [%d/%d] %q", counter, total, item) m["exception"] = fmt.Sprintf("SIGINT while downloading package [%d/%d] %q", counter, total, item) } return m } func refreshArgs() []string { if cfg.force { return []string{"refresh", "--force"} } return []string{"refresh"} } func worker(ctx context.Context, wg *sync.WaitGroup, uuidPool chan string, tmpDir string, jobsChan chan job) { defer wg.Done() for j := range jobsChan { select { case <-ctx.Done(): return default: } uuid := <-uuidPool msgs := makeLogMessages(j.item, j.itemCounter, j.totalItems) if prog != nil { prog.increment() } downloadLine(j.itemCounter, j.item, j.size) rootfs := filepath.Join(tmpDir, uuid, "rootfs") var zypperArgs []string if cfg.command == "refresh" || cfg.command == "ref" { zypperArgs = []string{"--non-interactive"} zypperArgs = append(zypperArgs, refreshArgs()...) zypperArgs = append(zypperArgs, j.item) } else { zypperArgs = []string{"--non-interactive", "download", j.item} } var stdout, stderr string var exitCode int var err error for attempt := 1; attempt <= maxRetries; attempt++ { stdout, stderr, exitCode, err = runZypper(ctx, rootfs, zypperArgs...) if ctx.Err() != nil { debugf("%s", msgs["exception"]) uuidPool <- uuid return } if err == nil && exitCode == 0 { break } if attempt < maxRetries { warningf("Attempt %d failed for %q. Retrying...", attempt, j.item) time.Sleep(1 * time.Second) } else { errorf("%s", colorText(colorError, fmt.Sprintf("%s. zypper exit code: %d", msgs["error"], exitCode))) if stderr != "" { debugf("[stderr]\n%s", stderr) } if stdout != "" { debugf("[stdout]\n%s", stdout) } } } uuidPool <- uuid } } func mainTask(ctx context.Context, taskItems []string, tmpDir string, needDev bool, tracker *MountTracker) error { numWorkers := cfg.jobs if len(taskItems) < numWorkers { numWorkers = len(taskItems) } uuidPool := make(chan string, numWorkers) allUUIDs := make([]string, numWorkers) for i := 0; i < numWorkers; i++ { u := randomUUID() allUUIDs[i] = u uuidPool <- u } if needDev { for _, uuid := range allUUIDs { if err := setupChroot(tmpDir, uuid, needDev, tracker); err != nil { errorf("%s", colorText(colorError, fmt.Sprintf("Chroot setup failed: %v", err))) return err } } } else { var setupWg sync.WaitGroup setupErrs := make(chan error, numWorkers) for _, uuid := range allUUIDs { setupWg.Add(1) go func(u string) { defer setupWg.Done() if err := setupChroot(tmpDir, u, needDev, tracker); err != nil { setupErrs <- err } }(uuid) } setupWg.Wait() close(setupErrs) if err := <-setupErrs; err != nil { errorf("%s", colorText(colorError, fmt.Sprintf("Chroot setup failed: %v", err))) return err } } totalItems := len(taskItems) totalBytes := float64(0) if cfg.txInfo != nil { for _, p := range cfg.txInfo { totalBytes += p.Size } } pkgSizes := make(map[string]float64) if cfg.txInfo != nil { for _, p := range cfg.txInfo { pkgSizes[p.String()] = p.Size } } prog = newProgressState(totalItems) prog.startTicker() defer func() { prog.finish() prog = nil }() jobsChan := make(chan job, totalItems) var wg sync.WaitGroup for i := 0; i < numWorkers; i++ { wg.Add(1) go worker(ctx, &wg, uuidPool, tmpDir, jobsChan) } itemCounter := 0 for _, item := range taskItems { itemCounter++ size := pkgSizes[item] select { case <-ctx.Done(): close(jobsChan) wg.Wait() return ctx.Err() case jobsChan <- job{item: item, itemCounter: itemCounter, totalItems: totalItems, workerIdx: (itemCounter - 1) % numWorkers, size: size}: } } close(jobsChan) wg.Wait() if totalBytes > 0 { fetchSummary(totalBytes, time.Since(prog.startTime)) } return nil } // prompt func queryYesNo(question, defaultVal string) bool { valid := map[string]bool{"yes": true, "y": true, "ye": true, "no": false, "n": false} var prompt string switch defaultVal { case "": prompt = " [y/n]: " case "yes": prompt = " [Y/n]: " case "no": prompt = " [y/N]: " default: prompt = " [y/n]: " } reader := bufio.NewReader(os.Stdin) for { fmt.Print(colorText(colorInput, question+prompt)) choice, err := reader.ReadString('\n') if err != nil { fmt.Println() return false } choice = strings.ToLower(strings.TrimSpace(choice)) if defaultVal != "" && choice == "" { return valid[defaultVal] } if v, ok := valid[choice]; ok { return v } fmt.Print(colorText(colorWarning, "Please respond with 'yes' or 'no' (or 'y' or 'n').\n")) } } // zypp lock func getZyppLock() int { ourPid := os.Getpid() os.MkdirAll(filepath.Dir(zypperPidFile), 0755) os.WriteFile(zypperPidFile, []byte(fmt.Sprintf("%d", ourPid)), 0644) return ourPid } func releaseZyppLock() bool { if os.Getuid() == 0 { if err := os.WriteFile(zypperPidFile, []byte{}, 0644); err != nil { debugf("Warning: failed to clear zypp lock file: %v", err) } return true } return false } func checkZypperRunning() (int, string) { data, err := os.ReadFile(zypperPidFile) if err != nil { return 0, "" } pidStr := strings.TrimSpace(string(data)) if pidStr == "" { return 0, "" } var pid int fmt.Sscanf(pidStr, "%d", &pid) if pid == 0 { return 0, "" } commPath := fmt.Sprintf("/proc/%d/comm", pid) commData, err := os.ReadFile(commPath) if err != nil { return 0, "" } program := strings.TrimSpace(string(commData)) if program == "" { return 0, "" } return pid, program } // command handlers func handleRefresh(ctx context.Context, tmpDir string) error { tracker := &MountTracker{} defer vlcroCleanup(tmpDir, tracker) statusMsg("Reading package lists") xmlOutput, _ := zypperDryRun([]string{"repos"}) statusMsg("Enumerating enabled repos") getZyppLock() repos := findElements(xmlOutput, "repo") var aliases []string for _, r := range repos { if r["enabled"] == "1" { aliases = append(aliases, r["alias"]) } } debugf("Enabled repos: %v", aliases) if len(aliases) == 0 { infof("%s", colorText(colorInfo, "No repos found. Exiting...")) return nil } return mainTask(ctx, aliases, tmpDir, true, tracker) } func handleDistUpgrade(ctx context.Context, tmpDir string) error { tracker := &MountTracker{} defer vlcroCleanup(tmpDir, tracker) statusMsg("Reading package lists") statusMsg("Building dependency tree") info, err := dryRunAndParse([]string{"dist-upgrade", "--dry-run"}) if err != nil { errorf("%s", colorText(colorError, fmt.Sprintf("%v\nThere are package conflicts that must be manually resolved. See output of:\nzypper --non-interactive --no-cd dist-upgrade --dry-run", err))) return err } if info.nothingToDo { infof("%s", colorText(colorInfo, "Nothing to do. Exiting...")) return nil } cfg.txInfo = info.installs if !showTransactionAndConfirm(info) { return nil } getZyppLock() if info.downloadSize > 0 { statusMsg("Downloading packages") err := mainTask(ctx, pkgNames(info.installs), tmpDir, false, tracker) if err == context.Canceled { return err } if err != nil { warningf("%s", colorText(colorWarning, fmt.Sprintf("Parallel execution failed (%v). Falling back to standard zypper...", err))) } } vlcroCleanup(tmpDir, tracker) if !cfg.downloadOnly { infof("%s", colorText(colorInfo, "Vlcro has finished its tasks. Handing you over to zypper...")) nonInteractive := "" if cfg.noConfirm { nonInteractive = "--non-interactive" } code := handover(fmt.Sprintf("env ZYPP_CURL2=1 ZYPP_PCK_PRELOAD=1 ZYPP_SINGLE_RPMTRANS=1 zypper %s --no-cd dist-upgrade", nonInteractive)) if code != 0 { return fmt.Errorf("zypper dist-upgrade failed with exit code %d", code) } } return nil } func handleUpdate(ctx context.Context, tmpDir string) error { tracker := &MountTracker{} defer vlcroCleanup(tmpDir, tracker) statusMsg("Reading package lists") statusMsg("Building dependency tree") info, err := dryRunAndParse([]string{"update", "--dry-run"}) if err != nil { errorf("%s", colorText(colorError, fmt.Sprintf("Error: %v", err))) return err } if info.nothingToDo { infof("%s", colorText(colorInfo, "Nothing to do. Exiting...")) return nil } cfg.txInfo = info.installs if !showTransactionAndConfirm(info) { return nil } getZyppLock() if info.downloadSize > 0 { statusMsg("Downloading packages") err := mainTask(ctx, pkgNames(info.installs), tmpDir, false, tracker) if err == context.Canceled { return err } if err != nil { warningf("%s", colorText(colorWarning, fmt.Sprintf("Parallel execution failed (%v). Falling back to standard zypper...", err))) } } vlcroCleanup(tmpDir, tracker) if !cfg.downloadOnly { infof("%s", colorText(colorInfo, "Vlcro has finished its tasks. Handing you over to zypper...")) nonInteractive := "" if cfg.noConfirm { nonInteractive = "--non-interactive" } code := handover(fmt.Sprintf("env ZYPP_CURL2=1 ZYPP_PCK_PRELOAD=1 ZYPP_SINGLE_RPMTRANS=1 zypper %s --no-cd update", nonInteractive)) if code != 0 { return fmt.Errorf("zypper update failed with exit code %d", code) } } return nil } // search / se type searchExitError struct { code int } func (e *searchExitError) Error() string { return fmt.Sprintf("zypper search failed with exit code %d", e.code) } // repoRefreshDelayMinutes returns the configured repo.refresh.delay (minutes) // from zypp.conf, or the default of 10 when unset. func repoRefreshDelayMinutes() int { delay := -1 parse := func(path string) { data, err := os.ReadFile(path) if err != nil { return } re := regexp.MustCompile(`^\s*repo\.refresh\.delay\s*[:=]\s*(\d+)\s*$`) for _, line := range strings.Split(string(data), "\n") { if m := re.FindStringSubmatch(line); m != nil { if n, err := strconv.Atoi(m[1]); err == nil { delay = n } } } } parse("/etc/zypp/zypp.conf") if entries, err := filepath.Glob("/etc/zypp/zypp.conf.d/*.conf"); err == nil { sort.Strings(entries) for _, e := range entries { parse(e) } } if delay < 0 { delay = 10 } return delay } // repoNeedsRefresh mirrors zypper's auto-refresh policy: a repo needs // refreshing when its cached index file timestamp is older than // repo.refresh.delay. Repos never refreshed must be refreshed. func repoNeedsRefresh(alias string) bool { delay := repoRefreshDelayMinutes() rawPath := filepath.Join("/var/cache/zypp/raw", alias) repomd := filepath.Join(rawPath, "repodata", "repomd.xml") var last time.Time if st, err := os.Stat(repomd); err == nil { last = st.ModTime() } else if info, err := os.Stat(rawPath); err == nil && info.IsDir() { filepath.Walk(rawPath, func(_ string, fi os.FileInfo, err error) error { if err == nil && fi.ModTime().After(last) { last = fi.ModTime() } return nil }) } else { return true } if last.IsZero() { return true } return time.Since(last) > time.Duration(delay)*time.Minute } // runSearch runs the real zypper search with terminal passthrough so the // output is identical to stock zypper. func runSearch(args []string) int { cmd := exec.Command("zypper", append([]string{"--no-refresh", "search"}, args...)...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { if exitErr, ok := err.(*exec.ExitError); ok { return exitErr.ExitCode() } return 1 } return 0 } func handleSearch(ctx context.Context, tmpDir string) error { if len(cfg.packages) == 0 { errorf("%s", colorText(colorError, "Search must have at least one search pattern")) return fmt.Errorf("no search pattern specified") } statusMsg("Reading repository list") xmlOutput, _ := zypperDryRun([]string{"repos"}) repos := findElements(xmlOutput, "repo") var staleRepos []string for _, r := range repos { if r["enabled"] != "1" || r["autorefresh"] != "1" { continue } if repoNeedsRefresh(r["alias"]) { staleRepos = append(staleRepos, r["alias"]) } } debugf("Stale repos: %v", staleRepos) if len(staleRepos) > 0 { tracker := &MountTracker{} statusMsg(fmt.Sprintf("Refreshing %d stale repo(s)", len(staleRepos))) getZyppLock() err := mainTask(ctx, staleRepos, tmpDir, true, tracker) if err == context.Canceled { vlcroCleanup(tmpDir, tracker) return err } if err != nil { warningf("%s", colorText(colorWarning, fmt.Sprintf("Parallel refresh failed (%v). Falling back to standard zypper...", err))) } vlcroCleanup(tmpDir, tracker) } code := runSearch(cfg.packages) if code != 0 { return &searchExitError{code: code} } return nil } func handleInstall(ctx context.Context, tmpDir string) error { if len(cfg.packages) == 0 { errorf("%s", colorText(colorError, "No packages specified for installation")) return fmt.Errorf("no packages specified") } tracker := &MountTracker{} defer vlcroCleanup(tmpDir, tracker) statusMsg("Reading package lists") statusMsg("Building dependency tree") dryRunArgs := []string{"install", "--dry-run"} dryRunArgs = append(dryRunArgs, cfg.packages...) info, err := dryRunAndParse(dryRunArgs) if err != nil { errorf("%s", colorText(colorError, fmt.Sprintf("Error: %v", err))) return err } if info.nothingToDo { infof("%s", colorText(colorInfo, "Nothing to do. Exiting...")) return nil } cfg.txInfo = info.installs if !showTransactionAndConfirm(info) { return nil } getZyppLock() if info.downloadSize > 0 { statusMsg("Downloading packages") err := mainTask(ctx, pkgNames(info.installs), tmpDir, false, tracker) if err == context.Canceled { return err } if err != nil { warningf("%s", colorText(colorWarning, fmt.Sprintf("Parallel execution failed (%v). Falling back to standard zypper...", err))) } } vlcroCleanup(tmpDir, tracker) if !cfg.downloadOnly { infof("%s", colorText(colorInfo, "Vlcro has finished its tasks. Handing you over to zypper...")) nonInteractive := "" if cfg.noConfirm { nonInteractive = "--non-interactive" } code := handover(fmt.Sprintf("env ZYPP_CURL2=1 ZYPP_PCK_PRELOAD=1 ZYPP_SINGLE_RPMTRANS=1 zypper %s --no-cd install %s", nonInteractive, strings.Join(cfg.packages, " "))) if code != 0 { return fmt.Errorf("zypper install failed with exit code %d", code) } } return nil } func handleINR(ctx context.Context, tmpDir string) error { tracker := &MountTracker{} defer vlcroCleanup(tmpDir, tracker) statusMsg("Reading package lists") statusMsg("Building dependency tree") info, err := dryRunAndParse([]string{"install-new-recommends", "--dry-run"}) if err != nil { errorf("%s", colorText(colorError, fmt.Sprintf("Error: %v", err))) return err } if info.nothingToDo { infof("%s", colorText(colorInfo, "Nothing to do. Exiting...")) return nil } cfg.txInfo = info.installs if !showTransactionAndConfirm(info) { return nil } getZyppLock() if info.downloadSize > 0 { statusMsg("Downloading packages") err := mainTask(ctx, pkgNames(info.installs), tmpDir, false, tracker) if err == context.Canceled { return err } if err != nil { warningf("%s", colorText(colorWarning, fmt.Sprintf("Parallel execution failed (%v). Falling back to standard zypper...", err))) } } vlcroCleanup(tmpDir, tracker) if !cfg.downloadOnly { infof("%s", colorText(colorInfo, "Vlcro has finished its tasks. Handing you over to zypper...")) nonInteractive := "" if cfg.noConfirm { nonInteractive = "--non-interactive" } code := handover(fmt.Sprintf("env ZYPP_CURL2=1 ZYPP_PCK_PRELOAD=1 ZYPP_SINGLE_RPMTRANS=1 zypper %s --no-cd install-new-recommends", nonInteractive)) if code != 0 { return fmt.Errorf("zypper install-new-recommends failed with exit code %d", code) } } return nil }