first commit
This commit is contained in:
commit
c1eb7b0e94
8 changed files with 2304 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
vlcro
|
||||
339
LICENSE
Normal file
339
LICENSE
Normal file
|
|
@ -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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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.
|
||||
|
||||
<signature of Ty Coon>, 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.
|
||||
25
README.md
Normal file
25
README.md
Normal file
|
|
@ -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 <command> [options]
|
||||
```
|
||||
|
||||
Downloads are handled in parallel; the actual install is handed back to zypper.
|
||||
272
chroot.go
Normal file
272
chroot.go
Normal file
|
|
@ -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])
|
||||
}
|
||||
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module vlcro
|
||||
|
||||
go 1.26.2
|
||||
355
main.go
Normal file
355
main.go
Normal file
|
|
@ -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<package>%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
|
||||
}
|
||||
}
|
||||
453
ui.go
Normal file
453
ui.go
Normal file
|
|
@ -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
|
||||
}
|
||||
856
zypper.go
Normal file
856
zypper.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue