mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-02 05:08:42 -07:00
Merge pull request #18 from gogrlx/cd/dnf5-support
feat(dnf): add dnf5 compatibility
This commit is contained in:
21
.github/workflows/integration.yml
vendored
21
.github/workflows/integration.yml
vendored
@@ -52,10 +52,25 @@ jobs:
|
|||||||
- name: Integration tests (snap)
|
- name: Integration tests (snap)
|
||||||
run: sudo -E go test -v -tags integration -count=1 ./snap/
|
run: sudo -E go test -v -tags integration -count=1 ./snap/
|
||||||
|
|
||||||
fedora:
|
fedora-dnf4:
|
||||||
name: Fedora (dnf)
|
name: Fedora 39 (dnf4)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: fedora:39 # last release with dnf4; dnf5 (fedora 40+) needs separate parser
|
container: fedora:39
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
- name: Setup
|
||||||
|
run: |
|
||||||
|
dnf install -y tree sudo
|
||||||
|
- name: Integration tests
|
||||||
|
run: go test -v -tags integration -count=1 ./dnf/ ./rpm/ ./detect/
|
||||||
|
|
||||||
|
fedora-dnf5:
|
||||||
|
name: Fedora latest (dnf5)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container: fedora:latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
|
|||||||
@@ -20,17 +20,17 @@ var (
|
|||||||
|
|
||||||
// LatestVersion returns the latest available version from configured repositories.
|
// LatestVersion returns the latest available version from configured repositories.
|
||||||
func (d *DNF) LatestVersion(ctx context.Context, pkg string) (string, error) {
|
func (d *DNF) LatestVersion(ctx context.Context, pkg string) (string, error) {
|
||||||
return latestVersion(ctx, pkg)
|
return latestVersion(ctx, pkg, d.v5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUpgrades returns packages that have newer versions available.
|
// ListUpgrades returns packages that have newer versions available.
|
||||||
func (d *DNF) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
|
func (d *DNF) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||||
return listUpgrades(ctx)
|
return listUpgrades(ctx, d.v5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpgradeAvailable reports whether a newer version is available.
|
// UpgradeAvailable reports whether a newer version is available.
|
||||||
func (d *DNF) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
func (d *DNF) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||||
return upgradeAvailable(ctx, pkg)
|
return upgradeAvailable(ctx, pkg, d.v5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// VersionCmp compares two version strings using RPM version comparison.
|
// VersionCmp compares two version strings using RPM version comparison.
|
||||||
@@ -83,7 +83,7 @@ func (d *DNF) Owner(ctx context.Context, path string) (string, error) {
|
|||||||
|
|
||||||
// ListRepos returns all configured package repositories.
|
// ListRepos returns all configured package repositories.
|
||||||
func (d *DNF) ListRepos(ctx context.Context) ([]snack.Repository, error) {
|
func (d *DNF) ListRepos(ctx context.Context) ([]snack.Repository, error) {
|
||||||
return listRepos(ctx)
|
return listRepos(ctx, d.v5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddRepo adds a new package repository.
|
// AddRepo adds a new package repository.
|
||||||
@@ -121,12 +121,12 @@ func (d *DNF) ListKeys(ctx context.Context) ([]string, error) {
|
|||||||
|
|
||||||
// GroupList returns all available package groups.
|
// GroupList returns all available package groups.
|
||||||
func (d *DNF) GroupList(ctx context.Context) ([]string, error) {
|
func (d *DNF) GroupList(ctx context.Context) ([]string, error) {
|
||||||
return groupList(ctx)
|
return groupList(ctx, d.v5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupInfo returns the packages in a group.
|
// GroupInfo returns the packages in a group.
|
||||||
func (d *DNF) GroupInfo(ctx context.Context, group string) ([]snack.Package, error) {
|
func (d *DNF) GroupInfo(ctx context.Context, group string) ([]snack.Package, error) {
|
||||||
return groupInfo(ctx, group)
|
return groupInfo(ctx, group, d.v5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupInstall installs all packages in a group.
|
// GroupInstall installs all packages in a group.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"github.com/gogrlx/snack"
|
"github.com/gogrlx/snack"
|
||||||
)
|
)
|
||||||
|
|
||||||
func latestVersion(ctx context.Context, pkg string) (string, error) {
|
func latestVersion(ctx context.Context, pkg string, v5 bool) (string, error) {
|
||||||
// Try "dnf info <pkg>" which shows both installed and available
|
// Try "dnf info <pkg>" which shows both installed and available
|
||||||
out, err := run(ctx, []string{"info", pkg}, snack.Options{})
|
out, err := run(ctx, []string{"info", pkg}, snack.Options{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -23,26 +23,42 @@ func latestVersion(ctx context.Context, pkg string) (string, error) {
|
|||||||
}
|
}
|
||||||
return "", fmt.Errorf("dnf latestVersion: %w", err)
|
return "", fmt.Errorf("dnf latestVersion: %w", err)
|
||||||
}
|
}
|
||||||
p := parseInfo(out)
|
var p *snack.Package
|
||||||
|
if v5 {
|
||||||
|
p = parseInfoDNF5(out)
|
||||||
|
} else {
|
||||||
|
p = parseInfo(out)
|
||||||
|
}
|
||||||
if p == nil || p.Version == "" {
|
if p == nil || p.Version == "" {
|
||||||
return "", fmt.Errorf("dnf latestVersion %s: %w", pkg, snack.ErrNotFound)
|
return "", fmt.Errorf("dnf latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||||
}
|
}
|
||||||
return p.Version, nil
|
return p.Version, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
|
func listUpgrades(ctx context.Context, v5 bool) ([]snack.Package, error) {
|
||||||
out, err := run(ctx, []string{"list", "upgrades"}, snack.Options{})
|
args := []string{"list", "upgrades"}
|
||||||
|
if v5 {
|
||||||
|
args = []string{"list", "--upgrades"}
|
||||||
|
}
|
||||||
|
out, err := run(ctx, args, snack.Options{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "exit status 1") {
|
if strings.Contains(err.Error(), "exit status 1") {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("dnf listUpgrades: %w", err)
|
return nil, fmt.Errorf("dnf listUpgrades: %w", err)
|
||||||
}
|
}
|
||||||
|
if v5 {
|
||||||
|
return parseListDNF5(out), nil
|
||||||
|
}
|
||||||
return parseList(out), nil
|
return parseList(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
func upgradeAvailable(ctx context.Context, pkg string, v5 bool) (bool, error) {
|
||||||
c := exec.CommandContext(ctx, "dnf", "list", "upgrades", pkg)
|
args := []string{"list", "upgrades", pkg}
|
||||||
|
if v5 {
|
||||||
|
args = []string{"list", "--upgrades", pkg}
|
||||||
|
}
|
||||||
|
c := exec.CommandContext(ctx, "dnf", args...)
|
||||||
err := c.Run()
|
err := c.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||||
@@ -144,11 +160,14 @@ func owner(ctx context.Context, path string) (string, error) {
|
|||||||
return strings.TrimSpace(stdout.String()), nil
|
return strings.TrimSpace(stdout.String()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func listRepos(ctx context.Context) ([]snack.Repository, error) {
|
func listRepos(ctx context.Context, v5 bool) ([]snack.Repository, error) {
|
||||||
out, err := run(ctx, []string{"repolist", "--all"}, snack.Options{})
|
out, err := run(ctx, []string{"repolist", "--all"}, snack.Options{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("dnf listRepos: %w", err)
|
return nil, fmt.Errorf("dnf listRepos: %w", err)
|
||||||
}
|
}
|
||||||
|
if v5 {
|
||||||
|
return parseRepoListDNF5(out), nil
|
||||||
|
}
|
||||||
return parseRepoList(out), nil
|
return parseRepoList(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,15 +225,18 @@ func listKeys(ctx context.Context) ([]string, error) {
|
|||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func groupList(ctx context.Context) ([]string, error) {
|
func groupList(ctx context.Context, v5 bool) ([]string, error) {
|
||||||
out, err := run(ctx, []string{"group", "list"}, snack.Options{})
|
out, err := run(ctx, []string{"group", "list"}, snack.Options{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("dnf groupList: %w", err)
|
return nil, fmt.Errorf("dnf groupList: %w", err)
|
||||||
}
|
}
|
||||||
|
if v5 {
|
||||||
|
return parseGroupListDNF5(out), nil
|
||||||
|
}
|
||||||
return parseGroupList(out), nil
|
return parseGroupList(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func groupInfo(ctx context.Context, group string) ([]snack.Package, error) {
|
func groupInfo(ctx context.Context, group string, v5 bool) ([]snack.Package, error) {
|
||||||
out, err := run(ctx, []string{"group", "info", group}, snack.Options{})
|
out, err := run(ctx, []string{"group", "info", group}, snack.Options{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "exit status 1") {
|
if strings.Contains(err.Error(), "exit status 1") {
|
||||||
@@ -222,6 +244,9 @@ func groupInfo(ctx context.Context, group string) ([]snack.Package, error) {
|
|||||||
}
|
}
|
||||||
return nil, fmt.Errorf("dnf groupInfo: %w", err)
|
return nil, fmt.Errorf("dnf groupInfo: %w", err)
|
||||||
}
|
}
|
||||||
|
if v5 {
|
||||||
|
return parseGroupInfoDNF5(out), nil
|
||||||
|
}
|
||||||
return parseGroupInfo(out), nil
|
return parseGroupInfo(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ import (
|
|||||||
"github.com/gogrlx/snack"
|
"github.com/gogrlx/snack"
|
||||||
)
|
)
|
||||||
|
|
||||||
func latestVersion(_ context.Context, _ string) (string, error) {
|
func latestVersion(_ context.Context, _ string, _ bool) (string, error) {
|
||||||
return "", snack.ErrUnsupportedPlatform
|
return "", snack.ErrUnsupportedPlatform
|
||||||
}
|
}
|
||||||
|
|
||||||
func listUpgrades(_ context.Context) ([]snack.Package, error) {
|
func listUpgrades(_ context.Context, _ bool) ([]snack.Package, error) {
|
||||||
return nil, snack.ErrUnsupportedPlatform
|
return nil, snack.ErrUnsupportedPlatform
|
||||||
}
|
}
|
||||||
|
|
||||||
func upgradeAvailable(_ context.Context, _ string) (bool, error) {
|
func upgradeAvailable(_ context.Context, _ string, _ bool) (bool, error) {
|
||||||
return false, snack.ErrUnsupportedPlatform
|
return false, snack.ErrUnsupportedPlatform
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ func owner(_ context.Context, _ string) (string, error) {
|
|||||||
return "", snack.ErrUnsupportedPlatform
|
return "", snack.ErrUnsupportedPlatform
|
||||||
}
|
}
|
||||||
|
|
||||||
func listRepos(_ context.Context) ([]snack.Repository, error) {
|
func listRepos(_ context.Context, _ bool) ([]snack.Repository, error) {
|
||||||
return nil, snack.ErrUnsupportedPlatform
|
return nil, snack.ErrUnsupportedPlatform
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,11 +76,11 @@ func listKeys(_ context.Context) ([]string, error) {
|
|||||||
return nil, snack.ErrUnsupportedPlatform
|
return nil, snack.ErrUnsupportedPlatform
|
||||||
}
|
}
|
||||||
|
|
||||||
func groupList(_ context.Context) ([]string, error) {
|
func groupList(_ context.Context, _ bool) ([]string, error) {
|
||||||
return nil, snack.ErrUnsupportedPlatform
|
return nil, snack.ErrUnsupportedPlatform
|
||||||
}
|
}
|
||||||
|
|
||||||
func groupInfo(_ context.Context, _ string) ([]snack.Package, error) {
|
func groupInfo(_ context.Context, _ string, _ bool) ([]snack.Package, error) {
|
||||||
return nil, snack.ErrUnsupportedPlatform
|
return nil, snack.ErrUnsupportedPlatform
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
dnf/dnf.go
19
dnf/dnf.go
@@ -10,13 +10,20 @@ import (
|
|||||||
// DNF wraps the dnf package manager CLI.
|
// DNF wraps the dnf package manager CLI.
|
||||||
type DNF struct {
|
type DNF struct {
|
||||||
snack.Locker
|
snack.Locker
|
||||||
|
v5 bool // true when the system dnf is dnf5
|
||||||
|
v5Set bool // true after detection has run
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a new DNF manager.
|
// New returns a new DNF manager.
|
||||||
func New() *DNF {
|
func New() *DNF {
|
||||||
return &DNF{}
|
d := &DNF{}
|
||||||
|
d.detectVersion()
|
||||||
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsDNF5 reports whether the underlying dnf binary is dnf5.
|
||||||
|
func (d *DNF) IsDNF5() bool { return d.v5 }
|
||||||
|
|
||||||
// Name returns "dnf".
|
// Name returns "dnf".
|
||||||
func (d *DNF) Name() string { return "dnf" }
|
func (d *DNF) Name() string { return "dnf" }
|
||||||
|
|
||||||
@@ -60,27 +67,27 @@ func (d *DNF) Update(ctx context.Context) error {
|
|||||||
|
|
||||||
// List returns all installed packages.
|
// List returns all installed packages.
|
||||||
func (d *DNF) List(ctx context.Context) ([]snack.Package, error) {
|
func (d *DNF) List(ctx context.Context) ([]snack.Package, error) {
|
||||||
return list(ctx)
|
return list(ctx, d.v5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search queries the repositories for packages matching the query.
|
// Search queries the repositories for packages matching the query.
|
||||||
func (d *DNF) Search(ctx context.Context, query string) ([]snack.Package, error) {
|
func (d *DNF) Search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||||
return search(ctx, query)
|
return search(ctx, query, d.v5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info returns details about a specific package.
|
// Info returns details about a specific package.
|
||||||
func (d *DNF) Info(ctx context.Context, pkg string) (*snack.Package, error) {
|
func (d *DNF) Info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||||
return info(ctx, pkg)
|
return info(ctx, pkg, d.v5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsInstalled reports whether a package is currently installed.
|
// IsInstalled reports whether a package is currently installed.
|
||||||
func (d *DNF) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
func (d *DNF) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||||
return isInstalled(ctx, pkg)
|
return isInstalled(ctx, pkg, d.v5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version returns the installed version of a package.
|
// Version returns the installed version of a package.
|
||||||
func (d *DNF) Version(ctx context.Context, pkg string) (string, error) {
|
func (d *DNF) Version(ctx context.Context, pkg string) (string, error) {
|
||||||
return version(ctx, pkg)
|
return version(ctx, pkg, d.v5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify interface compliance at compile time.
|
// Verify interface compliance at compile time.
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ func available() bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detectVersion checks whether the system dnf is dnf5 by inspecting
|
||||||
|
// `dnf --version` output. dnf5 prints "dnf5 version 5.x.x".
|
||||||
|
func (d *DNF) detectVersion() {
|
||||||
|
if d.v5Set {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.v5Set = true
|
||||||
|
out, err := exec.Command("dnf", "--version").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.v5 = strings.Contains(string(out), "dnf5")
|
||||||
|
}
|
||||||
|
|
||||||
// buildArgs constructs the command name and argument list from the base args
|
// buildArgs constructs the command name and argument list from the base args
|
||||||
// and the provided options.
|
// and the provided options.
|
||||||
func buildArgs(baseArgs []string, opts snack.Options) (string, []string) {
|
func buildArgs(baseArgs []string, opts snack.Options) (string, []string) {
|
||||||
@@ -116,15 +130,25 @@ func update(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func list(ctx context.Context) ([]snack.Package, error) {
|
func listArgs(v5 bool) []string {
|
||||||
out, err := run(ctx, []string{"list", "installed"}, snack.Options{})
|
if v5 {
|
||||||
|
return []string{"list", "--installed"}
|
||||||
|
}
|
||||||
|
return []string{"list", "installed"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(ctx context.Context, v5 bool) ([]snack.Package, error) {
|
||||||
|
out, err := run(ctx, listArgs(v5), snack.Options{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("dnf list: %w", err)
|
return nil, fmt.Errorf("dnf list: %w", err)
|
||||||
}
|
}
|
||||||
|
if v5 {
|
||||||
|
return parseListDNF5(out), nil
|
||||||
|
}
|
||||||
return parseList(out), nil
|
return parseList(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func search(ctx context.Context, query string) ([]snack.Package, error) {
|
func search(ctx context.Context, query string, v5 bool) ([]snack.Package, error) {
|
||||||
out, err := run(ctx, []string{"search", query}, snack.Options{})
|
out, err := run(ctx, []string{"search", query}, snack.Options{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "exit status 1") {
|
if strings.Contains(err.Error(), "exit status 1") {
|
||||||
@@ -132,10 +156,13 @@ func search(ctx context.Context, query string) ([]snack.Package, error) {
|
|||||||
}
|
}
|
||||||
return nil, fmt.Errorf("dnf search: %w", err)
|
return nil, fmt.Errorf("dnf search: %w", err)
|
||||||
}
|
}
|
||||||
|
if v5 {
|
||||||
|
return parseSearchDNF5(out), nil
|
||||||
|
}
|
||||||
return parseSearch(out), nil
|
return parseSearch(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func info(ctx context.Context, pkg string) (*snack.Package, error) {
|
func info(ctx context.Context, pkg string, v5 bool) (*snack.Package, error) {
|
||||||
out, err := run(ctx, []string{"info", pkg}, snack.Options{})
|
out, err := run(ctx, []string{"info", pkg}, snack.Options{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "exit status 1") {
|
if strings.Contains(err.Error(), "exit status 1") {
|
||||||
@@ -143,15 +170,24 @@ func info(ctx context.Context, pkg string) (*snack.Package, error) {
|
|||||||
}
|
}
|
||||||
return nil, fmt.Errorf("dnf info: %w", err)
|
return nil, fmt.Errorf("dnf info: %w", err)
|
||||||
}
|
}
|
||||||
p := parseInfo(out)
|
var p *snack.Package
|
||||||
|
if v5 {
|
||||||
|
p = parseInfoDNF5(out)
|
||||||
|
} else {
|
||||||
|
p = parseInfo(out)
|
||||||
|
}
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return nil, fmt.Errorf("dnf info %s: %w", pkg, snack.ErrNotFound)
|
return nil, fmt.Errorf("dnf info %s: %w", pkg, snack.ErrNotFound)
|
||||||
}
|
}
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isInstalled(ctx context.Context, pkg string) (bool, error) {
|
func isInstalled(ctx context.Context, pkg string, v5 bool) (bool, error) {
|
||||||
c := exec.CommandContext(ctx, "dnf", "list", "installed", pkg)
|
args := []string{"list", "installed", pkg}
|
||||||
|
if v5 {
|
||||||
|
args = []string{"list", "--installed", pkg}
|
||||||
|
}
|
||||||
|
c := exec.CommandContext(ctx, "dnf", args...)
|
||||||
err := c.Run()
|
err := c.Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||||
@@ -162,15 +198,21 @@ func isInstalled(ctx context.Context, pkg string) (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func version(ctx context.Context, pkg string) (string, error) {
|
func version(ctx context.Context, pkg string, v5 bool) (string, error) {
|
||||||
out, err := run(ctx, []string{"list", "installed", pkg}, snack.Options{})
|
args := append(listArgs(v5), pkg)
|
||||||
|
out, err := run(ctx, args, snack.Options{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "exit status 1") {
|
if strings.Contains(err.Error(), "exit status 1") {
|
||||||
return "", fmt.Errorf("dnf version %s: %w", pkg, snack.ErrNotInstalled)
|
return "", fmt.Errorf("dnf version %s: %w", pkg, snack.ErrNotInstalled)
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("dnf version: %w", err)
|
return "", fmt.Errorf("dnf version: %w", err)
|
||||||
}
|
}
|
||||||
pkgs := parseList(out)
|
var pkgs []snack.Package
|
||||||
|
if v5 {
|
||||||
|
pkgs = parseListDNF5(out)
|
||||||
|
} else {
|
||||||
|
pkgs = parseList(out)
|
||||||
|
}
|
||||||
if len(pkgs) == 0 {
|
if len(pkgs) == 0 {
|
||||||
return "", fmt.Errorf("dnf version %s: %w", pkg, snack.ErrNotInstalled)
|
return "", fmt.Errorf("dnf version %s: %w", pkg, snack.ErrNotInstalled)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
|
|
||||||
func available() bool { return false }
|
func available() bool { return false }
|
||||||
|
|
||||||
|
func (d *DNF) detectVersion() {}
|
||||||
|
|
||||||
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
|
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
|
||||||
return snack.ErrUnsupportedPlatform
|
return snack.ErrUnsupportedPlatform
|
||||||
}
|
}
|
||||||
@@ -26,22 +28,22 @@ func update(_ context.Context) error {
|
|||||||
return snack.ErrUnsupportedPlatform
|
return snack.ErrUnsupportedPlatform
|
||||||
}
|
}
|
||||||
|
|
||||||
func list(_ context.Context) ([]snack.Package, error) {
|
func list(_ context.Context, _ bool) ([]snack.Package, error) {
|
||||||
return nil, snack.ErrUnsupportedPlatform
|
return nil, snack.ErrUnsupportedPlatform
|
||||||
}
|
}
|
||||||
|
|
||||||
func search(_ context.Context, _ string) ([]snack.Package, error) {
|
func search(_ context.Context, _ string, _ bool) ([]snack.Package, error) {
|
||||||
return nil, snack.ErrUnsupportedPlatform
|
return nil, snack.ErrUnsupportedPlatform
|
||||||
}
|
}
|
||||||
|
|
||||||
func info(_ context.Context, _ string) (*snack.Package, error) {
|
func info(_ context.Context, _ string, _ bool) (*snack.Package, error) {
|
||||||
return nil, snack.ErrUnsupportedPlatform
|
return nil, snack.ErrUnsupportedPlatform
|
||||||
}
|
}
|
||||||
|
|
||||||
func isInstalled(_ context.Context, _ string) (bool, error) {
|
func isInstalled(_ context.Context, _ string, _ bool) (bool, error) {
|
||||||
return false, snack.ErrUnsupportedPlatform
|
return false, snack.ErrUnsupportedPlatform
|
||||||
}
|
}
|
||||||
|
|
||||||
func version(_ context.Context, _ string) (string, error) {
|
func version(_ context.Context, _ string, _ bool) (string, error) {
|
||||||
return "", snack.ErrUnsupportedPlatform
|
return "", snack.ErrUnsupportedPlatform
|
||||||
}
|
}
|
||||||
|
|||||||
231
dnf/parse_dnf5.go
Normal file
231
dnf/parse_dnf5.go
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
package dnf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gogrlx/snack"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stripPreamble removes dnf5 repository loading output that appears on stdout.
|
||||||
|
// It strips everything from "Updating and loading repositories:" through
|
||||||
|
// "Repositories loaded." inclusive.
|
||||||
|
func stripPreamble(output string) string {
|
||||||
|
lines := strings.Split(output, "\n")
|
||||||
|
var result []string
|
||||||
|
inPreamble := false
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(trimmed, "Updating and loading repositories:") {
|
||||||
|
inPreamble = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inPreamble {
|
||||||
|
if strings.HasPrefix(trimmed, "Repositories loaded.") {
|
||||||
|
inPreamble = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, line)
|
||||||
|
}
|
||||||
|
return strings.Join(result, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseListDNF5 parses `dnf5 list --installed` / `dnf5 list --upgrades` output.
|
||||||
|
// Format:
|
||||||
|
//
|
||||||
|
// Installed packages
|
||||||
|
// name.arch version-release repo-hash
|
||||||
|
func parseListDNF5(output string) []snack.Package {
|
||||||
|
output = stripPreamble(output)
|
||||||
|
var pkgs []snack.Package
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip section headers
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if lower == "installed packages" || lower == "available packages" ||
|
||||||
|
lower == "upgraded packages" || lower == "available upgrades" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Fields(trimmed)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nameArch := parts[0]
|
||||||
|
ver := parts[1]
|
||||||
|
repo := ""
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
repo = parts[2]
|
||||||
|
}
|
||||||
|
name, arch := parseArch(nameArch)
|
||||||
|
pkgs = append(pkgs, snack.Package{
|
||||||
|
Name: name,
|
||||||
|
Version: ver,
|
||||||
|
Arch: arch,
|
||||||
|
Repository: repo,
|
||||||
|
Installed: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSearchDNF5 parses `dnf5 search` output.
|
||||||
|
// Format:
|
||||||
|
//
|
||||||
|
// Matched fields: name
|
||||||
|
// name.arch Description text
|
||||||
|
// Matched fields: summary
|
||||||
|
// name.arch Description text
|
||||||
|
func parseSearchDNF5(output string) []snack.Package {
|
||||||
|
output = stripPreamble(output)
|
||||||
|
var pkgs []snack.Package
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" || strings.HasPrefix(trimmed, "Matched fields:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Lines are: "name.arch Description"
|
||||||
|
// Split on first double-space or use Fields
|
||||||
|
parts := strings.Fields(trimmed)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nameArch := parts[0]
|
||||||
|
if !strings.Contains(nameArch, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
desc := strings.Join(parts[1:], " ")
|
||||||
|
name, arch := parseArch(nameArch)
|
||||||
|
pkgs = append(pkgs, snack.Package{
|
||||||
|
Name: name,
|
||||||
|
Arch: arch,
|
||||||
|
Description: desc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInfoDNF5 parses `dnf5 info` output.
|
||||||
|
// Format: "Key : Value" lines with possible section headers like "Available packages".
|
||||||
|
func parseInfoDNF5(output string) *snack.Package {
|
||||||
|
output = stripPreamble(output)
|
||||||
|
pkg := &snack.Package{}
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
idx := strings.Index(line, " : ")
|
||||||
|
if idx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(line[:idx])
|
||||||
|
val := strings.TrimSpace(line[idx+3:])
|
||||||
|
switch key {
|
||||||
|
case "Name":
|
||||||
|
pkg.Name = val
|
||||||
|
case "Version":
|
||||||
|
pkg.Version = val
|
||||||
|
case "Release":
|
||||||
|
if pkg.Version != "" {
|
||||||
|
pkg.Version = pkg.Version + "-" + val
|
||||||
|
}
|
||||||
|
case "Architecture", "Arch":
|
||||||
|
pkg.Arch = val
|
||||||
|
case "Summary":
|
||||||
|
pkg.Description = val
|
||||||
|
case "Repository", "From repo":
|
||||||
|
pkg.Repository = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pkg.Name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return pkg
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRepoListDNF5 parses `dnf5 repolist --all` output.
|
||||||
|
// Same tabular format as dnf4, reuses the same logic.
|
||||||
|
func parseRepoListDNF5(output string) []snack.Repository {
|
||||||
|
output = stripPreamble(output)
|
||||||
|
return parseRepoList(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGroupListDNF5 parses `dnf5 group list` tabular output.
|
||||||
|
// Format:
|
||||||
|
//
|
||||||
|
// ID Name Installed
|
||||||
|
// neuron-modelling-simulators Neuron Modelling Simulators no
|
||||||
|
func parseGroupListDNF5(output string) []string {
|
||||||
|
output = stripPreamble(output)
|
||||||
|
var groups []string
|
||||||
|
inBody := false
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inBody {
|
||||||
|
if strings.HasPrefix(trimmed, "ID") && strings.Contains(trimmed, "Name") {
|
||||||
|
inBody = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Last field is "yes"/"no", second-to-last through first space group is name.
|
||||||
|
// Parse: first token is ID, last token is yes/no, middle is name.
|
||||||
|
parts := strings.Fields(trimmed)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Last field is installed status
|
||||||
|
status := parts[len(parts)-1]
|
||||||
|
if status != "yes" && status != "no" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.Join(parts[1:len(parts)-1], " ")
|
||||||
|
groups = append(groups, name)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGroupInfoDNF5 parses `dnf5 group info` output.
|
||||||
|
// Format:
|
||||||
|
//
|
||||||
|
// Id : kde-desktop
|
||||||
|
// Mandatory packages : plasma-desktop
|
||||||
|
// : plasma-workspace
|
||||||
|
// Default packages : NetworkManager-config-connectivity-fedora
|
||||||
|
func parseGroupInfoDNF5(output string) []snack.Package {
|
||||||
|
output = stripPreamble(output)
|
||||||
|
var pkgs []snack.Package
|
||||||
|
inPkgSection := false
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
idx := strings.Index(line, " : ")
|
||||||
|
if idx < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(line[:idx])
|
||||||
|
val := strings.TrimSpace(line[idx+3:])
|
||||||
|
|
||||||
|
if key == "Mandatory packages" || key == "Default packages" ||
|
||||||
|
key == "Optional packages" || key == "Conditional packages" {
|
||||||
|
inPkgSection = true
|
||||||
|
if val != "" {
|
||||||
|
pkgs = append(pkgs, snack.Package{Name: val})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continuation line: key is empty
|
||||||
|
if key == "" && inPkgSection && val != "" {
|
||||||
|
pkgs = append(pkgs, snack.Package{Name: val})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any other key ends the package section
|
||||||
|
if key != "" {
|
||||||
|
inPkgSection = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pkgs
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package dnf
|
package dnf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gogrlx/snack"
|
"github.com/gogrlx/snack"
|
||||||
@@ -220,6 +221,165 @@ func TestParseArch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- dnf5 parser tests ---
|
||||||
|
|
||||||
|
func TestStripPreamble(t *testing.T) {
|
||||||
|
input := "Updating and loading repositories:\n Fedora 43 - x86_64 100% | 10.2 MiB/s | 20.5 MiB | 00m02s\nRepositories loaded.\nInstalled packages\nbash.x86_64 5.3.0-2.fc43 abc123\n"
|
||||||
|
got := stripPreamble(input)
|
||||||
|
if strings.Contains(got, "Updating and loading") {
|
||||||
|
t.Error("preamble not stripped")
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "Repositories loaded") {
|
||||||
|
t.Error("preamble tail not stripped")
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "bash.x86_64") {
|
||||||
|
t.Error("content was incorrectly stripped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseListDNF5(t *testing.T) {
|
||||||
|
input := `Installed packages
|
||||||
|
alternatives.x86_64 1.33-3.fc43 a899a9b296804e8ab27411270a04f5e9
|
||||||
|
bash.x86_64 5.3.0-2.fc43 3b3d0b7480cd48d19a2c4259e547f2da
|
||||||
|
`
|
||||||
|
pkgs := parseListDNF5(input)
|
||||||
|
if len(pkgs) != 2 {
|
||||||
|
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||||
|
}
|
||||||
|
if pkgs[0].Name != "alternatives" || pkgs[0].Version != "1.33-3.fc43" || pkgs[0].Arch != "x86_64" {
|
||||||
|
t.Errorf("unexpected pkg[0]: %+v", pkgs[0])
|
||||||
|
}
|
||||||
|
if pkgs[1].Name != "bash" || pkgs[1].Version != "5.3.0-2.fc43" {
|
||||||
|
t.Errorf("unexpected pkg[1]: %+v", pkgs[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseListDNF5WithPreamble(t *testing.T) {
|
||||||
|
input := "Updating and loading repositories:\nRepositories loaded.\nInstalled packages\nbash.x86_64 5.3.0-2.fc43 abc\n"
|
||||||
|
pkgs := parseListDNF5(input)
|
||||||
|
if len(pkgs) != 1 {
|
||||||
|
t.Fatalf("expected 1 package, got %d", len(pkgs))
|
||||||
|
}
|
||||||
|
if pkgs[0].Name != "bash" {
|
||||||
|
t.Errorf("expected bash, got %q", pkgs[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSearchDNF5(t *testing.T) {
|
||||||
|
input := `Matched fields: name
|
||||||
|
tree.x86_64 File system tree viewer
|
||||||
|
treescan.noarch Scan directory trees, list directories/files, stat, sync, grep
|
||||||
|
Matched fields: summary
|
||||||
|
baobab.x86_64 A graphical directory tree analyzer
|
||||||
|
`
|
||||||
|
pkgs := parseSearchDNF5(input)
|
||||||
|
if len(pkgs) != 3 {
|
||||||
|
t.Fatalf("expected 3 packages, got %d", len(pkgs))
|
||||||
|
}
|
||||||
|
if pkgs[0].Name != "tree" || pkgs[0].Arch != "x86_64" {
|
||||||
|
t.Errorf("unexpected pkg[0]: %+v", pkgs[0])
|
||||||
|
}
|
||||||
|
if pkgs[0].Description != "File system tree viewer" {
|
||||||
|
t.Errorf("unexpected description: %q", pkgs[0].Description)
|
||||||
|
}
|
||||||
|
if pkgs[2].Name != "baobab" {
|
||||||
|
t.Errorf("unexpected pkg[2]: %+v", pkgs[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseInfoDNF5(t *testing.T) {
|
||||||
|
input := `Available packages
|
||||||
|
Name : tree
|
||||||
|
Epoch : 0
|
||||||
|
Version : 2.2.1
|
||||||
|
Release : 2.fc43
|
||||||
|
Architecture : x86_64
|
||||||
|
Download size : 61.3 KiB
|
||||||
|
Installed size : 112.2 KiB
|
||||||
|
Source : tree-pkg-2.2.1-2.fc43.src.rpm
|
||||||
|
Repository : fedora
|
||||||
|
Summary : File system tree viewer
|
||||||
|
`
|
||||||
|
p := parseInfoDNF5(input)
|
||||||
|
if p == nil {
|
||||||
|
t.Fatal("expected package, got nil")
|
||||||
|
}
|
||||||
|
if p.Name != "tree" {
|
||||||
|
t.Errorf("Name = %q, want tree", p.Name)
|
||||||
|
}
|
||||||
|
if p.Version != "2.2.1-2.fc43" {
|
||||||
|
t.Errorf("Version = %q, want 2.2.1-2.fc43", p.Version)
|
||||||
|
}
|
||||||
|
if p.Arch != "x86_64" {
|
||||||
|
t.Errorf("Arch = %q, want x86_64", p.Arch)
|
||||||
|
}
|
||||||
|
if p.Repository != "fedora" {
|
||||||
|
t.Errorf("Repository = %q, want fedora", p.Repository)
|
||||||
|
}
|
||||||
|
if p.Description != "File system tree viewer" {
|
||||||
|
t.Errorf("Description = %q", p.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGroupListDNF5(t *testing.T) {
|
||||||
|
input := `ID Name Installed
|
||||||
|
neuron-modelling-simulators Neuron Modelling Simulators no
|
||||||
|
kde-desktop KDE no
|
||||||
|
`
|
||||||
|
groups := parseGroupListDNF5(input)
|
||||||
|
if len(groups) != 2 {
|
||||||
|
t.Fatalf("expected 2 groups, got %d", len(groups))
|
||||||
|
}
|
||||||
|
if groups[0] != "Neuron Modelling Simulators" {
|
||||||
|
t.Errorf("groups[0] = %q", groups[0])
|
||||||
|
}
|
||||||
|
if groups[1] != "KDE" {
|
||||||
|
t.Errorf("groups[1] = %q", groups[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGroupInfoDNF5(t *testing.T) {
|
||||||
|
input := `Id : kde-desktop
|
||||||
|
Name : KDE
|
||||||
|
Description : The KDE Plasma Workspaces...
|
||||||
|
Installed : no
|
||||||
|
Mandatory packages : plasma-desktop
|
||||||
|
: plasma-workspace
|
||||||
|
Default packages : NetworkManager-config-connectivity-fedora
|
||||||
|
`
|
||||||
|
pkgs := parseGroupInfoDNF5(input)
|
||||||
|
if len(pkgs) != 3 {
|
||||||
|
t.Fatalf("expected 3 packages, got %d", len(pkgs))
|
||||||
|
}
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, p := range pkgs {
|
||||||
|
names[p.Name] = true
|
||||||
|
}
|
||||||
|
for _, want := range []string{"plasma-desktop", "plasma-workspace", "NetworkManager-config-connectivity-fedora"} {
|
||||||
|
if !names[want] {
|
||||||
|
t.Errorf("missing package %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRepoListDNF5(t *testing.T) {
|
||||||
|
input := `repo id repo name status
|
||||||
|
fedora Fedora 43 - x86_64 enabled
|
||||||
|
updates Fedora 43 - x86_64 - Updates enabled
|
||||||
|
updates-testing Fedora 43 - x86_64 - Test Updates disabled
|
||||||
|
`
|
||||||
|
repos := parseRepoListDNF5(input)
|
||||||
|
if len(repos) != 3 {
|
||||||
|
t.Fatalf("expected 3 repos, got %d", len(repos))
|
||||||
|
}
|
||||||
|
if repos[0].ID != "fedora" || !repos[0].Enabled {
|
||||||
|
t.Errorf("unexpected repo[0]: %+v", repos[0])
|
||||||
|
}
|
||||||
|
if repos[2].ID != "updates-testing" || repos[2].Enabled {
|
||||||
|
t.Errorf("unexpected repo[2]: %+v", repos[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure interface checks from capabilities.go are satisfied.
|
// Ensure interface checks from capabilities.go are satisfied.
|
||||||
var (
|
var (
|
||||||
_ snack.Manager = (*DNF)(nil)
|
_ snack.Manager = (*DNF)(nil)
|
||||||
|
|||||||
Reference in New Issue
Block a user