From d49c49cd9952b24f5c18933dd4f9eb05894d0e3e Mon Sep 17 00:00:00 2001 From: purian23 Date: Tue, 5 May 2026 15:16:36 -0400 Subject: [PATCH] refactor(sysupdate): Streamline DMS Updater command handling --- core/cmd/dms/commands_system.go | 67 +++++--- core/internal/server/sysupdate/backend_apt.go | 11 +- core/internal/server/sysupdate/backend_dnf.go | 28 ++-- .../server/sysupdate/backend_dnf_test.go | 34 ++++ .../server/sysupdate/backend_flatpak.go | 21 +-- .../server/sysupdate/backend_pacman.go | 36 ++--- .../server/sysupdate/backend_rpmostree.go | 9 +- .../server/sysupdate/backend_zypper.go | 11 +- core/internal/server/sysupdate/executor.go | 18 ++- core/internal/server/sysupdate/manager.go | 61 +++++--- core/internal/server/sysupdate/targets.go | 60 +++++++ core/internal/server/sysupdate/types.go | 1 + .../server/sysupdate/upgrade_commands_test.go | 147 ++++++++++++++++++ 13 files changed, 405 insertions(+), 99 deletions(-) create mode 100644 core/internal/server/sysupdate/targets.go create mode 100644 core/internal/server/sysupdate/upgrade_commands_test.go diff --git a/core/cmd/dms/commands_system.go b/core/cmd/dms/commands_system.go index 266646f4..93e3322d 100644 --- a/core/cmd/dms/commands_system.go +++ b/core/cmd/dms/commands_system.go @@ -100,29 +100,13 @@ func runSystemUpdateCheck() { } stopSpin := startSpinner("Checking for updates… ") - - type backendResult struct { - ID string `json:"id"` - Display string `json:"displayName"` - Packages []sysupdate.Package `json:"packages"` - } - var results []backendResult - var allPkgs []sysupdate.Package - var firstErr error - - for _, b := range backends { - pkgs, err := b.CheckUpdates(ctx) - if err != nil && firstErr == nil { - firstErr = fmt.Errorf("%s: %w", b.ID(), err) - } - results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: pkgs}) - allPkgs = append(allPkgs, pkgs...) - } + allPkgs, firstErr := collectUpdates(ctx, backends) stopSpin() + allPkgs = filterUpdateTargets(allPkgs) if sysUpdateJSON { out, _ := json.MarshalIndent(map[string]any{ - "backends": results, + "backends": backendResults(backends, allPkgs), "packages": allPkgs, "error": errOrEmpty(firstErr), "count": len(allPkgs), @@ -145,6 +129,26 @@ func runSystemUpdateCheck() { } } +type backendResult struct { + ID string `json:"id"` + Display string `json:"displayName"` + Packages []sysupdate.Package `json:"packages"` +} + +func backendResults(backends []sysupdate.Backend, pkgs []sysupdate.Package) []backendResult { + results := make([]backendResult, 0, len(backends)) + for _, b := range backends { + var backendPkgs []sysupdate.Package + for _, p := range pkgs { + if sysupdate.BackendHasTargets(b, []sysupdate.Package{p}, true, true) { + backendPkgs = append(backendPkgs, p) + } + } + results = append(results, backendResult{ID: b.ID(), Display: b.DisplayName(), Packages: backendPkgs}) + } + return results +} + func runSystemUpdateApply() { checkCtx, checkCancel := context.WithTimeout(context.Background(), sysUpdateListPmTime) defer checkCancel() @@ -157,6 +161,7 @@ func runSystemUpdateApply() { stopSpin := startSpinner("Checking for updates…") pkgs, firstErr := collectUpdates(checkCtx, backends) stopSpin() + pkgs = filterUpdateTargets(pkgs) if firstErr != nil { fmt.Printf("Warning: %v\n\n", firstErr) } @@ -190,14 +195,24 @@ func runSystemUpdateApply() { DryRun: sysUpdateDry, UseSudo: true, } + opts.AttachStdio = sysupdate.UpgradeNeedsPrivilege(backends, pkgs, opts) onLine := func(line string) { fmt.Println(line) } + ran := false for _, b := range backends { + if !sysupdate.BackendHasTargets(b, pkgs, opts.IncludeAUR, opts.IncludeFlatpak) { + continue + } + ran = true fmt.Printf("\n== %s ==\n", b.DisplayName()) if err := b.Upgrade(ctx, opts, onLine); err != nil { log.Fatalf("%s upgrade failed: %v", b.ID(), err) } } + if !ran { + fmt.Println("Nothing to upgrade.") + return + } if sysUpdateDry { fmt.Println("\nDry run complete (no changes applied).") return @@ -218,6 +233,20 @@ func collectUpdates(ctx context.Context, backends []sysupdate.Backend) ([]sysupd return all, firstErr } +func filterUpdateTargets(pkgs []sysupdate.Package) []sysupdate.Package { + if !sysUpdateNoAUR { + return pkgs + } + out := pkgs[:0] + for _, p := range pkgs { + if p.Repo == sysupdate.RepoAUR { + continue + } + out = append(out, p) + } + return out +} + func runSystemUpdateSetInterval(seconds int) { resp, err := sendServerRequest(models.Request{ ID: 1, diff --git a/core/internal/server/sysupdate/backend_apt.go b/core/internal/server/sysupdate/backend_apt.go index 0791c740..14916715 100644 --- a/core/internal/server/sysupdate/backend_apt.go +++ b/core/internal/server/sysupdate/backend_apt.go @@ -45,13 +45,14 @@ func (aptBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func( OnLine: onLine, }) } - names := pickTargetNames(opts.Targets, "apt", true) - if len(names) == 0 { + if !BackendHasTargets(aptBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) { return nil } - privesc := privescBin(opts.UseSudo) - argv := append([]string{privesc, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "install", "-y", "--only-upgrade"}, names...) - return Run(ctx, argv, RunOptions{OnLine: onLine}) + return Run(ctx, aptUpgradeArgv(bin, opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio}) +} + +func aptUpgradeArgv(bin string, opts UpgradeOptions) []string { + return privilegedArgv(opts, "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", bin, "upgrade", "-y") } func parseAptUpgradable(text string) []Package { diff --git a/core/internal/server/sysupdate/backend_dnf.go b/core/internal/server/sysupdate/backend_dnf.go index bab2afb6..0d25f6ee 100644 --- a/core/internal/server/sysupdate/backend_dnf.go +++ b/core/internal/server/sysupdate/backend_dnf.go @@ -45,27 +45,37 @@ func (b dnfBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fun if opts.DryRun { return Run(ctx, []string{b.bin, "upgrade", "--assumeno"}, RunOptions{OnLine: onLine}) } - names := pickTargetNames(opts.Targets, b.bin, true) - if len(names) == 0 { + if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) { return nil } - privesc := privescBin(opts.UseSudo) - argv := append([]string{privesc, b.bin, "upgrade", "-y"}, names...) - return Run(ctx, argv, RunOptions{OnLine: onLine}) + return Run(ctx, dnfUpgradeArgv(b.bin, opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio}) +} + +func dnfUpgradeArgv(bin string, opts UpgradeOptions) []string { + return privilegedArgv(opts, bin, "upgrade", "--refresh", "-y") } func dnfListUpgrades(ctx context.Context, bin string) (string, error) { - cmd := exec.CommandContext(ctx, bin, "list", "--upgrades", "--refresh", "--quiet") - out, err := cmd.Output() + argv := dnfCheckUpdatesArgv(bin) + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + out, err := cmd.CombinedOutput() if err == nil { return string(out), nil } - if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 1 { - return "", nil + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 100 { + return string(out), nil } return "", err } +func dnfCheckUpdatesArgv(bin string) []string { + subcommand := "check-update" + if bin == "dnf5" { + subcommand = "check-upgrade" + } + return []string{bin, subcommand, "--refresh", "--quiet"} +} + func rpmInstalledVersions(ctx context.Context) map[string]string { out, err := exec.CommandContext(ctx, "rpm", "-qa", "--qf", `%{NAME}\t%{VERSION}-%{RELEASE}\n`).Output() if err != nil { diff --git a/core/internal/server/sysupdate/backend_dnf_test.go b/core/internal/server/sysupdate/backend_dnf_test.go index a3370d11..29680de2 100644 --- a/core/internal/server/sysupdate/backend_dnf_test.go +++ b/core/internal/server/sysupdate/backend_dnf_test.go @@ -67,6 +67,21 @@ bash.x86_64 5.2.40-1.fc41 updates`, {Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf", FromVersion: "", ToVersion: "5.2.40-1.fc41"}, }, }, + { + name: "skips dnf warning lines while keeping package rows", + input: `Failed to expire repository cache in path "/home/user/.cache/libdnf5/updates": cannot open file +example-driver.x86_64 2:9.8.7-1.fc99 updates +example-tool.noarch 1.2.3^45.gitabcdef-1.fc99 copr`, + backendID: "dnf5", + installed: map[string]string{ + "example-driver": "2:9.8.6-1.fc99", + "example-tool": "1.2.2^44.gitabcdef-1.fc99", + }, + want: []Package{ + {Name: "example-driver.x86_64", Repo: RepoSystem, Backend: "dnf5", FromVersion: "2:9.8.6-1.fc99", ToVersion: "2:9.8.7-1.fc99"}, + {Name: "example-tool.noarch", Repo: RepoSystem, Backend: "dnf5", FromVersion: "1.2.2^44.gitabcdef-1.fc99", ToVersion: "1.2.3^45.gitabcdef-1.fc99"}, + }, + }, } for _, tt := range tests { @@ -78,3 +93,22 @@ bash.x86_64 5.2.40-1.fc41 updates`, }) } } + +func TestDnfCheckUpdatesArgv(t *testing.T) { + tests := []struct { + bin string + want []string + }{ + {bin: "dnf5", want: []string{"dnf5", "check-upgrade", "--refresh", "--quiet"}}, + {bin: "dnf", want: []string{"dnf", "check-update", "--refresh", "--quiet"}}, + } + + for _, tt := range tests { + t.Run(tt.bin, func(t *testing.T) { + got := dnfCheckUpdatesArgv(tt.bin) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("dnfCheckUpdatesArgv(%q) = %#v, want %#v", tt.bin, got, tt.want) + } + }) + } +} diff --git a/core/internal/server/sysupdate/backend_flatpak.go b/core/internal/server/sysupdate/backend_flatpak.go index 57874cf3..3371898b 100644 --- a/core/internal/server/sysupdate/backend_flatpak.go +++ b/core/internal/server/sysupdate/backend_flatpak.go @@ -73,27 +73,14 @@ func (flatpakBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine f if opts.DryRun { return Run(ctx, []string{"flatpak", "update", "--no-deploy", "-y"}, RunOptions{OnLine: onLine}) } - refs := flatpakTargetRefs(opts.Targets) - if len(refs) == 0 { + if !BackendHasTargets(flatpakBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) { return nil } - argv := append([]string{"flatpak", "update", "-y", "--noninteractive"}, refs...) - return Run(ctx, argv, RunOptions{OnLine: onLine}) + return Run(ctx, flatpakUpgradeArgv(), RunOptions{OnLine: onLine}) } -func flatpakTargetRefs(targets []Package) []string { - out := make([]string, 0, len(targets)) - for _, p := range targets { - if p.Backend != "flatpak" { - continue - } - ref := p.Ref - if ref == "" { - ref = p.Name - } - out = append(out, ref) - } - return out +func flatpakUpgradeArgv() []string { + return []string{"flatpak", "update", "-y", "--noninteractive"} } func parseFlatpakUpdates(text string, installed map[string]flatpakInstalledEntry) []Package { diff --git a/core/internal/server/sysupdate/backend_pacman.go b/core/internal/server/sysupdate/backend_pacman.go index 6dea6010..c92f1a91 100644 --- a/core/internal/server/sysupdate/backend_pacman.go +++ b/core/internal/server/sysupdate/backend_pacman.go @@ -43,13 +43,14 @@ func (b pacmanBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine if opts.DryRun { return Run(ctx, []string{"pacman", "-Sup"}, RunOptions{OnLine: onLine}) } - names := pickTargetNames(opts.Targets, b.ID(), opts.IncludeAUR) - if len(names) == 0 { + if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) { return nil } - privesc := privescBin(opts.UseSudo) - argv := append([]string{privesc, "pacman", "-Sy", "--noconfirm", "--needed"}, names...) - return Run(ctx, argv, RunOptions{OnLine: onLine}) + return Run(ctx, pacmanUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio}) +} + +func pacmanUpgradeArgv(opts UpgradeOptions) []string { + return privilegedArgv(opts, "pacman", "-Syu", "--noconfirm", "--needed") } type archHelperBackend struct { @@ -94,35 +95,28 @@ func (b archHelperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onL if opts.DryRun { return Run(ctx, []string{b.id, "-Sup"}, RunOptions{OnLine: onLine}) } - names := pickTargetNames(opts.Targets, b.id, opts.IncludeAUR) - if len(names) == 0 { + if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) { return nil } if os.Getenv("DMS_FORCE_PKEXEC") == "1" { - argv := append([]string{"pkexec", b.id, "-Sy", "--noconfirm", "--needed"}, names...) - return Run(ctx, argv, RunOptions{OnLine: onLine}) + argv := append([]string{"pkexec"}, archHelperUpgradeArgv(b.id, opts.IncludeAUR)...) + return Run(ctx, argv, RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio}) } term := findTerminal(opts.Terminal) if term == "" { return fmt.Errorf("no terminal found (pick one in DMS settings, set $TERMINAL, or install kitty/ghostty/foot/alacritty)") } - cmd := fmt.Sprintf("%s -Sy --noconfirm --needed %s", b.id, strings.Join(names, " ")) + cmd := strings.Join(archHelperUpgradeArgv(b.id, opts.IncludeAUR), " ") title := fmt.Sprintf("DMS — System Update (%s)", b.id) return Run(ctx, wrapInTerminal(term, title, cmd), RunOptions{OnLine: onLine}) } -func pickTargetNames(targets []Package, backendID string, includeAUR bool) []string { - out := make([]string, 0, len(targets)) - for _, p := range targets { - if p.Backend != backendID { - continue - } - if !includeAUR && p.Repo == RepoAUR { - continue - } - out = append(out, p.Name) +func archHelperUpgradeArgv(id string, includeAUR bool) []string { + argv := []string{id, "-Syu", "--noconfirm", "--needed"} + if !includeAUR { + argv = append(argv, "--repo") } - return out + return argv } func pacmanRepoUpdates(ctx context.Context) (string, error) { diff --git a/core/internal/server/sysupdate/backend_rpmostree.go b/core/internal/server/sysupdate/backend_rpmostree.go index 077b22c4..debd2d11 100644 --- a/core/internal/server/sysupdate/backend_rpmostree.go +++ b/core/internal/server/sysupdate/backend_rpmostree.go @@ -117,9 +117,16 @@ func bootedDeployment(deps []ostreeDeployment) *ostreeDeployment { } func (rpmOstreeBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine func(string)) error { + if !BackendHasTargets(rpmOstreeBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) { + return nil + } + return Run(ctx, rpmOstreeUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio}) +} + +func rpmOstreeUpgradeArgv(opts UpgradeOptions) []string { argv := []string{"rpm-ostree", "upgrade"} if opts.DryRun { argv = append(argv, "--check") } - return Run(ctx, argv, RunOptions{OnLine: onLine}) + return argv } diff --git a/core/internal/server/sysupdate/backend_zypper.go b/core/internal/server/sysupdate/backend_zypper.go index 3aacb0d4..1ea1ee46 100644 --- a/core/internal/server/sysupdate/backend_zypper.go +++ b/core/internal/server/sysupdate/backend_zypper.go @@ -74,11 +74,12 @@ func (zypperBackend) Upgrade(ctx context.Context, opts UpgradeOptions, onLine fu if opts.DryRun { return Run(ctx, []string{"zypper", "--non-interactive", "--dry-run", "update"}, RunOptions{OnLine: onLine}) } - names := pickTargetNames(opts.Targets, "zypper", true) - if len(names) == 0 { + if !BackendHasTargets(zypperBackend{}, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) { return nil } - privesc := privescBin(opts.UseSudo) - argv := append([]string{privesc, "zypper", "--non-interactive", "update"}, names...) - return Run(ctx, argv, RunOptions{OnLine: onLine}) + return Run(ctx, zypperUpgradeArgv(opts), RunOptions{OnLine: onLine, AttachStdio: opts.AttachStdio}) +} + +func zypperUpgradeArgv(opts UpgradeOptions) []string { + return privilegedArgv(opts, "zypper", "--non-interactive", "update") } diff --git a/core/internal/server/sysupdate/executor.go b/core/internal/server/sysupdate/executor.go index e43bb29c..792cb5f3 100644 --- a/core/internal/server/sysupdate/executor.go +++ b/core/internal/server/sysupdate/executor.go @@ -14,8 +14,9 @@ import ( ) type RunOptions struct { - Env []string - OnLine func(string) + Env []string + OnLine func(string) + AttachStdio bool } func Run(ctx context.Context, argv []string, opts RunOptions) error { @@ -27,6 +28,19 @@ func Run(ctx context.Context, argv []string, opts RunOptions) error { if len(opts.Env) > 0 { cmd.Env = append(cmd.Environ(), opts.Env...) } + if opts.AttachStdio { + cmd.Cancel = func() error { + if cmd.Process == nil { + return nil + } + return cmd.Process.Kill() + } + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.Cancel = func() error { if cmd.Process == nil { diff --git a/core/internal/server/sysupdate/manager.go b/core/internal/server/sysupdate/manager.go index 6b86ad5a..e18dbfe3 100644 --- a/core/internal/server/sysupdate/manager.go +++ b/core/internal/server/sysupdate/manager.go @@ -16,11 +16,12 @@ import ( ) const ( - defaultIntervalSeconds = 30 * 60 - minIntervalSeconds = 5 * 60 - recentLogCapacity = 200 - checkTimeout = 5 * time.Minute - upgradeTimeout = 30 * time.Minute + defaultIntervalSeconds = 30 * 60 + minIntervalSeconds = 5 * 60 + recentLogCapacity = 200 + checkTimeout = 5 * time.Minute + upgradeTimeout = 30 * time.Minute + postUpgradeCompleteDelay = 3 * time.Second ) type Manager struct { @@ -310,18 +311,18 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) { return } - backends := upgradeBackends(m.selection, opts) - if len(backends) == 0 { - m.setError(ErrCodeNoBackend, "no backend selected for upgrade") - return - } - if len(opts.Targets) == 0 { m.mu.RLock() opts.Targets = append([]Package(nil), m.state.Packages...) m.mu.RUnlock() } + backends := upgradeBackends(m.selection, opts) + if len(backends) == 0 { + m.setError(ErrCodeNoBackend, "no backend selected for upgrade") + return + } + opID := fmt.Sprintf("op-%d", time.Now().UnixNano()) m.mu.Lock() m.state.Phase = PhaseUpgrading @@ -351,13 +352,7 @@ func (m *Manager) runUpgrade(ctx context.Context, opts UpgradeOptions) { } } - m.mu.Lock() - m.state.Phase = PhaseIdle - m.state.OperationID = "" - m.state.OperationStarted = 0 - m.mu.Unlock() - m.markDirty() - go m.runRefresh(context.Background()) + m.finishSuccessfulUpgrade(true) } func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverride string) { @@ -395,10 +390,29 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverrid return } + m.finishSuccessfulUpgrade(false) +} + +func (m *Manager) finishSuccessfulUpgrade(clearPackages bool) { + m.appendLog("Upgrade complete.") + + timer := time.NewTimer(postUpgradeCompleteDelay) + defer timer.Stop() + + select { + case <-m.stopChan: + return + case <-timer.C: + } + m.mu.Lock() m.state.Phase = PhaseIdle m.state.OperationID = "" m.state.OperationStarted = 0 + if clearPackages { + m.state.Packages = m.state.Packages[:0] + m.state.Count = 0 + } m.mu.Unlock() m.markDirty() go m.runRefresh(context.Background()) @@ -407,18 +421,25 @@ func (m *Manager) runCustomUpgrade(ctx context.Context, command, terminalOverrid func upgradeBackends(sel Selection, opts UpgradeOptions) []Backend { var out []Backend if sel.System != nil { - out = append(out, sel.System) + out = appendUpgradeBackend(out, sel.System, opts) } for _, b := range sel.Overlay { switch { case b.Repo() == RepoFlatpak && !opts.IncludeFlatpak: continue } - out = append(out, b) + out = appendUpgradeBackend(out, b, opts) } return out } +func appendUpgradeBackend(out []Backend, b Backend, opts UpgradeOptions) []Backend { + if !BackendHasTargets(b, opts.Targets, opts.IncludeAUR, opts.IncludeFlatpak) { + return out + } + return append(out, b) +} + func (m *Manager) appendLog(line string) { m.mu.Lock() if cap(m.state.RecentLog) == 0 { diff --git a/core/internal/server/sysupdate/targets.go b/core/internal/server/sysupdate/targets.go new file mode 100644 index 00000000..c2b8ff19 --- /dev/null +++ b/core/internal/server/sysupdate/targets.go @@ -0,0 +1,60 @@ +package sysupdate + +func BackendHasTargets(b Backend, targets []Package, includeAUR, includeFlatpak bool) bool { + if b == nil || len(targets) == 0 { + return false + } + id := b.ID() + repo := b.Repo() + for _, p := range targets { + switch p.Repo { + case RepoFlatpak: + if !includeFlatpak { + continue + } + case RepoAUR: + if !includeAUR { + continue + } + } + + switch repo { + case RepoFlatpak: + if p.Repo == RepoFlatpak || p.Backend == id { + return true + } + case RepoOSTree: + if p.Repo == RepoOSTree || p.Backend == id { + return true + } + default: + if p.Backend == id { + return true + } + } + } + return false +} + +func UpgradeNeedsPrivilege(backends []Backend, targets []Package, opts UpgradeOptions) bool { + if opts.DryRun { + return false + } + for _, b := range backends { + if b == nil { + continue + } + if b.NeedsAuth() && BackendHasTargets(b, targets, opts.IncludeAUR, opts.IncludeFlatpak) { + return true + } + } + return false +} + +func privilegedArgv(opts UpgradeOptions, argv ...string) []string { + privesc := privescBin(opts.UseSudo) + out := make([]string, 0, len(argv)+1) + out = append(out, privesc) + out = append(out, argv...) + return out +} diff --git a/core/internal/server/sysupdate/types.go b/core/internal/server/sysupdate/types.go index 4205e522..2d3c0d90 100644 --- a/core/internal/server/sysupdate/types.go +++ b/core/internal/server/sysupdate/types.go @@ -77,6 +77,7 @@ type UpgradeOptions struct { IncludeAUR bool DryRun bool UseSudo bool + AttachStdio bool CustomCommand string Terminal string Targets []Package diff --git a/core/internal/server/sysupdate/upgrade_commands_test.go b/core/internal/server/sysupdate/upgrade_commands_test.go new file mode 100644 index 00000000..55ed28ee --- /dev/null +++ b/core/internal/server/sysupdate/upgrade_commands_test.go @@ -0,0 +1,147 @@ +package sysupdate + +import ( + "reflect" + "testing" +) + +func TestUpgradeCommandBuilders(t *testing.T) { + pkexecOpts := UpgradeOptions{UseSudo: false} + + tests := []struct { + name string + got []string + want []string + }{ + { + name: "dnf full upgrade", + got: dnfUpgradeArgv("dnf5", pkexecOpts), + want: []string{"pkexec", "dnf5", "upgrade", "--refresh", "-y"}, + }, + { + name: "apt full upgrade", + got: aptUpgradeArgv("apt-get", pkexecOpts), + want: []string{"pkexec", "env", "DEBIAN_FRONTEND=noninteractive", "LC_ALL=C", "apt-get", "upgrade", "-y"}, + }, + { + name: "zypper full update", + got: zypperUpgradeArgv(pkexecOpts), + want: []string{"pkexec", "zypper", "--non-interactive", "update"}, + }, + { + name: "pacman full sync upgrade", + got: pacmanUpgradeArgv(pkexecOpts), + want: []string{"pkexec", "pacman", "-Syu", "--noconfirm", "--needed"}, + }, + { + name: "aur helper full update with aur", + got: archHelperUpgradeArgv("paru", true), + want: []string{"paru", "-Syu", "--noconfirm", "--needed"}, + }, + { + name: "aur helper repo-only full update", + got: archHelperUpgradeArgv("yay", false), + want: []string{"yay", "-Syu", "--noconfirm", "--needed", "--repo"}, + }, + { + name: "flatpak full update", + got: flatpakUpgradeArgv(), + want: []string{"flatpak", "update", "-y", "--noninteractive"}, + }, + { + name: "rpm-ostree upgrade", + got: rpmOstreeUpgradeArgv(UpgradeOptions{}), + want: []string{"rpm-ostree", "upgrade"}, + }, + { + name: "rpm-ostree check", + got: rpmOstreeUpgradeArgv(UpgradeOptions{DryRun: true}), + want: []string{"rpm-ostree", "upgrade", "--check"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !reflect.DeepEqual(tt.got, tt.want) { + t.Fatalf("argv = %#v, want %#v", tt.got, tt.want) + } + }) + } +} + +func TestBackendHasTargetsRespectsBackendAndOptions(t *testing.T) { + targets := []Package{ + {Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"}, + {Name: "google-chrome", Repo: RepoAUR, Backend: "paru"}, + {Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"}, + {Name: "silverblue", Repo: RepoOSTree, Backend: "rpm-ostree"}, + } + + if !BackendHasTargets(dnfBackend{bin: "dnf5"}, targets, true, true) { + t.Fatal("dnf5 target was not detected") + } + if BackendHasTargets(dnfBackend{bin: "dnf"}, targets, true, true) { + t.Fatal("dnf target should not match dnf5 package targets") + } + if !BackendHasTargets(archHelperBackend{id: "paru"}, targets, true, true) { + t.Fatal("AUR helper target was not detected") + } + if BackendHasTargets(archHelperBackend{id: "paru"}, targets, false, true) { + t.Fatal("AUR helper should not match AUR-only target when AUR is disabled") + } + if !BackendHasTargets(flatpakBackend{}, targets, true, true) { + t.Fatal("Flatpak target was not detected") + } + if BackendHasTargets(flatpakBackend{}, targets, true, false) { + t.Fatal("Flatpak target should not match when Flatpak is disabled") + } + if !BackendHasTargets(rpmOstreeBackend{}, targets, true, true) { + t.Fatal("rpm-ostree target was not detected") + } +} + +func TestUpgradeNeedsPrivilegeSkipsFlatpakOnly(t *testing.T) { + backends := []Backend{dnfBackend{bin: "dnf5"}, flatpakBackend{}} + opts := UpgradeOptions{IncludeAUR: true, IncludeFlatpak: true} + + flatpakOnly := []Package{{Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"}} + if UpgradeNeedsPrivilege(backends, flatpakOnly, opts) { + t.Fatal("Flatpak-only updates should not need privileged auth") + } + + mixed := []Package{ + {Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"}, + {Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"}, + } + if !UpgradeNeedsPrivilege(backends, mixed, opts) { + t.Fatal("mixed system updates should need privileged auth") + } + + opts.DryRun = true + if UpgradeNeedsPrivilege(backends, mixed, opts) { + t.Fatal("dry-run updates should not need privileged auth") + } +} + +func TestUpgradeBackendsFiltersFlatpakOnly(t *testing.T) { + sel := Selection{ + System: dnfBackend{bin: "dnf5"}, + Overlay: []Backend{flatpakBackend{}}, + } + opts := UpgradeOptions{ + IncludeAUR: true, + IncludeFlatpak: true, + Targets: []Package{{Name: "Discord", Repo: RepoFlatpak, Backend: "flatpak"}}, + } + + got := upgradeBackends(sel, opts) + if len(got) != 1 || got[0].ID() != "flatpak" { + t.Fatalf("upgradeBackends(flatpak-only) = %#v, want only flatpak", got) + } + + opts.Targets = append(opts.Targets, Package{Name: "bash.x86_64", Repo: RepoSystem, Backend: "dnf5"}) + got = upgradeBackends(sel, opts) + if len(got) != 2 || got[0].ID() != "dnf5" || got[1].ID() != "flatpak" { + t.Fatalf("upgradeBackends(mixed) = %#v, want dnf5 then flatpak", got) + } +}