mirror of
https://github.com/gogrlx/snack.git
synced 2026-04-02 05:08:42 -07:00
Merge pull request #9 from gogrlx/cd/dnf-rpm
feat: add dnf and rpm package manager implementations
This commit is contained in:
147
dnf/capabilities.go
Normal file
147
dnf/capabilities.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package dnf
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ snack.VersionQuerier = (*DNF)(nil)
|
||||
_ snack.Holder = (*DNF)(nil)
|
||||
_ snack.Cleaner = (*DNF)(nil)
|
||||
_ snack.FileOwner = (*DNF)(nil)
|
||||
_ snack.RepoManager = (*DNF)(nil)
|
||||
_ snack.KeyManager = (*DNF)(nil)
|
||||
_ snack.Grouper = (*DNF)(nil)
|
||||
_ snack.NameNormalizer = (*DNF)(nil)
|
||||
)
|
||||
|
||||
// LatestVersion returns the latest available version from configured repositories.
|
||||
func (d *DNF) LatestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
return latestVersion(ctx, pkg)
|
||||
}
|
||||
|
||||
// ListUpgrades returns packages that have newer versions available.
|
||||
func (d *DNF) ListUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
return listUpgrades(ctx)
|
||||
}
|
||||
|
||||
// UpgradeAvailable reports whether a newer version is available.
|
||||
func (d *DNF) UpgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
return upgradeAvailable(ctx, pkg)
|
||||
}
|
||||
|
||||
// VersionCmp compares two version strings using RPM version comparison.
|
||||
func (d *DNF) VersionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
|
||||
return versionCmp(ctx, ver1, ver2)
|
||||
}
|
||||
|
||||
// Hold pins packages at their current version.
|
||||
func (d *DNF) Hold(ctx context.Context, pkgs []string) error {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
return hold(ctx, pkgs)
|
||||
}
|
||||
|
||||
// Unhold removes version pins.
|
||||
func (d *DNF) Unhold(ctx context.Context, pkgs []string) error {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
return unhold(ctx, pkgs)
|
||||
}
|
||||
|
||||
// ListHeld returns all currently held packages.
|
||||
func (d *DNF) ListHeld(ctx context.Context) ([]snack.Package, error) {
|
||||
return listHeld(ctx)
|
||||
}
|
||||
|
||||
// Autoremove removes orphaned packages.
|
||||
func (d *DNF) Autoremove(ctx context.Context, opts ...snack.Option) error {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
return autoremove(ctx, opts...)
|
||||
}
|
||||
|
||||
// Clean removes cached package files.
|
||||
func (d *DNF) Clean(ctx context.Context) error {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
return clean(ctx)
|
||||
}
|
||||
|
||||
// FileList returns all files installed by a package.
|
||||
func (d *DNF) FileList(ctx context.Context, pkg string) ([]string, error) {
|
||||
return fileList(ctx, pkg)
|
||||
}
|
||||
|
||||
// Owner returns the package that owns a given file path.
|
||||
func (d *DNF) Owner(ctx context.Context, path string) (string, error) {
|
||||
return owner(ctx, path)
|
||||
}
|
||||
|
||||
// ListRepos returns all configured package repositories.
|
||||
func (d *DNF) ListRepos(ctx context.Context) ([]snack.Repository, error) {
|
||||
return listRepos(ctx)
|
||||
}
|
||||
|
||||
// AddRepo adds a new package repository.
|
||||
func (d *DNF) AddRepo(ctx context.Context, repo snack.Repository) error {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
return addRepo(ctx, repo)
|
||||
}
|
||||
|
||||
// RemoveRepo removes a configured repository.
|
||||
func (d *DNF) RemoveRepo(ctx context.Context, id string) error {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
return removeRepo(ctx, id)
|
||||
}
|
||||
|
||||
// AddKey imports a GPG key for package verification.
|
||||
func (d *DNF) AddKey(ctx context.Context, key string) error {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
return addKey(ctx, key)
|
||||
}
|
||||
|
||||
// RemoveKey removes a GPG key.
|
||||
func (d *DNF) RemoveKey(ctx context.Context, keyID string) error {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
return removeKey(ctx, keyID)
|
||||
}
|
||||
|
||||
// ListKeys returns all trusted package signing keys.
|
||||
func (d *DNF) ListKeys(ctx context.Context) ([]string, error) {
|
||||
return listKeys(ctx)
|
||||
}
|
||||
|
||||
// GroupList returns all available package groups.
|
||||
func (d *DNF) GroupList(ctx context.Context) ([]string, error) {
|
||||
return groupList(ctx)
|
||||
}
|
||||
|
||||
// GroupInfo returns the packages in a group.
|
||||
func (d *DNF) GroupInfo(ctx context.Context, group string) ([]snack.Package, error) {
|
||||
return groupInfo(ctx, group)
|
||||
}
|
||||
|
||||
// GroupInstall installs all packages in a group.
|
||||
func (d *DNF) GroupInstall(ctx context.Context, group string, opts ...snack.Option) error {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
return groupInstall(ctx, group, opts...)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a package name.
|
||||
func (d *DNF) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a package name if present.
|
||||
func (d *DNF) ParseArch(name string) (string, string) {
|
||||
return parseArch(name)
|
||||
}
|
||||
231
dnf/capabilities_linux.go
Normal file
231
dnf/capabilities_linux.go
Normal file
@@ -0,0 +1,231 @@
|
||||
//go:build linux
|
||||
|
||||
package dnf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func latestVersion(ctx context.Context, pkg string) (string, error) {
|
||||
out, err := run(ctx, []string{"info", "--available", pkg}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return "", fmt.Errorf("dnf latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return "", fmt.Errorf("dnf latestVersion: %w", err)
|
||||
}
|
||||
p := parseInfo(out)
|
||||
if p == nil || p.Version == "" {
|
||||
return "", fmt.Errorf("dnf latestVersion %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return p.Version, nil
|
||||
}
|
||||
|
||||
func listUpgrades(ctx context.Context) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"list", "upgrades"}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("dnf listUpgrades: %w", err)
|
||||
}
|
||||
return parseList(out), nil
|
||||
}
|
||||
|
||||
func upgradeAvailable(ctx context.Context, pkg string) (bool, error) {
|
||||
c := exec.CommandContext(ctx, "dnf", "list", "upgrades", pkg)
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("dnf upgradeAvailable: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func versionCmp(ctx context.Context, ver1, ver2 string) (int, error) {
|
||||
c := exec.CommandContext(ctx, "rpmdev-vercmp", ver1, ver2)
|
||||
var stdout bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
err := c.Run()
|
||||
out := strings.TrimSpace(stdout.String())
|
||||
// rpmdev-vercmp exits 0 for equal, 11 for ver1 > ver2, 12 for ver1 < ver2
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
switch exitErr.ExitCode() {
|
||||
case 11:
|
||||
return 1, nil
|
||||
case 12:
|
||||
return -1, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("rpmdev-vercmp: %s: %w", out, err)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func hold(ctx context.Context, pkgs []string) error {
|
||||
args := append([]string{"versionlock", "add"}, pkgs...)
|
||||
_, err := run(ctx, args, snack.Options{})
|
||||
return err
|
||||
}
|
||||
|
||||
func unhold(ctx context.Context, pkgs []string) error {
|
||||
args := append([]string{"versionlock", "delete"}, pkgs...)
|
||||
_, err := run(ctx, args, snack.Options{})
|
||||
return err
|
||||
}
|
||||
|
||||
func listHeld(ctx context.Context) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"versionlock", "list"}, snack.Options{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dnf listHeld: %w", err)
|
||||
}
|
||||
return parseVersionLock(out), nil
|
||||
}
|
||||
|
||||
func autoremove(ctx context.Context, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
_, err := run(ctx, []string{"autoremove", "-y"}, o)
|
||||
return err
|
||||
}
|
||||
|
||||
func clean(ctx context.Context) error {
|
||||
_, err := run(ctx, []string{"clean", "all"}, snack.Options{})
|
||||
return err
|
||||
}
|
||||
|
||||
func fileList(ctx context.Context, pkg string) ([]string, error) {
|
||||
c := exec.CommandContext(ctx, "rpm", "-ql", pkg)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
se := stderr.String()
|
||||
if strings.Contains(se, "is not installed") {
|
||||
return nil, fmt.Errorf("dnf fileList %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return nil, fmt.Errorf("rpm -ql: %s: %w", strings.TrimSpace(se), err)
|
||||
}
|
||||
var files []string
|
||||
for _, line := range strings.Split(stdout.String(), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.HasPrefix(line, "(contains no files)") {
|
||||
files = append(files, line)
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func owner(ctx context.Context, path string) (string, error) {
|
||||
c := exec.CommandContext(ctx, "rpm", "-qf", path)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
se := stderr.String()
|
||||
if strings.Contains(se, "is not owned by any package") {
|
||||
return "", fmt.Errorf("dnf owner %s: %w", path, snack.ErrNotFound)
|
||||
}
|
||||
return "", fmt.Errorf("rpm -qf: %s: %w", strings.TrimSpace(se), err)
|
||||
}
|
||||
return strings.TrimSpace(stdout.String()), nil
|
||||
}
|
||||
|
||||
func listRepos(ctx context.Context) ([]snack.Repository, error) {
|
||||
out, err := run(ctx, []string{"repolist", "--all"}, snack.Options{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dnf listRepos: %w", err)
|
||||
}
|
||||
return parseRepoList(out), nil
|
||||
}
|
||||
|
||||
func addRepo(ctx context.Context, repo snack.Repository) error {
|
||||
_, err := run(ctx, []string{"config-manager", "--add-repo", repo.URL}, snack.Options{})
|
||||
return err
|
||||
}
|
||||
|
||||
func removeRepo(_ context.Context, id string) error {
|
||||
// Remove the repo file from /etc/yum.repos.d/
|
||||
repoFile := filepath.Join("/etc/yum.repos.d", id+".repo")
|
||||
if err := os.Remove(repoFile); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("dnf removeRepo %s: %w", id, snack.ErrNotFound)
|
||||
}
|
||||
return fmt.Errorf("dnf removeRepo %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addKey(ctx context.Context, key string) error {
|
||||
c := exec.CommandContext(ctx, "rpm", "--import", key)
|
||||
var stderr bytes.Buffer
|
||||
c.Stderr = &stderr
|
||||
if err := c.Run(); err != nil {
|
||||
return fmt.Errorf("rpm --import: %s: %w", strings.TrimSpace(stderr.String()), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeKey(ctx context.Context, keyID string) error {
|
||||
c := exec.CommandContext(ctx, "rpm", "-e", keyID)
|
||||
var stderr bytes.Buffer
|
||||
c.Stderr = &stderr
|
||||
if err := c.Run(); err != nil {
|
||||
return fmt.Errorf("rpm -e: %s: %w", strings.TrimSpace(stderr.String()), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func listKeys(ctx context.Context) ([]string, error) {
|
||||
c := exec.CommandContext(ctx, "rpm", "-qa", "gpg-pubkey*")
|
||||
var stdout bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
if err := c.Run(); err != nil {
|
||||
return nil, fmt.Errorf("rpm -qa gpg-pubkey: %w", err)
|
||||
}
|
||||
var keys []string
|
||||
for _, line := range strings.Split(stdout.String(), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
keys = append(keys, line)
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func groupList(ctx context.Context) ([]string, error) {
|
||||
out, err := run(ctx, []string{"group", "list"}, snack.Options{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dnf groupList: %w", err)
|
||||
}
|
||||
return parseGroupList(out), nil
|
||||
}
|
||||
|
||||
func groupInfo(ctx context.Context, group string) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"group", "info", group}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, fmt.Errorf("dnf groupInfo %s: %w", group, snack.ErrNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("dnf groupInfo: %w", err)
|
||||
}
|
||||
return parseGroupInfo(out), nil
|
||||
}
|
||||
|
||||
func groupInstall(ctx context.Context, group string, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
_, err := run(ctx, []string{"group", "install", "-y", group}, o)
|
||||
return err
|
||||
}
|
||||
89
dnf/capabilities_other.go
Normal file
89
dnf/capabilities_other.go
Normal file
@@ -0,0 +1,89 @@
|
||||
//go:build !linux
|
||||
|
||||
package dnf
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func latestVersion(_ context.Context, _ string) (string, error) {
|
||||
return "", snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func listUpgrades(_ context.Context) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func upgradeAvailable(_ context.Context, _ string) (bool, error) {
|
||||
return false, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func versionCmp(_ context.Context, _, _ string) (int, error) {
|
||||
return 0, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func hold(_ context.Context, _ []string) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func unhold(_ context.Context, _ []string) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func listHeld(_ context.Context) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func autoremove(_ context.Context, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func clean(_ context.Context) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func fileList(_ context.Context, _ string) ([]string, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func owner(_ context.Context, _ string) (string, error) {
|
||||
return "", snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func listRepos(_ context.Context) ([]snack.Repository, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func addRepo(_ context.Context, _ snack.Repository) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func removeRepo(_ context.Context, _ string) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func addKey(_ context.Context, _ string) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func removeKey(_ context.Context, _ string) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func listKeys(_ context.Context) ([]string, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func groupList(_ context.Context) ([]string, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func groupInfo(_ context.Context, _ string) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func groupInstall(_ context.Context, _ string, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
87
dnf/dnf.go
87
dnf/dnf.go
@@ -1,2 +1,87 @@
|
||||
// Package dnf provides Go bindings for DNF (Fedora/RHEL package manager).
|
||||
// Package dnf provides Go bindings for the dnf package manager (Fedora/RHEL).
|
||||
package dnf
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// DNF wraps the dnf package manager CLI.
|
||||
type DNF struct {
|
||||
snack.Locker
|
||||
}
|
||||
|
||||
// New returns a new DNF manager.
|
||||
func New() *DNF {
|
||||
return &DNF{}
|
||||
}
|
||||
|
||||
// Name returns "dnf".
|
||||
func (d *DNF) Name() string { return "dnf" }
|
||||
|
||||
// Available reports whether dnf is present on the system.
|
||||
func (d *DNF) Available() bool { return available() }
|
||||
|
||||
// Install one or more packages.
|
||||
func (d *DNF) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
return install(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Remove one or more packages.
|
||||
func (d *DNF) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
return remove(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Purge removes packages including configuration files (same as Remove for dnf).
|
||||
func (d *DNF) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
return remove(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Upgrade all installed packages to their latest versions.
|
||||
func (d *DNF) Upgrade(ctx context.Context, opts ...snack.Option) error {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
return upgrade(ctx, opts...)
|
||||
}
|
||||
|
||||
// Update refreshes the package index/database.
|
||||
func (d *DNF) Update(ctx context.Context) error {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
return update(ctx)
|
||||
}
|
||||
|
||||
// List returns all installed packages.
|
||||
func (d *DNF) List(ctx context.Context) ([]snack.Package, error) {
|
||||
return list(ctx)
|
||||
}
|
||||
|
||||
// Search queries the repositories for packages matching the query.
|
||||
func (d *DNF) Search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
return search(ctx, query)
|
||||
}
|
||||
|
||||
// Info returns details about a specific package.
|
||||
func (d *DNF) Info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
return info(ctx, pkg)
|
||||
}
|
||||
|
||||
// IsInstalled reports whether a package is currently installed.
|
||||
func (d *DNF) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
return isInstalled(ctx, pkg)
|
||||
}
|
||||
|
||||
// Version returns the installed version of a package.
|
||||
func (d *DNF) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*DNF)(nil)
|
||||
|
||||
178
dnf/dnf_linux.go
Normal file
178
dnf/dnf_linux.go
Normal file
@@ -0,0 +1,178 @@
|
||||
//go:build linux
|
||||
|
||||
package dnf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool {
|
||||
_, err := exec.LookPath("dnf")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// buildArgs constructs the command name and argument list from the base args
|
||||
// and the provided options.
|
||||
func buildArgs(baseArgs []string, opts snack.Options) (string, []string) {
|
||||
cmd := "dnf"
|
||||
args := make([]string, 0, len(baseArgs)+4)
|
||||
|
||||
if opts.Root != "" {
|
||||
args = append(args, "--installroot="+opts.Root)
|
||||
}
|
||||
args = append(args, baseArgs...)
|
||||
if opts.AssumeYes {
|
||||
args = append(args, "-y")
|
||||
}
|
||||
if opts.DryRun {
|
||||
args = append(args, "--setopt=tsflags=test")
|
||||
}
|
||||
|
||||
if opts.Sudo {
|
||||
args = append([]string{cmd}, args...)
|
||||
cmd = "sudo"
|
||||
}
|
||||
return cmd, args
|
||||
}
|
||||
|
||||
func run(ctx context.Context, baseArgs []string, opts snack.Options) (string, error) {
|
||||
cmd, args := buildArgs(baseArgs, opts)
|
||||
c := exec.CommandContext(ctx, cmd, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
se := stderr.String()
|
||||
if strings.Contains(se, "permission denied") || strings.Contains(se, "requires root") ||
|
||||
strings.Contains(se, "This command has to be run with superuser privileges") {
|
||||
return "", fmt.Errorf("dnf: %w", snack.ErrPermissionDenied)
|
||||
}
|
||||
return "", fmt.Errorf("dnf: %s: %w", strings.TrimSpace(se), err)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// formatTargets converts targets to dnf CLI arguments.
|
||||
// DNF uses "pkg-version" for version pinning.
|
||||
func formatTargets(targets []snack.Target) []string {
|
||||
args := make([]string, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
if t.Source != "" {
|
||||
args = append(args, t.Source)
|
||||
} else if t.Version != "" {
|
||||
args = append(args, t.Name+"-"+t.Version)
|
||||
} else {
|
||||
args = append(args, t.Name)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
base := []string{"install", "-y"}
|
||||
if o.Refresh {
|
||||
base = append(base, "--refresh")
|
||||
}
|
||||
if o.FromRepo != "" {
|
||||
base = append(base, "--repo="+o.FromRepo)
|
||||
}
|
||||
if o.Reinstall {
|
||||
base[0] = "reinstall"
|
||||
}
|
||||
for _, t := range pkgs {
|
||||
if t.FromRepo != "" {
|
||||
base = append(base, "--repo="+t.FromRepo)
|
||||
break
|
||||
}
|
||||
}
|
||||
args := append(base, formatTargets(pkgs)...)
|
||||
_, err := run(ctx, args, o)
|
||||
return err
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
args := append([]string{"remove", "-y"}, snack.TargetNames(pkgs)...)
|
||||
_, err := run(ctx, args, o)
|
||||
return err
|
||||
}
|
||||
|
||||
func upgrade(ctx context.Context, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
_, err := run(ctx, []string{"upgrade", "-y"}, o)
|
||||
return err
|
||||
}
|
||||
|
||||
func update(ctx context.Context) error {
|
||||
_, err := run(ctx, []string{"makecache"}, snack.Options{})
|
||||
return err
|
||||
}
|
||||
|
||||
func list(ctx context.Context) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"list", "installed"}, snack.Options{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dnf list: %w", err)
|
||||
}
|
||||
return parseList(out), nil
|
||||
}
|
||||
|
||||
func search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"search", query}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("dnf search: %w", err)
|
||||
}
|
||||
return parseSearch(out), nil
|
||||
}
|
||||
|
||||
func info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
out, err := run(ctx, []string{"info", pkg}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, fmt.Errorf("dnf info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("dnf info: %w", err)
|
||||
}
|
||||
p := parseInfo(out)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("dnf info %s: %w", pkg, snack.ErrNotFound)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func isInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
c := exec.CommandContext(ctx, "dnf", "list", "installed", pkg)
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("dnf isInstalled: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func version(ctx context.Context, pkg string) (string, error) {
|
||||
out, err := run(ctx, []string{"list", "installed", pkg}, snack.Options{})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return "", fmt.Errorf("dnf version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return "", fmt.Errorf("dnf version: %w", err)
|
||||
}
|
||||
pkgs := parseList(out)
|
||||
if len(pkgs) == 0 {
|
||||
return "", fmt.Errorf("dnf version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return pkgs[0].Version, nil
|
||||
}
|
||||
47
dnf/dnf_other.go
Normal file
47
dnf/dnf_other.go
Normal file
@@ -0,0 +1,47 @@
|
||||
//go:build !linux
|
||||
|
||||
package dnf
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool { return false }
|
||||
|
||||
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func upgrade(_ context.Context, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func update(_ context.Context) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func list(_ context.Context) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func search(_ context.Context, _ string) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func info(_ context.Context, _ string) (*snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func isInstalled(_ context.Context, _ string) (bool, error) {
|
||||
return false, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func version(_ context.Context, _ string) (string, error) {
|
||||
return "", snack.ErrUnsupportedPlatform
|
||||
}
|
||||
297
dnf/parse.go
Normal file
297
dnf/parse.go
Normal file
@@ -0,0 +1,297 @@
|
||||
package dnf
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// knownArchs is the set of RPM architecture suffixes.
|
||||
var knownArchs = map[string]bool{
|
||||
"x86_64": true,
|
||||
"i686": true,
|
||||
"i386": true,
|
||||
"aarch64": true,
|
||||
"armv7hl": true,
|
||||
"ppc64le": true,
|
||||
"s390x": true,
|
||||
"noarch": true,
|
||||
"src": true,
|
||||
}
|
||||
|
||||
// normalizeName strips architecture suffixes from a package name.
|
||||
func normalizeName(name string) string {
|
||||
n, _ := parseArch(name)
|
||||
return n
|
||||
}
|
||||
|
||||
// parseArch extracts the architecture from a package name if present.
|
||||
// Returns the name without arch and the arch string.
|
||||
func parseArch(name string) (string, string) {
|
||||
idx := strings.LastIndex(name, ".")
|
||||
if idx < 0 {
|
||||
return name, ""
|
||||
}
|
||||
arch := name[idx+1:]
|
||||
if knownArchs[arch] {
|
||||
return name[:idx], arch
|
||||
}
|
||||
return name, ""
|
||||
}
|
||||
|
||||
// parseList parses the output of `dnf list installed` or `dnf list upgrades`.
|
||||
// Format (after header line):
|
||||
//
|
||||
// pkg-name.arch version-release repo
|
||||
func parseList(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
inBody := false
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Skip header lines until we see a line of dashes or the first package
|
||||
if !inBody {
|
||||
if strings.HasPrefix(line, "Installed Packages") ||
|
||||
strings.HasPrefix(line, "Available Upgrades") ||
|
||||
strings.HasPrefix(line, "Available Packages") ||
|
||||
strings.HasPrefix(line, "Upgraded Packages") {
|
||||
inBody = true
|
||||
continue
|
||||
}
|
||||
// Also skip metadata lines
|
||||
if strings.HasPrefix(line, "Last metadata") || strings.HasPrefix(line, "Updating") {
|
||||
continue
|
||||
}
|
||||
// If we see a line with fields that looks like a package, process it
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 && strings.Contains(parts[0], ".") {
|
||||
inBody = true
|
||||
// fall through to process
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
parts := strings.Fields(line)
|
||||
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
|
||||
}
|
||||
|
||||
// parseSearch parses the output of `dnf search`.
|
||||
// Format:
|
||||
//
|
||||
// === Name Exactly Matched: query ===
|
||||
// pkg-name.arch : Description text
|
||||
// === Name & Summary Matched: query ===
|
||||
// pkg-name.arch : Description text
|
||||
func parseSearch(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "===") || strings.HasPrefix(line, "Last metadata") {
|
||||
continue
|
||||
}
|
||||
// "pkg-name.arch : Description"
|
||||
idx := strings.Index(line, " : ")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
nameArch := strings.TrimSpace(line[:idx])
|
||||
desc := strings.TrimSpace(line[idx+3:])
|
||||
name, arch := parseArch(nameArch)
|
||||
pkgs = append(pkgs, snack.Package{
|
||||
Name: name,
|
||||
Arch: arch,
|
||||
Description: desc,
|
||||
})
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// parseInfo parses the output of `dnf info`.
|
||||
// Format is "Key : Value" lines.
|
||||
func parseInfo(output string) *snack.Package {
|
||||
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+1:])
|
||||
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 "From repo", "Repository":
|
||||
pkg.Repository = val
|
||||
}
|
||||
}
|
||||
if pkg.Name == "" {
|
||||
return nil
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
|
||||
// parseVersionLock parses `dnf versionlock list` output.
|
||||
// Lines are typically package NEVRA patterns like "pkg-0:1.2.3-4.el9.*"
|
||||
func parseVersionLock(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "Last metadata") || strings.HasPrefix(line, "Adding") {
|
||||
continue
|
||||
}
|
||||
// Try to extract name from NEVRA pattern
|
||||
// Format: "name-epoch:version-release.arch" or simpler variants
|
||||
name := line
|
||||
// Strip trailing .* glob
|
||||
name = strings.TrimSuffix(name, ".*")
|
||||
// Strip arch
|
||||
name, _ = parseArch(name)
|
||||
// Strip version-release: find epoch pattern like "-0:" or last "-" before version
|
||||
// Try epoch pattern first: "name-epoch:version-release"
|
||||
for {
|
||||
idx := strings.LastIndex(name, "-")
|
||||
if idx <= 0 {
|
||||
break
|
||||
}
|
||||
rest := name[idx+1:]
|
||||
// Check if what follows looks like a version or epoch (digit or epoch:)
|
||||
if len(rest) > 0 && (rest[0] >= '0' && rest[0] <= '9') {
|
||||
name = name[:idx]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if name != "" {
|
||||
pkgs = append(pkgs, snack.Package{Name: name, Installed: true})
|
||||
}
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// parseRepoList parses `dnf repolist --all` output.
|
||||
// Format:
|
||||
//
|
||||
// repo id repo name status
|
||||
// appstream CentOS Stream 9 - AppStream enabled
|
||||
func parseRepoList(output string) []snack.Repository {
|
||||
var repos []snack.Repository
|
||||
inBody := false
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if !inBody {
|
||||
if strings.HasPrefix(line, "repo id") {
|
||||
inBody = true
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
id := parts[0]
|
||||
enabled := false
|
||||
status := parts[len(parts)-1]
|
||||
if status == "enabled" {
|
||||
enabled = true
|
||||
}
|
||||
// Name is everything between id and status
|
||||
name := strings.Join(parts[1:len(parts)-1], " ")
|
||||
repos = append(repos, snack.Repository{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Enabled: enabled,
|
||||
})
|
||||
}
|
||||
return repos
|
||||
}
|
||||
|
||||
// parseGroupList parses `dnf group list` output.
|
||||
// Groups are listed under section headers like "Available Groups:" / "Installed Groups:".
|
||||
func parseGroupList(output string) []string {
|
||||
var groups []string
|
||||
inSection := false
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
if strings.HasSuffix(strings.TrimSpace(line), "Groups:") ||
|
||||
strings.HasSuffix(strings.TrimSpace(line), "groups:") {
|
||||
inSection = true
|
||||
continue
|
||||
}
|
||||
if !inSection {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
inSection = false
|
||||
continue
|
||||
}
|
||||
groups = append(groups, trimmed)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// parseGroupInfo parses `dnf group info <group>` output.
|
||||
// Packages are listed under section headers like "Mandatory Packages:", "Default Packages:", "Optional Packages:".
|
||||
func parseGroupInfo(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
inPkgSection := false
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
inPkgSection = false
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(trimmed, "Packages:") || strings.HasSuffix(trimmed, "packages:") {
|
||||
inPkgSection = true
|
||||
continue
|
||||
}
|
||||
if !inPkgSection {
|
||||
continue
|
||||
}
|
||||
name := trimmed
|
||||
// Strip leading marks like "=" or "-" or "+"
|
||||
if len(name) > 2 && (name[0] == '=' || name[0] == '-' || name[0] == '+') && name[1] == ' ' {
|
||||
name = name[2:]
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
pkgs = append(pkgs, snack.Package{Name: name})
|
||||
}
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
234
dnf/parse_test.go
Normal file
234
dnf/parse_test.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package dnf
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func TestParseList(t *testing.T) {
|
||||
input := `Last metadata expiration check: 0:42:03 ago on Wed 26 Feb 2025 10:00:00 AM UTC.
|
||||
Installed Packages
|
||||
acl.x86_64 2.3.1-4.el9 @anaconda
|
||||
bash.x86_64 5.1.8-6.el9 @anaconda
|
||||
curl.x86_64 7.76.1-23.el9 @baseos
|
||||
`
|
||||
pkgs := parseList(input)
|
||||
if len(pkgs) != 3 {
|
||||
t.Fatalf("expected 3 packages, got %d", len(pkgs))
|
||||
}
|
||||
tests := []struct {
|
||||
name, ver, arch, repo string
|
||||
}{
|
||||
{"acl", "2.3.1-4.el9", "x86_64", "@anaconda"},
|
||||
{"bash", "5.1.8-6.el9", "x86_64", "@anaconda"},
|
||||
{"curl", "7.76.1-23.el9", "x86_64", "@baseos"},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
if pkgs[i].Name != tt.name {
|
||||
t.Errorf("pkg[%d].Name = %q, want %q", i, pkgs[i].Name, tt.name)
|
||||
}
|
||||
if pkgs[i].Version != tt.ver {
|
||||
t.Errorf("pkg[%d].Version = %q, want %q", i, pkgs[i].Version, tt.ver)
|
||||
}
|
||||
if pkgs[i].Arch != tt.arch {
|
||||
t.Errorf("pkg[%d].Arch = %q, want %q", i, pkgs[i].Arch, tt.arch)
|
||||
}
|
||||
if pkgs[i].Repository != tt.repo {
|
||||
t.Errorf("pkg[%d].Repository = %q, want %q", i, pkgs[i].Repository, tt.repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListUpgrades(t *testing.T) {
|
||||
input := `Available Upgrades
|
||||
curl.x86_64 7.76.1-26.el9 baseos
|
||||
vim-minimal.x86_64 2:9.0.1572-1.el9 appstream
|
||||
`
|
||||
pkgs := parseList(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "curl" || pkgs[0].Version != "7.76.1-26.el9" {
|
||||
t.Errorf("unexpected first package: %+v", pkgs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSearch(t *testing.T) {
|
||||
input := `Last metadata expiration check: 0:10:00 ago.
|
||||
=== Name Exactly Matched: nginx ===
|
||||
nginx.x86_64 : A high performance web server and reverse proxy server
|
||||
=== Name & Summary Matched: nginx ===
|
||||
nginx-mod-http-perl.x86_64 : Nginx HTTP perl module
|
||||
`
|
||||
pkgs := parseSearch(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "nginx" || pkgs[0].Arch != "x86_64" {
|
||||
t.Errorf("unexpected first package: %+v", pkgs[0])
|
||||
}
|
||||
if pkgs[0].Description != "A high performance web server and reverse proxy server" {
|
||||
t.Errorf("unexpected description: %q", pkgs[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfo(t *testing.T) {
|
||||
input := `Last metadata expiration check: 0:10:00 ago.
|
||||
Available Packages
|
||||
Name : nginx
|
||||
Version : 1.20.1
|
||||
Release : 14.el9_2.1
|
||||
Architecture : x86_64
|
||||
Size : 45 k
|
||||
Source : nginx-1.20.1-14.el9_2.1.src.rpm
|
||||
Repository : appstream
|
||||
Summary : A high performance web server
|
||||
License : BSD
|
||||
Description : Nginx is a web server.
|
||||
`
|
||||
p := parseInfo(input)
|
||||
if p == nil {
|
||||
t.Fatal("expected package, got nil")
|
||||
}
|
||||
if p.Name != "nginx" {
|
||||
t.Errorf("Name = %q, want nginx", p.Name)
|
||||
}
|
||||
if p.Version != "1.20.1-14.el9_2.1" {
|
||||
t.Errorf("Version = %q, want 1.20.1-14.el9_2.1", p.Version)
|
||||
}
|
||||
if p.Arch != "x86_64" {
|
||||
t.Errorf("Arch = %q, want x86_64", p.Arch)
|
||||
}
|
||||
if p.Repository != "appstream" {
|
||||
t.Errorf("Repository = %q, want appstream", p.Repository)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVersionLock(t *testing.T) {
|
||||
input := `Last metadata expiration check: 0:05:00 ago.
|
||||
nginx-0:1.20.1-14.el9_2.1.*
|
||||
curl-0:7.76.1-23.el9.*
|
||||
`
|
||||
pkgs := parseVersionLock(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "nginx" {
|
||||
t.Errorf("pkg[0].Name = %q, want nginx", pkgs[0].Name)
|
||||
}
|
||||
if pkgs[1].Name != "curl" {
|
||||
t.Errorf("pkg[1].Name = %q, want curl", pkgs[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRepoList(t *testing.T) {
|
||||
input := `repo id repo name status
|
||||
appstream CentOS Stream 9 - AppStream enabled
|
||||
baseos CentOS Stream 9 - BaseOS enabled
|
||||
crb CentOS Stream 9 - CRB disabled
|
||||
`
|
||||
repos := parseRepoList(input)
|
||||
if len(repos) != 3 {
|
||||
t.Fatalf("expected 3 repos, got %d", len(repos))
|
||||
}
|
||||
if repos[0].ID != "appstream" || !repos[0].Enabled {
|
||||
t.Errorf("unexpected repo[0]: %+v", repos[0])
|
||||
}
|
||||
if repos[2].ID != "crb" || repos[2].Enabled {
|
||||
t.Errorf("unexpected repo[2]: %+v", repos[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGroupList(t *testing.T) {
|
||||
input := `Available Groups:
|
||||
Container Management
|
||||
Development Tools
|
||||
Headless Management
|
||||
Installed Groups:
|
||||
Minimal Install
|
||||
`
|
||||
groups := parseGroupList(input)
|
||||
if len(groups) != 4 {
|
||||
t.Fatalf("expected 4 groups, got %d", len(groups))
|
||||
}
|
||||
if groups[0] != "Container Management" {
|
||||
t.Errorf("groups[0] = %q, want Container Management", groups[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGroupInfo(t *testing.T) {
|
||||
input := `Group: Development Tools
|
||||
Description: A basic development environment.
|
||||
Mandatory Packages:
|
||||
autoconf
|
||||
automake
|
||||
gcc
|
||||
Default Packages:
|
||||
byacc
|
||||
flex
|
||||
Optional Packages:
|
||||
ElectricFence
|
||||
`
|
||||
pkgs := parseGroupInfo(input)
|
||||
if len(pkgs) != 6 {
|
||||
t.Fatalf("expected 6 packages, got %d", len(pkgs))
|
||||
}
|
||||
names := make(map[string]bool)
|
||||
for _, p := range pkgs {
|
||||
names[p.Name] = true
|
||||
}
|
||||
for _, want := range []string{"autoconf", "automake", "gcc", "byacc", "flex", "ElectricFence"} {
|
||||
if !names[want] {
|
||||
t.Errorf("missing package %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"nginx.x86_64", "nginx"},
|
||||
{"curl.aarch64", "curl"},
|
||||
{"bash.noarch", "bash"},
|
||||
{"python3", "python3"},
|
||||
{"glibc.i686", "glibc"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := normalizeName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArch(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, wantName, wantArch string
|
||||
}{
|
||||
{"nginx.x86_64", "nginx", "x86_64"},
|
||||
{"curl.aarch64", "curl", "aarch64"},
|
||||
{"bash", "bash", ""},
|
||||
{"python3.11.noarch", "python3.11", "noarch"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
name, arch := parseArch(tt.input)
|
||||
if name != tt.wantName || arch != tt.wantArch {
|
||||
t.Errorf("parseArch(%q) = (%q, %q), want (%q, %q)", tt.input, name, arch, tt.wantName, tt.wantArch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure interface checks from capabilities.go are satisfied.
|
||||
var (
|
||||
_ snack.Manager = (*DNF)(nil)
|
||||
_ snack.VersionQuerier = (*DNF)(nil)
|
||||
_ snack.Holder = (*DNF)(nil)
|
||||
_ snack.Cleaner = (*DNF)(nil)
|
||||
_ snack.FileOwner = (*DNF)(nil)
|
||||
_ snack.RepoManager = (*DNF)(nil)
|
||||
_ snack.KeyManager = (*DNF)(nil)
|
||||
_ snack.Grouper = (*DNF)(nil)
|
||||
_ snack.NameNormalizer = (*DNF)(nil)
|
||||
)
|
||||
33
rpm/capabilities.go
Normal file
33
rpm/capabilities.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package rpm
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ snack.FileOwner = (*RPM)(nil)
|
||||
_ snack.NameNormalizer = (*RPM)(nil)
|
||||
)
|
||||
|
||||
// FileList returns all files installed by a package.
|
||||
func (r *RPM) FileList(ctx context.Context, pkg string) ([]string, error) {
|
||||
return fileList(ctx, pkg)
|
||||
}
|
||||
|
||||
// Owner returns the package that owns a given file path.
|
||||
func (r *RPM) Owner(ctx context.Context, path string) (string, error) {
|
||||
return owner(ctx, path)
|
||||
}
|
||||
|
||||
// NormalizeName returns the canonical form of a package name.
|
||||
func (r *RPM) NormalizeName(name string) string {
|
||||
return normalizeName(name)
|
||||
}
|
||||
|
||||
// ParseArch extracts the architecture from a package name if present.
|
||||
func (r *RPM) ParseArch(name string) (string, string) {
|
||||
return parseArchSuffix(name)
|
||||
}
|
||||
3
rpm/capabilities_other.go
Normal file
3
rpm/capabilities_other.go
Normal file
@@ -0,0 +1,3 @@
|
||||
//go:build !linux
|
||||
|
||||
package rpm
|
||||
97
rpm/parse.go
Normal file
97
rpm/parse.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package rpm
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// knownArchs is the set of RPM architecture suffixes.
|
||||
var knownArchs = map[string]bool{
|
||||
"x86_64": true,
|
||||
"i686": true,
|
||||
"i386": true,
|
||||
"aarch64": true,
|
||||
"armv7hl": true,
|
||||
"ppc64le": true,
|
||||
"s390x": true,
|
||||
"noarch": true,
|
||||
"src": true,
|
||||
}
|
||||
|
||||
// normalizeName strips architecture suffixes from a package name.
|
||||
func normalizeName(name string) string {
|
||||
n, _ := parseArchSuffix(name)
|
||||
return n
|
||||
}
|
||||
|
||||
// parseArchSuffix extracts the architecture from a package name if present.
|
||||
func parseArchSuffix(name string) (string, string) {
|
||||
idx := strings.LastIndex(name, ".")
|
||||
if idx < 0 {
|
||||
return name, ""
|
||||
}
|
||||
arch := name[idx+1:]
|
||||
if knownArchs[arch] {
|
||||
return name[:idx], arch
|
||||
}
|
||||
return name, ""
|
||||
}
|
||||
|
||||
// parseList parses rpm -qa --queryformat output.
|
||||
// Format: "NAME\tVERSION-RELEASE\tSUMMARY\n"
|
||||
func parseList(output string) []snack.Package {
|
||||
var pkgs []snack.Package
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "\t", 3)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
pkg := snack.Package{
|
||||
Name: parts[0],
|
||||
Version: parts[1],
|
||||
Installed: true,
|
||||
}
|
||||
if len(parts) >= 3 {
|
||||
pkg.Description = parts[2]
|
||||
}
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
|
||||
// parseInfo parses rpm -qi output.
|
||||
// Format is "Key : Value" lines.
|
||||
func parseInfo(output string) *snack.Package {
|
||||
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+1:])
|
||||
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
|
||||
}
|
||||
}
|
||||
if pkg.Name == "" {
|
||||
return nil
|
||||
}
|
||||
return pkg
|
||||
}
|
||||
103
rpm/parse_test.go
Normal file
103
rpm/parse_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package rpm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func TestParseList(t *testing.T) {
|
||||
input := "bash\t5.1.8-6.el9\tThe GNU Bourne Again shell\ncurl\t7.76.1-23.el9\tA utility for getting files from remote servers\n"
|
||||
pkgs := parseList(input)
|
||||
if len(pkgs) != 2 {
|
||||
t.Fatalf("expected 2 packages, got %d", len(pkgs))
|
||||
}
|
||||
if pkgs[0].Name != "bash" || pkgs[0].Version != "5.1.8-6.el9" {
|
||||
t.Errorf("unexpected pkg[0]: %+v", pkgs[0])
|
||||
}
|
||||
if pkgs[0].Description != "The GNU Bourne Again shell" {
|
||||
t.Errorf("unexpected description: %q", pkgs[0].Description)
|
||||
}
|
||||
if !pkgs[0].Installed {
|
||||
t.Error("expected Installed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInfo(t *testing.T) {
|
||||
input := `Name : bash
|
||||
Version : 5.1.8
|
||||
Release : 6.el9
|
||||
Architecture: x86_64
|
||||
Install Date: Mon 01 Jan 2024 12:00:00 AM UTC
|
||||
Group : System Environment/Shells
|
||||
Size : 7896043
|
||||
License : GPLv3+
|
||||
Signature : RSA/SHA256, Mon 01 Jan 2024 12:00:00 AM UTC, Key ID abc123
|
||||
Source RPM : bash-5.1.8-6.el9.src.rpm
|
||||
Build Date : Mon 01 Jan 2024 12:00:00 AM UTC
|
||||
Build Host : builder.example.com
|
||||
Packager : CentOS Buildsys <bugs@centos.org>
|
||||
Vendor : CentOS
|
||||
URL : https://www.gnu.org/software/bash
|
||||
Summary : The GNU Bourne Again shell
|
||||
Description :
|
||||
The GNU Bourne Again shell (Bash) is a shell or command language
|
||||
interpreter that is compatible with the Bourne shell (sh).
|
||||
`
|
||||
p := parseInfo(input)
|
||||
if p == nil {
|
||||
t.Fatal("expected package, got nil")
|
||||
}
|
||||
if p.Name != "bash" {
|
||||
t.Errorf("Name = %q, want bash", p.Name)
|
||||
}
|
||||
if p.Version != "5.1.8-6.el9" {
|
||||
t.Errorf("Version = %q, want 5.1.8-6.el9", p.Version)
|
||||
}
|
||||
if p.Arch != "x86_64" {
|
||||
t.Errorf("Arch = %q, want x86_64", p.Arch)
|
||||
}
|
||||
if p.Description != "The GNU Bourne Again shell" {
|
||||
t.Errorf("Description = %q, want 'The GNU Bourne Again shell'", p.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"nginx.x86_64", "nginx"},
|
||||
{"curl.aarch64", "curl"},
|
||||
{"bash.noarch", "bash"},
|
||||
{"python3", "python3"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := normalizeName(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArchSuffix(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, wantName, wantArch string
|
||||
}{
|
||||
{"nginx.x86_64", "nginx", "x86_64"},
|
||||
{"bash", "bash", ""},
|
||||
{"glibc.i686", "glibc", "i686"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
name, arch := parseArchSuffix(tt.input)
|
||||
if name != tt.wantName || arch != tt.wantArch {
|
||||
t.Errorf("parseArchSuffix(%q) = (%q, %q), want (%q, %q)", tt.input, name, arch, tt.wantName, tt.wantArch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ snack.Manager = (*RPM)(nil)
|
||||
_ snack.FileOwner = (*RPM)(nil)
|
||||
_ snack.NameNormalizer = (*RPM)(nil)
|
||||
)
|
||||
85
rpm/rpm.go
85
rpm/rpm.go
@@ -1,2 +1,85 @@
|
||||
// Package rpm provides Go bindings for RPM (low-level Red Hat package tool).
|
||||
// Package rpm provides Go bindings for the rpm low-level package manager.
|
||||
package rpm
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
// RPM wraps the rpm package manager CLI.
|
||||
type RPM struct {
|
||||
snack.Locker
|
||||
}
|
||||
|
||||
// New returns a new RPM manager.
|
||||
func New() *RPM {
|
||||
return &RPM{}
|
||||
}
|
||||
|
||||
// Name returns "rpm".
|
||||
func (r *RPM) Name() string { return "rpm" }
|
||||
|
||||
// Available reports whether rpm is present on the system.
|
||||
func (r *RPM) Available() bool { return available() }
|
||||
|
||||
// Install one or more packages.
|
||||
func (r *RPM) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
r.Lock()
|
||||
defer r.Unlock()
|
||||
return install(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Remove one or more packages.
|
||||
func (r *RPM) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
r.Lock()
|
||||
defer r.Unlock()
|
||||
return remove(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Purge removes packages (same as Remove for rpm).
|
||||
func (r *RPM) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
r.Lock()
|
||||
defer r.Unlock()
|
||||
return remove(ctx, pkgs, opts...)
|
||||
}
|
||||
|
||||
// Upgrade upgrades packages from files.
|
||||
func (r *RPM) Upgrade(ctx context.Context, opts ...snack.Option) error {
|
||||
r.Lock()
|
||||
defer r.Unlock()
|
||||
return upgradeAll(ctx, opts...)
|
||||
}
|
||||
|
||||
// Update is not supported by rpm (use dnf instead).
|
||||
func (r *RPM) Update(_ context.Context) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
// List returns all installed packages.
|
||||
func (r *RPM) List(ctx context.Context) ([]snack.Package, error) {
|
||||
return list(ctx)
|
||||
}
|
||||
|
||||
// Search queries installed packages matching the query.
|
||||
func (r *RPM) Search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
return search(ctx, query)
|
||||
}
|
||||
|
||||
// Info returns details about a specific package.
|
||||
func (r *RPM) Info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
return info(ctx, pkg)
|
||||
}
|
||||
|
||||
// IsInstalled reports whether a package is currently installed.
|
||||
func (r *RPM) IsInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
return isInstalled(ctx, pkg)
|
||||
}
|
||||
|
||||
// Version returns the installed version of a package.
|
||||
func (r *RPM) Version(ctx context.Context, pkg string) (string, error) {
|
||||
return version(ctx, pkg)
|
||||
}
|
||||
|
||||
// Verify interface compliance at compile time.
|
||||
var _ snack.Manager = (*RPM)(nil)
|
||||
|
||||
182
rpm/rpm_linux.go
Normal file
182
rpm/rpm_linux.go
Normal file
@@ -0,0 +1,182 @@
|
||||
//go:build linux
|
||||
|
||||
package rpm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool {
|
||||
_, err := exec.LookPath("rpm")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string) (string, error) {
|
||||
c := exec.CommandContext(ctx, "rpm", args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
se := stderr.String()
|
||||
if strings.Contains(se, "permission denied") {
|
||||
return "", fmt.Errorf("rpm: %w", snack.ErrPermissionDenied)
|
||||
}
|
||||
return "", fmt.Errorf("rpm: %s: %w", strings.TrimSpace(se), err)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
func runWithSudo(ctx context.Context, args []string, sudo bool) (string, error) {
|
||||
cmd := "rpm"
|
||||
if sudo {
|
||||
args = append([]string{cmd}, args...)
|
||||
cmd = "sudo"
|
||||
}
|
||||
c := exec.CommandContext(ctx, cmd, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
se := stderr.String()
|
||||
if strings.Contains(se, "permission denied") {
|
||||
return "", fmt.Errorf("rpm: %w", snack.ErrPermissionDenied)
|
||||
}
|
||||
return "", fmt.Errorf("rpm: %s: %w", strings.TrimSpace(se), err)
|
||||
}
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// formatSources extracts source paths or names from targets for rpm -i/-U.
|
||||
func formatSources(targets []snack.Target) []string {
|
||||
args := make([]string, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
if t.Source != "" {
|
||||
args = append(args, t.Source)
|
||||
} else {
|
||||
args = append(args, t.Name)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
args := append([]string{"-i"}, formatSources(pkgs)...)
|
||||
_, err := runWithSudo(ctx, args, o.Sudo)
|
||||
return err
|
||||
}
|
||||
|
||||
func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error {
|
||||
o := snack.ApplyOptions(opts...)
|
||||
args := append([]string{"-e"}, snack.TargetNames(pkgs)...)
|
||||
_, err := runWithSudo(ctx, args, o.Sudo)
|
||||
return err
|
||||
}
|
||||
|
||||
func upgradeAll(ctx context.Context, opts ...snack.Option) error {
|
||||
// rpm -U requires specific files; upgradeAll with no targets is a no-op
|
||||
_ = opts
|
||||
return fmt.Errorf("rpm: upgrade requires specific package files: %w", snack.ErrUnsupportedPlatform)
|
||||
}
|
||||
|
||||
func list(ctx context.Context) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"-qa", "--queryformat", `%{NAME}\t%{VERSION}-%{RELEASE}\t%{SUMMARY}\n`})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rpm list: %w", err)
|
||||
}
|
||||
return parseList(out), nil
|
||||
}
|
||||
|
||||
func search(ctx context.Context, query string) ([]snack.Package, error) {
|
||||
out, err := run(ctx, []string{"-qa", "*" + query + "*", "--queryformat", `%{NAME}\t%{VERSION}-%{RELEASE}\t%{SUMMARY}\n`})
|
||||
if err != nil {
|
||||
// exit 1 = no matches
|
||||
if strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("rpm search: %w", err)
|
||||
}
|
||||
return parseList(out), nil
|
||||
}
|
||||
|
||||
func info(ctx context.Context, pkg string) (*snack.Package, error) {
|
||||
out, err := run(ctx, []string{"-qi", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "is not installed") ||
|
||||
strings.Contains(err.Error(), "exit status 1") {
|
||||
return nil, fmt.Errorf("rpm info %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return nil, fmt.Errorf("rpm info: %w", err)
|
||||
}
|
||||
p := parseInfo(out)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("rpm info %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
p.Installed = true
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func isInstalled(ctx context.Context, pkg string) (bool, error) {
|
||||
c := exec.CommandContext(ctx, "rpm", "-q", pkg)
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("rpm isInstalled: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func version(ctx context.Context, pkg string) (string, error) {
|
||||
c := exec.CommandContext(ctx, "rpm", "-q", "--queryformat", "%{VERSION}-%{RELEASE}", pkg)
|
||||
var stdout, stderr bytes.Buffer
|
||||
c.Stdout = &stdout
|
||||
c.Stderr = &stderr
|
||||
err := c.Run()
|
||||
if err != nil {
|
||||
se := stderr.String()
|
||||
if strings.Contains(se, "is not installed") || strings.Contains(err.Error(), "exit status 1") {
|
||||
return "", fmt.Errorf("rpm version %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return "", fmt.Errorf("rpm version: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(stdout.String()), nil
|
||||
}
|
||||
|
||||
func fileList(ctx context.Context, pkg string) ([]string, error) {
|
||||
out, err := run(ctx, []string{"-ql", pkg})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "is not installed") {
|
||||
return nil, fmt.Errorf("rpm fileList %s: %w", pkg, snack.ErrNotInstalled)
|
||||
}
|
||||
return nil, fmt.Errorf("rpm fileList: %w", err)
|
||||
}
|
||||
var files []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.HasPrefix(line, "(contains no files)") {
|
||||
files = append(files, line)
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func owner(ctx context.Context, path string) (string, error) {
|
||||
out, err := run(ctx, []string{"-qf", path})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "is not owned by any package") {
|
||||
return "", fmt.Errorf("rpm owner %s: %w", path, snack.ErrNotFound)
|
||||
}
|
||||
return "", fmt.Errorf("rpm owner: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
51
rpm/rpm_other.go
Normal file
51
rpm/rpm_other.go
Normal file
@@ -0,0 +1,51 @@
|
||||
//go:build !linux
|
||||
|
||||
package rpm
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogrlx/snack"
|
||||
)
|
||||
|
||||
func available() bool { return false }
|
||||
|
||||
func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func upgradeAll(_ context.Context, _ ...snack.Option) error {
|
||||
return snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func list(_ context.Context) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func search(_ context.Context, _ string) ([]snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func info(_ context.Context, _ string) (*snack.Package, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func isInstalled(_ context.Context, _ string) (bool, error) {
|
||||
return false, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func version(_ context.Context, _ string) (string, error) {
|
||||
return "", snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func fileList(_ context.Context, _ string) ([]string, error) {
|
||||
return nil, snack.ErrUnsupportedPlatform
|
||||
}
|
||||
|
||||
func owner(_ context.Context, _ string) (string, error) {
|
||||
return "", snack.ErrUnsupportedPlatform
|
||||
}
|
||||
Reference in New Issue
Block a user