commit c1eb7b0e9459de99d3112df79a02c8025b741614 Author: Nixietab <75538775+nixietab@users.noreply.github.com> Date: Sun Sep 6 13:24:35 2026 -0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3c1f3a --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +vlcro diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2a5b2c --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# vlcro + +WIP README + +A parallel frontend for zypper. Speeds up slow zypper operations by running them in parallel across chroot-isolated workers + + +inspirated by [zypperoni](https://github.com/pavinjosdev/zypperoni) + + +## Build + +``` +go build -o vlcro . +``` + +To install copy to path + +## Usage + +``` +sudo vlcro [options] +``` + +Downloads are handled in parallel; the actual install is handed back to zypper. diff --git a/chroot.go b/chroot.go new file mode 100644 index 0000000..d736786 --- /dev/null +++ b/chroot.go @@ -0,0 +1,272 @@ +package main + +import ( + "crypto/rand" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "syscall" + "time" +) + +// mount tracker + +type MountTracker struct { + mounts []string + mu sync.Mutex +} + +func (mt *MountTracker) Add(path string) { + mt.mu.Lock() + defer mt.mu.Unlock() + mt.mounts = append(mt.mounts, path) +} + +func (mt *MountTracker) GetAll() []string { + mt.mu.Lock() + defer mt.mu.Unlock() + result := make([]string, len(mt.mounts)) + copy(result, mt.mounts) + sort.SliceStable(result, func(i, j int) bool { + return len(result[i]) > len(result[j]) + }) + return result +} + +func (mt *MountTracker) Reset() { + mt.mu.Lock() + defer mt.mu.Unlock() + mt.mounts = nil +} + +// mount helpers + +func findActiveMountCount(path string) int { + if path == "" { + return 0 + } + cmd := exec.Command("findmnt", "-R", "-n", "-o", "TARGET", path) + out, err := cmd.Output() + if err != nil { + return 0 + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) == 1 && lines[0] == "" { + return 0 + } + return len(lines) +} + +func hasActiveMounts(path string) bool { + return findActiveMountCount(path) > 0 +} + +func retryMount(source, target, fstype string, flags uintptr, data string, maxRetries int) error { + var lastErr error + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + time.Sleep(retryDelay) + } + err := syscall.Mount(source, target, fstype, flags, data) + if err == nil { + debugf("Mounted %s -> %s", source, target) + return nil + } + lastErr = err + debugf("Mount attempt %d failed for %s: %v", attempt+1, target, err) + } + return fmt.Errorf("mount %s failed after %d attempts: %w", target, maxRetries, lastErr) +} + +func retryUnmount(target string, flags int, maxRetries int) error { + var lastErr error + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + time.Sleep(retryDelay) + } + err := syscall.Unmount(target, flags) + if err == nil || err == syscall.EINVAL || err == syscall.ENOENT { + return nil + } + lastErr = err + debugf("Unmount attempt %d failed for %s: %v", attempt+1, target, err) + } + return fmt.Errorf("unmount %s failed after %d attempts: %w", target, maxRetries, lastErr) +} + +// chroot setup + +func setupChroot(tmpDir, uuid string, needDev bool, tracker *MountTracker) error { + base := filepath.Join(tmpDir, uuid) + rootfs := filepath.Join(base, "rootfs") + + dirs := []string{ + rootfs, + filepath.Join(base, "run"), + filepath.Join(base, "var", "cache", "zypp"), + filepath.Join(base, "var", "lib", "ca-certificates"), + } + for _, d := range dirs { + if err := os.MkdirAll(d, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", d, err) + } + } + + resolvPath := "/etc/resolv.conf" + if linkTarget, err := os.Readlink(resolvPath); err == nil { + if !filepath.IsAbs(linkTarget) { + linkTarget = filepath.Join("/etc", linkTarget) + } + target := filepath.Join(base, linkTarget) + os.MkdirAll(filepath.Dir(target), 0755) + if data, err := os.ReadFile(linkTarget); err == nil { + os.WriteFile(target, data, 0644) + } + } else if data, err := os.ReadFile(resolvPath); err == nil { + target := filepath.Join(base, resolvPath) + os.MkdirAll(filepath.Dir(target), 0755) + os.WriteFile(target, data, 0644) + } + + if err := retryMount("/", rootfs, "", syscall.MS_BIND|syscall.MS_RDONLY, "", maxRetries); err != nil { + return fmt.Errorf("mount rootfs: %w", err) + } + tracker.Add(rootfs) + + if needDev { + devPath := filepath.Join(rootfs, "dev") + if err := retryMount("/dev", devPath, "", syscall.MS_BIND, "", maxRetries); err != nil { + return fmt.Errorf("mount /dev bind: %w", err) + } + tracker.Add(devPath) + + tmpPath := filepath.Join(rootfs, "tmp") + if err := retryMount("tmpfs", tmpPath, "tmpfs", 0, "", maxRetries); err != nil { + return fmt.Errorf("mount tmpfs: %w", err) + } + tracker.Add(tmpPath) + } + + runPath := filepath.Join(rootfs, "run") + if err := retryMount(filepath.Join(base, "run"), runPath, "", syscall.MS_BIND, "", maxRetries); err != nil { + return fmt.Errorf("mount run: %w", err) + } + tracker.Add(runPath) + + varPath := filepath.Join(rootfs, "var") + if err := retryMount(filepath.Join(base, "var"), varPath, "", syscall.MS_BIND, "", maxRetries); err != nil { + return fmt.Errorf("mount var: %w", err) + } + tracker.Add(varPath) + + cachePath := filepath.Join(rootfs, "var", "cache", "zypp") + if err := retryMount("/var/cache/zypp", cachePath, "", syscall.MS_BIND, "", maxRetries); err != nil { + debugf("Warning: failed to mount cache (non-critical): %v", err) + } else { + tracker.Add(cachePath) + } + + certPath := filepath.Join(rootfs, "var", "lib", "ca-certificates") + if err := retryMount("/var/lib/ca-certificates", certPath, "", syscall.MS_BIND|syscall.MS_RDONLY, "", maxRetries); err != nil { + debugf("Warning: failed to mount ca-certs (non-critical): %v", err) + } else { + tracker.Add(certPath) + } + + return nil +} + +// unmount + +func unmountAll(tmpDir string, tracker *MountTracker) error { + if tmpDir == "" { + return nil + } + + mounts := tracker.GetAll() + var errs []string + + for _, mountPath := range mounts { + cmd := exec.Command("findmnt", "-n", "-o", "TARGET", mountPath) + if err := cmd.Run(); err != nil { + continue + } + + if err := retryUnmount(mountPath, syscall.MNT_DETACH, maxRetries); err != nil { + debugf("Failed to unmount %s: %v", mountPath, err) + if err := syscall.Unmount(mountPath, syscall.MNT_DETACH|syscall.MNT_FORCE); err != nil { + errs = append(errs, fmt.Sprintf("%s: %v", mountPath, err)) + } + } + } + + if len(errs) > 0 { + warningf("Some unmounts failed: %s", strings.Join(errs, "; ")) + } + return nil +} + +// cleanup + +func vlcroCleanup(tmpDir string, tracker *MountTracker) { + releaseZyppLock() + + if tmpDir == "" { + return + } + + if _, err := os.Stat(tmpDir); os.IsNotExist(err) { + return + } + + absPath, err := filepath.Abs(tmpDir) + if err != nil || !strings.HasPrefix(absPath, "/tmp/vlcro_") { + errorf("Refusing to cleanup unsafe temp directory: %s", tmpDir) + return + } + + infof("%s", colorText(colorInfo, "Cleaning up temp mounts...")) + unmountAll(tmpDir, tracker) + + time.Sleep(500 * time.Millisecond) + + for attempt := 0; attempt < 5; attempt++ { + if !hasActiveMounts(tmpDir) { + break + } + warningf("Active mounts still detected under %s, retrying unmount...", tmpDir) + unmountAll(tmpDir, tracker) + time.Sleep(500 * time.Millisecond) + } + + if hasActiveMounts(tmpDir) { + warningf("Could not unmount all filesystems under %s. Skipping temp directory removal.", tmpDir) + return + } + + infof("%s", colorText(colorInfo, "Cleaning up temp directory...")) + for attempt := 0; attempt < 3; attempt++ { + if err := os.RemoveAll(tmpDir); err == nil { + return + } + time.Sleep(500 * time.Millisecond) + } + + warningf("Could not fully remove temp directory %s (will clean up at next boot)", tmpDir) +} + +// uuid + +func randomUUID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("fallback-%d", time.Now().UnixNano()) + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7143027 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module vlcro + +go 1.26.2 diff --git a/main.go b/main.go new file mode 100644 index 0000000..c9f2946 --- /dev/null +++ b/main.go @@ -0,0 +1,355 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "strconv" + "strings" + "syscall" +) + +const ( + vlcroVersion = "0.1.0" + zypperPidFile = "/run/zypp.pid" + maxRetries = 3 + retryDelay = 100_000_000 // 100ms as int64 nanoseconds +) + +var cfg *config + +type config struct { + version bool + noConfirm bool + jobs int + debug bool + noColor bool + command string + force bool + downloadOnly bool + packages []string + txInfo []packageInfo // parsed packages from dry-run +} + +// CLI + +func printHelp() { + // Ensure cfg is available for colorStart + if cfg == nil { + cfg = &config{jobs: 10} + } + + // highlight colors + hi := colorStart(colorInfo) // section headers / "vlcro" + hc := colorStart(colorWarning) // usage keyword + he := colorStart(colorError) // options + hd := colorStart(colorInput) // command bold-ish + d := "\033[0m" + if !isTerminal || cfg.noColor { + d = "" + } + + usage := fmt.Sprintf(`%susage%s: vlcro [-h] [-v] [-y] [-j N] [--debug] [--no-color] +%s {refresh,ref,dist-upgrade,dup,install,in,install-new-recommends,inr} ... + +%s%s%s (%sv%s%s) makes zypper faster by running slow operations in parallel. + +%soptions%s: + %s-h, --help%s show this help message and exit + %s-v, -V, --version%s print version number and exit + %s-y, --no-confirm%s automatic yes to prompts, run non-interactively + %s-j, --jobs%s number of parallel operations (1-64, default: 10) + %s--debug%s enable debug output + %s--no-color%s disable color output + +%scommands%s: + %srefresh%s (%sref%s) refresh all enabled repos + %s-f, --force%s force a complete refresh + + %sdist-upgrade%s (%sdup%s) perform distribution upgrade + %s-d, --download-only%s download packages without installing + + %sinstall%s (%sin%s) install one or more packages + %s-d, --download-only%s download packages without installing + %s%s package name(s) to install + + %sinstall-new-recommends%s (%sinr%s) + install new packages recommended by already installed ones + %s-d, --download-only%s download packages without installing +`, + hc, d, hc, + hi, "vlcro", d, hi, vlcroVersion, d, + hi, d, + he, d, he, d, he, d, he, d, he, d, he, d, + hi, d, + hd, d, hd, d, he, d, + hd, d, hd, d, he, d, + hd, d, hd, d, he, d, hd, d, + hd, d, hd, d, he, d) + + fmt.Print(usage) +} + +func parseArgs() *config { + c, err := parseArgsInto(os.Args[1:]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return c +} + +func parseJobs(c *config, value string) error { + n, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("Invalid jobs value: %s", value) + } + if n < 1 || n > 64 { + return fmt.Errorf("Invalid jobs value: %d. Must be between 1 and 64", n) + } + c.jobs = n + return nil +} + +func isRefreshCommand(cmd string) bool { + return cmd == "refresh" || cmd == "ref" +} + +func isInstallCommand(cmd string) bool { + return cmd == "install" || cmd == "in" +} + +func isKnownCommand(cmd string) bool { + switch cmd { + case "refresh", "ref", "dist-upgrade", "dup", "install", "in", + "install-new-recommends", "inr": + return true + } + return false +} + +func parseArgsInto(args []string) (*config, error) { + c := &config{jobs: 10} + i := 0 + cmdFound := false + packagesOnly := false + + for i < len(args) { + arg := args[i] + consumedNext := false + + switch { + case arg == "-h" || arg == "--help": + printHelp() + os.Exit(0) + case arg == "-v" || arg == "-V" || arg == "--version": + c.version = true + i++ + continue + case arg == "-y" || arg == "--no-confirm": + c.noConfirm = true + i++ + continue + case arg == "--debug": + c.debug = true + i++ + continue + case arg == "--no-color": + c.noColor = true + i++ + continue + case arg == "-f" || arg == "--force": + c.force = true + i++ + continue + case arg == "-d" || arg == "--download-only": + c.downloadOnly = true + i++ + continue + case strings.HasPrefix(arg, "--"): + name, value, hasValue := strings.Cut(arg, "=") + if name == "--jobs" { + if !hasValue { + if i+1 >= len(args) { + return nil, fmt.Errorf("Error: --jobs requires an argument") + } + value = args[i+1] + i += 2 + } else { + i++ + } + if err := parseJobs(c, value); err != nil { + return nil, err + } + continue + } + return nil, fmt.Errorf("Unknown option: %s", arg) + case strings.HasPrefix(arg, "-") && len(arg) > 1: + body := arg[1:] + for k := 0; k < len(body); k++ { + switch body[k] { + case 'h': + printHelp() + os.Exit(0) + case 'v', 'V': + c.version = true + case 'y': + c.noConfirm = true + case 'f': + c.force = true + case 'd': + c.downloadOnly = true + case 'j': + rest := body[k+1:] + if strings.HasPrefix(rest, "=") { + rest = rest[1:] + } + if rest == "" { + if i+1 >= len(args) { + return nil, fmt.Errorf("Error: --jobs requires an argument") + } + rest = args[i+1] + consumedNext = true + } + if err := parseJobs(c, rest); err != nil { + return nil, err + } + k = len(body) + default: + return nil, fmt.Errorf("Unknown option: -%s", string(body[k])) + } + } + if !consumedNext { + i++ + } else { + i += 2 + } + continue + default: + if !cmdFound { + c.command = arg + cmdFound = true + packagesOnly = isInstallCommand(arg) + i++ + continue + } + if !packagesOnly { + return nil, fmt.Errorf("Unexpected argument: %s", arg) + } + c.packages = append(c.packages, arg) + i++ + continue + } + } + + if !cmdFound { + return c, nil + } + + if !isKnownCommand(c.command) { + return nil, fmt.Errorf("Unknown command: %s", c.command) + } + if c.force && !isRefreshCommand(c.command) { + return nil, fmt.Errorf("Option --force is only valid with the refresh command") + } + if c.downloadOnly && isRefreshCommand(c.command) { + return nil, fmt.Errorf("Option --download-only is not valid with the refresh command") + } + + return c, nil +} + +// dependency check (exec.LookPath) + +func checkDependencies() { + required := []string{"zypper", "findmnt", "bash"} + missing := []string{} + for _, program := range required { + if _, err := exec.LookPath(program); err != nil { + missing = append(missing, program) + } + } + if len(missing) > 0 { + msg := fmt.Sprintf("Bailing out, missing required dependencies: %s\n"+ + "The following are required for vlcro to function: %s", + strings.Join(missing, ", "), + strings.Join(required, ", ")) + errorf("%s", colorText(colorError, msg)) + os.Exit(4) + } +} + +// main + +func main() { + cfg = parseArgs() + + if cfg.version { + fmt.Printf("vlcro v%s\n", vlcroVersion) + return + } + if cfg.command == "" { + printHelp() + return + } + + if os.Getuid() != 0 { + errorf("%s", colorText(colorError, "Bailing out, program must be run with root privileges")) + os.Exit(3) + } + + checkDependencies() + + if pid, program := checkZypperRunning(); pid != 0 { + msg := fmt.Sprintf("zypper is already invoked by the application with pid %d (%s).\nClose this application before trying again.", pid, program) + errorf("%s", colorText(colorError, msg)) + os.Exit(5) + } + + tmpDir, err := os.MkdirTemp("/tmp", "vlcro_") + if err != nil { + errorf("%s", colorText(colorError, fmt.Sprintf("Failed to create temp directory: %v", err))) + os.Exit(1) + } + + var exitCode int + defer func() { + os.Exit(exitCode) + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigChan + infof("%s", colorText(colorInfo, "Cancelling pending tasks...")) + os.Stdin.Close() + cancel() + }() + + switch cfg.command { + case "refresh", "ref": + if err := handleRefresh(ctx, tmpDir); err != nil { + exitCode = 1 + } + case "dist-upgrade", "dup": + if err := handleDistUpgrade(ctx, tmpDir); err != nil { + exitCode = 1 + } + case "install", "in": + if err := handleInstall(ctx, tmpDir); err != nil { + exitCode = 1 + } + case "install-new-recommends", "inr": + if err := handleINR(ctx, tmpDir); err != nil { + exitCode = 1 + } + default: + printHelp() + exitCode = 1 + } +} diff --git a/ui.go b/ui.go new file mode 100644 index 0000000..11e2c02 --- /dev/null +++ b/ui.go @@ -0,0 +1,453 @@ +package main + +import ( + "fmt" + "os" + "strings" + "sync" + "syscall" + "time" + "unsafe" +) + +// color system + +type colorType string + +const ( + colorInput colorType = "input" + colorSuccess colorType = "success" + colorInfo colorType = "info" + colorWarning colorType = "warning" + colorError colorType = "error" + colorException colorType = "exception" +) + +var colorCodes = map[colorType][3]int{ + colorInput: {0, 200, 200}, + colorSuccess: {80, 220, 80}, + colorInfo: {255, 175, 50}, + colorWarning: {255, 220, 60}, + colorError: {255, 90, 90}, + colorException: {220, 120, 255}, +} + +var isTerminal = func() bool { + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +}() + +func colorText(textType colorType, text string) string { + if !isTerminal || (cfg != nil && cfg.noColor) { + return text + } + c, ok := colorCodes[textType] + if !ok { + return text + } + return fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[0m", c[0], c[1], c[2], text) +} + +func colorStart(textType colorType) string { + if !isTerminal || (cfg != nil && cfg.noColor) { + return "" + } + c, ok := colorCodes[textType] + if !ok { + return "" + } + return fmt.Sprintf("\033[38;2;%d;%d;%dm", c[0], c[1], c[2]) +} + +// terminal width + +func getTerminalWidth() int { + if !isTerminal { + return 80 + } + type winsize struct { + Row uint16 + Col uint16 + Xpixel uint16 + Ypixel uint16 + } + ws := winsize{} + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(os.Stdout.Fd()), uintptr(0x5413), uintptr(unsafe.Pointer(&ws))) + if errno != 0 || ws.Col == 0 { + return 80 + } + return int(ws.Col) +} + +// output serialization + +var outputMu sync.Mutex + +// logging + +func logMsg(level, format string, args ...interface{}) { + outputMu.Lock() + defer outputMu.Unlock() + + _clearProgressLine() + + msg := fmt.Sprintf(format, args...) + ts := time.Now().Format("15:04:05") + fmt.Fprintf(os.Stdout, "%s %s %s\n", ts, colorText(colorType(strings.ToLower(level)), fmt.Sprintf("%-7s", level)), msg) +} + +func infof(format string, args ...interface{}) { logMsg("INFO", format, args...) } +func errorf(format string, args ...interface{}) { logMsg("ERROR", format, args...) } +func warningf(format string, args ...interface{}) { + logMsg("WARNING", format, args...) +} +func debugf(format string, args ...interface{}) { + if cfg.debug { + logMsg("DEBUG", format, args...) + } +} + +// status messages + +func statusMsg(text string) { + outputMu.Lock() + defer outputMu.Unlock() + _clearProgressLine() + fmt.Printf("%s... ", text) + fmt.Println(colorText(colorSuccess, "Done")) +} + +// download line + +func downloadLine(counter int, name string, size float64) { + outputMu.Lock() + defer outputMu.Unlock() + _clearProgressLine() + if size > 0 { + fmt.Printf("Get:%-3d %s [%s]\n", counter, colorText(colorInfo, name), formatSize(size)) + } else { + fmt.Printf("Get:%-3d %s\n", counter, colorText(colorInfo, name)) + } +} + +// fetch summary + +func fetchSummary(totalBytes float64, elapsed time.Duration) { + outputMu.Lock() + defer outputMu.Unlock() + _clearProgressLine() + speed := float64(0) + if elapsed.Seconds() > 0 { + speed = totalBytes / elapsed.Seconds() + } + fmt.Printf("Fetched %s in %s", formatSize(totalBytes), formatDuration(elapsed)) + if speed > 0 { + fmt.Printf(" (%s/s)", formatSpeed(speed)) + } + fmt.Println() +} + +func formatSpeed(bytesPerSec float64) string { + const KB = 1000 + const MB = 1000 * KB + switch { + case bytesPerSec >= MB: + return fmt.Sprintf("%.2f MB/s", bytesPerSec/MB) + case bytesPerSec >= KB: + return fmt.Sprintf("%.2f kB/s", bytesPerSec/KB) + default: + return fmt.Sprintf("%.0f B/s", bytesPerSec) + } +} + +// size formatting + +func formatSize(bytes float64) string { + const ( + KiB = 1000 + MiB = 1000 * KiB + GiB = 1000 * MiB + ) + switch { + case bytes >= GiB: + return fmt.Sprintf("%.2f GiB", bytes/GiB) + case bytes >= MiB: + return fmt.Sprintf("%.2f MiB", bytes/MiB) + case bytes >= KiB: + return fmt.Sprintf("%.2f KiB", bytes/KiB) + default: + return fmt.Sprintf("%.0f B", bytes) + } +} + +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d >= time.Hour { + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + if h >= 100 { + return fmt.Sprintf("%dh%02dm", h, m) + } + return fmt.Sprintf("%dh%02dm%02ds", h, m, s) + } + return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) +} + +// progress tracking + +type progressState struct { + mu sync.Mutex + total int + completed int + startTime time.Time + drawn bool + spinnerIdx int + stop chan struct{} +} + +var prog *progressState + +var spinnerChars = [4]byte{'|', '/', '-', '\\'} + +func newProgressState(total int) *progressState { + return &progressState{ + total: total, + startTime: time.Now(), + stop: make(chan struct{}), + } +} + +func (p *progressState) increment() { + p.mu.Lock() + defer p.mu.Unlock() + if p.completed < p.total { + p.completed++ + } +} + +func (p *progressState) startTicker() { + go func() { + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-p.stop: + return + case <-ticker.C: + outputMu.Lock() + _drawProgressLine() + outputMu.Unlock() + } + } + }() +} + +func (p *progressState) stopTicker() { + close(p.stop) +} + +func _clearProgressLine() { + if prog == nil || !prog.drawn || !isTerminal || cfg.noColor { + return + } + fmt.Print("\r\033[K") + prog.drawn = false +} + +func _drawProgressLine() { + if prog == nil || !isTerminal || cfg.noColor || prog.total == 0 { + return + } + + prog.mu.Lock() + completed := prog.completed + total := prog.total + startTime := prog.startTime + prog.mu.Unlock() + + // Refresh terminal width each frame so a live resize never wraps the line. + width := getTerminalWidth() + + prog.spinnerIdx++ + + fmt.Print("\r\033[K" + buildProgressLine(width, completed, total, time.Since(startTime), prog.spinnerIdx)) + prog.drawn = true +} + +func buildProgressLine(width, completed, total int, elapsed time.Duration, spinnerIdx int) string { + percent := 0 + if total > 0 { + percent = int(float64(completed) / float64(total) * 100) + } + + // overall bar: "Progress: [ NN%] [####.....]" gracefully stolen from APT + prefix := fmt.Sprintf("Progress: [%3d%%] [%c ", percent, spinnerChars[spinnerIdx%len(spinnerChars)]) + + suffix := fmt.Sprintf("] %d/%d", completed, total) + + if completed > 0 && elapsed.Seconds() > 1 { + rate := float64(completed) / elapsed.Seconds() + remaining := float64(total-completed) / rate + suffix += fmt.Sprintf(" %s", formatDuration(time.Duration(remaining*float64(time.Second)))) + } + + barW := width - len(prefix) - len(suffix) + if barW < 1 { + line := prefix + suffix + if len(line) > width { + line = line[:width] + } + return line + } + + filled := int(float64(barW) * float64(percent) / 100) + if filled > barW { + filled = barW + } + bar := strings.Repeat("#", filled) + strings.Repeat(".", barW-filled) + + return prefix + colorText(colorInfo, bar) + suffix +} + +func (p *progressState) finish() { + p.stopTicker() + outputMu.Lock() + defer outputMu.Unlock() + _clearProgressLine() + fmt.Println() +} + +// transaction summary + +type packageInfo struct { + Name string + Edition string + Arch string + Action string + Size float64 +} + +func (p packageInfo) String() string { + return fmt.Sprintf("%s-%s.%s", p.Name, p.Edition, p.Arch) +} + +func printTransactionSummary(installs, removes []packageInfo, downloadSize, diffBytes float64, numPkgs int) { + outputMu.Lock() + defer outputMu.Unlock() + _clearProgressLine() + + fmt.Println() + + // Count upgrades vs new installs + nUpgrades := 0 + nInstalls := 0 + for _, pkg := range installs { + if pkg.Edition != "" && pkg.Arch != "" { + nUpgrades++ + } else { + nInstalls++ + } + } + nRemoves := len(removes) + + // Packages to download (upgrades) + if nUpgrades > 0 { + names := make([]string, nUpgrades) + for i, pkg := range installs { + if i >= nUpgrades { + break + } + names[i] = colorText(colorInfo, pkg.String()) + } + fmt.Printf("The following packages will be upgraded:\n") + printWrappedNames(names, getTerminalWidth(), " ") + fmt.Println() + } + + // New installs + if nInstalls > 0 { + names := make([]string, nInstalls) + for i := nUpgrades; i < len(installs) && i-nUpgrades < nInstalls; i++ { + names[i-nUpgrades] = colorText(colorInfo, installs[i].String()) + } + fmt.Printf("The following NEW packages will be installed:\n") + printWrappedNames(names, getTerminalWidth(), " ") + fmt.Println() + } + + // Removed packages + if nRemoves > 0 { + names := make([]string, nRemoves) + for i, pkg := range removes { + names[i] = colorText(colorError, pkg.String()) + } + fmt.Printf("The following packages will be REMOVED:\n") + printWrappedNames(names, getTerminalWidth(), " ") + fmt.Println() + } + + // Summary line + parts := []string{} + if nUpgrades > 0 { + parts = append(parts, fmt.Sprintf("%d upgraded", nUpgrades)) + } + if nInstalls > 0 { + parts = append(parts, fmt.Sprintf("%d newly installed", nInstalls)) + } + if nRemoves > 0 { + parts = append(parts, fmt.Sprintf("%d to remove", nRemoves)) + } + parts = append(parts, fmt.Sprintf("%d not upgraded", 0)) + fmt.Printf("%s.\n", strings.Join(parts, ", ")) + + // Download and space info + if downloadSize > 0 { + fmt.Printf("Need to get %s of archives.\n", formatSize(downloadSize)) + } + + spacePrefix := "" + if diffBytes >= 0 { + spacePrefix = "+" + } + fmt.Printf("After this operation, %s%s of additional disk space will be used.\n", + spacePrefix, formatSize(diffBytes)) +} + +// printWrappedNames prints package names wrapped to fit within termWidth. +func printWrappedNames(names []string, termWidth int, indent string) { + lineLen := len(indent) + first := true + for _, name := range names { + nameLen := len(name) + if !first && lineLen+1+nameLen > termWidth-2 { + fmt.Println() + lineLen = len(indent) + first = true + } + if !first { + fmt.Print(" ") + lineLen++ + } + fmt.Printf("%s%s", indent, name) + if first { + lineLen += nameLen + first = false + } else { + lineLen += nameLen + } + } + fmt.Println() +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/zypper.go b/zypper.go new file mode 100644 index 0000000..36ff478 --- /dev/null +++ b/zypper.go @@ -0,0 +1,856 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/xml" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "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 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 +}