Portable install: the binary installs itself; static builds on five architectures; root via sudo, doas or uid 0; every package manager; older systemd; no lsblk
- hamlab install / uninstall (internal/setup): systemd or OpenRC, elevates through sudo or doas, runs as the invoking user, sets or generates the password, names only journal groups that exist, waits for /healthz, reports an active firewalld/ufw. --purge and the data chown never touch a directory that isn't Ham Lab's alone. - install.sh: curl-able bootstrap (arch detection, SHA256SUMS, offline beside the binaries, noexec /tmp fallback). make release builds dist/ for amd64, arm64, arm (ARMv6), 386, riscv64. Every build is CGO_ENABLED=0; the native one used to link glibc. - internal/priv replaces every literal `sudo -n`: uid 0, sudo or doas. - Updates acts on dnf, zypper, pacman and apk as well as apt; dnf shows installed versions. - Services falls back to the plain systemctl table before systemd 246 and says so on hosts without systemd. Disks falls back to /sys/block without lsblk. - Listener binds with IP_FREEBIND, so a LAN address that isn't up at boot is no failure. - Self-update: the new build must pass `check` against the live config, is put in place by atomic rename, and the daemon re-execs itself (same under any supervisor). - State and config writes are fsynced; a corrupt state.json is set aside, not fatal. - Terminal shutdown no longer empties the saved list, so names come back after a restart by design rather than by winning a race. - Config default is ~/.config/hamlab; an existing ~/apps/hamlab is still found. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
91b7f4f009
commit
488d929733
+174
-34
@@ -2,8 +2,9 @@
|
||||
// was last refreshed, whether a reboot is pending, and the refresh/upgrade actions,
|
||||
// which run as background tasks so their output can be followed live.
|
||||
//
|
||||
// apt is fully supported. dnf and pacman get a read-only list so the tab still says
|
||||
// something useful on those hosts; upgrades there are a later addition.
|
||||
// Supported: apt (Debian, Ubuntu), dnf (Fedora, RHEL family), zypper (openSUSE, SLES),
|
||||
// pacman (Arch) and apk (Alpine). All of them list, refresh, upgrade and install; pacman
|
||||
// upgrades everything or nothing, because Arch doesn't support partial upgrades.
|
||||
package updates
|
||||
|
||||
import (
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
"hamlab/internal/tasks"
|
||||
)
|
||||
|
||||
@@ -31,8 +33,9 @@ type Package struct {
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Manager string `json:"manager"` // apt | dnf | pacman | ""
|
||||
Manager string `json:"manager"` // apt | dnf | zypper | pacman | apk | ""
|
||||
CanUpgrade bool `json:"can_upgrade"`
|
||||
Selective bool `json:"selective"` // individual packages can be upgraded
|
||||
Packages []Package `json:"packages"`
|
||||
SecurityCount int `json:"security_count"`
|
||||
LastRefresh int64 `json:"last_refresh,omitempty"` // unix ms
|
||||
@@ -52,7 +55,7 @@ type Manager struct {
|
||||
|
||||
func New(sudo bool, tm *tasks.Manager) *Manager {
|
||||
m := &Manager{sudo: sudo, tasks: tm}
|
||||
for _, pm := range []string{"apt-get", "dnf", "pacman"} {
|
||||
for _, pm := range []string{"apt-get", "dnf", "zypper", "pacman", "apk"} {
|
||||
if _, err := exec.LookPath(pm); err == nil {
|
||||
m.pm = strings.TrimSuffix(pm, "-get")
|
||||
break
|
||||
@@ -63,7 +66,7 @@ func New(sudo bool, tm *tasks.Manager) *Manager {
|
||||
|
||||
func (m *Manager) sudoArgs(args ...string) []string {
|
||||
if m.sudo {
|
||||
return append([]string{"sudo", "-n"}, args...)
|
||||
return priv.Wrap(args...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
@@ -145,9 +148,10 @@ func (m *Manager) Status(ctx context.Context) Status {
|
||||
if t, ok := m.tasks.RunningOf("updates"); ok {
|
||||
s.RunningTask = t.ID
|
||||
}
|
||||
s.CanUpgrade = m.sudo && m.pm != ""
|
||||
s.Selective = m.pm != "pacman"
|
||||
switch m.pm {
|
||||
case "apt":
|
||||
s.CanUpgrade = m.sudo
|
||||
s.Packages = aptUpgradable(ctx)
|
||||
s.LastRefresh = mtimeMs("/var/lib/apt/periodic/update-success-stamp")
|
||||
if s.LastRefresh == 0 {
|
||||
@@ -162,9 +166,18 @@ func (m *Manager) Status(ctx context.Context) Status {
|
||||
s.Packages = dnfUpgradable(ctx)
|
||||
s.LastRefresh = newestMtime("/var/cache/libdnf5/*/repodata", "/var/cache/dnf/*/repodata", "/var/cache/libdnf5", "/var/cache/dnf")
|
||||
s.LastUpgrade = dnfLastUpgrade(ctx)
|
||||
case "zypper":
|
||||
s.Packages = zypperUpgradable(ctx)
|
||||
s.LastRefresh = newestMtime("/var/cache/zypp/raw/*", "/var/cache/zypp/raw")
|
||||
s.LastUpgrade = mtimeMs("/var/log/zypp/history")
|
||||
case "pacman":
|
||||
s.Packages = pacmanUpgradable(ctx)
|
||||
s.LastRefresh = mtimeMs("/var/lib/pacman/sync")
|
||||
s.LastRefresh = newestMtime("/var/lib/pacman/sync/*.db", "/var/lib/pacman/sync")
|
||||
s.LastUpgrade = pacmanLastUpgrade()
|
||||
case "apk":
|
||||
s.Packages = apkUpgradable(ctx)
|
||||
s.LastRefresh = newestMtime("/var/cache/apk/APKINDEX.*", "/var/lib/apk/APKINDEX.*")
|
||||
s.LastUpgrade = mtimeMs("/lib/apk/db/installed")
|
||||
}
|
||||
for _, p := range s.Packages {
|
||||
if p.Security {
|
||||
@@ -289,7 +302,7 @@ func aptAutoUpgrades(ctx context.Context) bool {
|
||||
return strings.Contains(text, `"1"`)
|
||||
}
|
||||
|
||||
// ---- dnf / pacman (read-only) ----
|
||||
// ---- dnf / zypper / pacman / apk ----
|
||||
|
||||
func dnfUpgradable(ctx context.Context) []Package {
|
||||
text, _ := out(ctx, "dnf", "-q", "check-update") // exit 100 when updates exist; output still parses
|
||||
@@ -305,6 +318,19 @@ func dnfUpgradable(ctx context.Context) []Package {
|
||||
if pkgs == nil {
|
||||
return []Package{}
|
||||
}
|
||||
// check-update only names the candidate; one rpm query fills in what's installed.
|
||||
args := append([]string{"rpm", "-q", "--qf", `%{NAME} %|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\n`}, names(pkgs)...)
|
||||
if text, _ := out(ctx, args...); text != "" { // exits non-zero when any name isn't installed (new dependencies)
|
||||
cur := map[string]string{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if f := strings.Fields(line); len(f) == 2 {
|
||||
cur[f[0]] = f[1] // several installed (kernels): the last listed is the newest
|
||||
}
|
||||
}
|
||||
for i := range pkgs {
|
||||
pkgs[i].Current = cur[pkgs[i].Name]
|
||||
}
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
@@ -323,44 +349,148 @@ func pacmanUpgradable(ctx context.Context) []Package {
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// ---- actions (apt only) ----
|
||||
// zypperUpgradable parses the table `zypper list-updates` prints:
|
||||
// S | Repository | Name | Current Version | Available Version | Arch
|
||||
func zypperUpgradable(ctx context.Context) []Package {
|
||||
text, _ := out(ctx, "zypper", "--non-interactive", "--quiet", "list-updates")
|
||||
pkgs := []Package{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Split(line, "|")
|
||||
if len(f) < 5 || strings.TrimSpace(f[0]) != "v" {
|
||||
continue
|
||||
}
|
||||
repo := strings.TrimSpace(f[1])
|
||||
pkgs = append(pkgs, Package{Name: strings.TrimSpace(f[2]), Current: strings.TrimSpace(f[3]), Candidate: strings.TrimSpace(f[4]),
|
||||
Suite: repo, Security: strings.Contains(strings.ToLower(repo), "security")})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
var pkgNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]{1,127}$`)
|
||||
var apkVerRe = regexp.MustCompile(`^(.+)-([^-]+-r\d+)\s+<\s+(\S+)`)
|
||||
|
||||
func (m *Manager) start(title string, args ...string) (*tasks.Task, error) {
|
||||
if m.pm != "apt" {
|
||||
return nil, errors.New("upgrades are only supported for apt so far")
|
||||
// apkUpgradable parses `apk version -l '<'`: "musl-1.2.4-r2 < 1.2.5-r0".
|
||||
func apkUpgradable(ctx context.Context) []Package {
|
||||
text, _ := out(ctx, "apk", "version", "-l", "<")
|
||||
pkgs := []Package{}
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if m := apkVerRe.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
|
||||
pkgs = append(pkgs, Package{Name: m[1], Current: m[2], Candidate: m[3]})
|
||||
}
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// pacmanLastUpgrade: the timestamp of the last "upgraded" line in pacman.log.
|
||||
func pacmanLastUpgrade() int64 {
|
||||
f, err := os.Open("/var/log/pacman.log")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
last := ""
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
|
||||
for sc.Scan() {
|
||||
if l := sc.Text(); strings.Contains(l, "[ALPM] upgraded ") && strings.HasPrefix(l, "[") {
|
||||
if end := strings.IndexByte(l, ']'); end > 1 {
|
||||
last = l[1:end]
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, layout := range []string{"2006-01-02T15:04:05-0700", "2006-01-02 15:04"} {
|
||||
if t, err := time.ParseInLocation(layout, last, time.Local); err == nil {
|
||||
return t.UnixMilli()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- actions ----
|
||||
|
||||
// Never starts with '-', so a name can't be read as an option by any of the managers.
|
||||
var pkgNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9+._-]{0,127}$`)
|
||||
|
||||
func checkNames(pkgs []string) error {
|
||||
for _, p := range pkgs {
|
||||
if !pkgNameRe.MatchString(p) {
|
||||
return errors.New("bad package name: " + p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) start(args ...string) (*tasks.Task, error) {
|
||||
if m.pm == "" {
|
||||
return nil, errors.New("no supported package manager on this host")
|
||||
}
|
||||
if !m.sudo {
|
||||
return nil, errors.New("needs passwordless sudo")
|
||||
return nil, errors.New("needs root: run Ham Lab as root, or give its user passwordless sudo or doas")
|
||||
}
|
||||
if t, ok := m.tasks.RunningOf("updates"); ok {
|
||||
return nil, errors.New("another package task is running: " + t.Title)
|
||||
}
|
||||
full := append([]string{"sudo", "-n", "env", "DEBIAN_FRONTEND=noninteractive", "APT_LISTCHANGES_FRONTEND=none", "LC_ALL=C"}, args...)
|
||||
full := priv.Wrap(append([]string{"env", "DEBIAN_FRONTEND=noninteractive", "APT_LISTCHANGES_FRONTEND=none", "LC_ALL=C"}, args...)...)
|
||||
cmd := exec.Command(full[0], full[1:]...)
|
||||
return m.tasks.Start("updates", title, cmd)
|
||||
return m.tasks.Start("updates", strings.Join(args, " "), cmd)
|
||||
}
|
||||
|
||||
func (m *Manager) Refresh() (*tasks.Task, error) {
|
||||
return m.start("apt-get update", "apt-get", "update")
|
||||
switch m.pm {
|
||||
case "apt":
|
||||
return m.start("apt-get", "update")
|
||||
case "dnf":
|
||||
return m.start("dnf", "-y", "makecache", "--refresh")
|
||||
case "zypper":
|
||||
return m.start("zypper", "--non-interactive", "refresh")
|
||||
case "pacman":
|
||||
return m.start("pacman", "-Sy", "--noconfirm")
|
||||
case "apk":
|
||||
return m.start("apk", "update")
|
||||
}
|
||||
return m.start()
|
||||
}
|
||||
|
||||
// Upgrade runs a full dist-upgrade, or only the named packages. Config-file prompts are
|
||||
// answered "keep the current one" so the run can never block on a question.
|
||||
// rolling reports a distro whose supported upgrade path is a full distribution upgrade.
|
||||
func rolling() bool {
|
||||
b, _ := os.ReadFile("/etc/os-release")
|
||||
return strings.Contains(string(b), "tumbleweed") || strings.Contains(string(b), "microos")
|
||||
}
|
||||
|
||||
// Upgrade upgrades everything, or only the named packages. Nothing here can block on a
|
||||
// question: apt keeps the current config file on a conflict, the rest run non-interactive.
|
||||
func (m *Manager) Upgrade(pkgs []string) (*tasks.Task, error) {
|
||||
base := []string{"apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"}
|
||||
if len(pkgs) == 0 {
|
||||
return m.start("apt-get dist-upgrade", append(base, "dist-upgrade")...)
|
||||
if err := checkNames(pkgs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range pkgs {
|
||||
if !pkgNameRe.MatchString(p) {
|
||||
return nil, errors.New("bad package name: " + p)
|
||||
all := len(pkgs) == 0
|
||||
var args []string
|
||||
switch m.pm {
|
||||
case "apt":
|
||||
args = []string{"apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"}
|
||||
if all {
|
||||
args = append(args, "dist-upgrade")
|
||||
} else {
|
||||
args = append(args, "install", "--only-upgrade")
|
||||
}
|
||||
case "dnf":
|
||||
args = []string{"dnf", "-y", "upgrade"}
|
||||
case "zypper":
|
||||
args = []string{"zypper", "--non-interactive", "update"}
|
||||
if all && rolling() {
|
||||
args = []string{"zypper", "--non-interactive", "dist-upgrade"}
|
||||
}
|
||||
case "pacman":
|
||||
if !all {
|
||||
return nil, errors.New("pacman upgrades everything or nothing: Arch doesn't support partial upgrades")
|
||||
}
|
||||
args = []string{"pacman", "-Syu", "--noconfirm"}
|
||||
case "apk":
|
||||
args = []string{"apk", "upgrade"}
|
||||
if all {
|
||||
args = []string{"apk", "upgrade", "--update-cache"}
|
||||
}
|
||||
}
|
||||
args := append(base, "install", "--only-upgrade")
|
||||
args = append(args, pkgs...)
|
||||
return m.start("apt-get upgrade "+strings.Join(pkgs, " "), args...)
|
||||
return m.start(append(args, pkgs...)...)
|
||||
}
|
||||
|
||||
// Install installs packages (used by other tabs for optional tooling like smartmontools).
|
||||
@@ -368,13 +498,23 @@ func (m *Manager) Install(pkgs []string) (*tasks.Task, error) {
|
||||
if len(pkgs) == 0 {
|
||||
return nil, errors.New("no packages")
|
||||
}
|
||||
for _, p := range pkgs {
|
||||
if !pkgNameRe.MatchString(p) {
|
||||
return nil, errors.New("bad package name: " + p)
|
||||
}
|
||||
if err := checkNames(pkgs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := append([]string{"apt-get", "-y", "--no-install-recommends", "install"}, pkgs...)
|
||||
return m.start("apt-get install "+strings.Join(pkgs, " "), args...)
|
||||
var args []string
|
||||
switch m.pm {
|
||||
case "apt":
|
||||
args = []string{"apt-get", "-y", "--no-install-recommends", "install"}
|
||||
case "dnf":
|
||||
args = []string{"dnf", "-y", "install"}
|
||||
case "zypper":
|
||||
args = []string{"zypper", "--non-interactive", "install", "--no-recommends"}
|
||||
case "pacman":
|
||||
args = []string{"pacman", "-S", "--noconfirm", "--needed"}
|
||||
case "apk":
|
||||
args = []string{"apk", "add"}
|
||||
}
|
||||
return m.start(append(args, pkgs...)...)
|
||||
}
|
||||
|
||||
func (m *Manager) Changelog(ctx context.Context, pkg string) (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user