From 0d6c5d9e172ba11861a91b67e9a4d42ab4035e40 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Wed, 25 Feb 2026 20:35:45 +0000 Subject: [PATCH] feat: add per-provider mutex and Target-aware implementations - Add snack.Locker embed for per-provider mutex serialization - Update all providers (pacman, apk, apt, dpkg) to use []Target with version pinning support (pkg=version syntax) - Add lock/unlock to all mutating operations (Install, Remove, Purge, Upgrade, Update) - Add snack.TargetNames helper and formatTargets per provider - apt: add FromRepo (-t) and Reinstall support - dpkg: use Target.Source for .deb file paths in Install --- apk/apk.go | 20 ++++++++++++++++---- apk/apk_linux.go | 26 ++++++++++++++++++++------ apk/apk_other.go | 6 +++--- apt/apt.go | 20 ++++++++++++++++---- apt/apt_linux.go | 32 ++++++++++++++++++++++++++------ apt/apt_other.go | 6 +++--- dpkg/dpkg.go | 16 ++++++++++++---- dpkg/dpkg_linux.go | 19 +++++++++++++------ dpkg/dpkg_other.go | 6 +++--- mutex.go | 27 +++++++++++++++++++++++++++ pacman/pacman.go | 20 ++++++++++++++++---- pacman/pacman_linux.go | 36 ++++++++++++++++++++++++++++++------ pacman/pacman_other.go | 6 +++--- target.go | 10 ++++++++++ 14 files changed, 198 insertions(+), 52 deletions(-) create mode 100644 mutex.go create mode 100644 target.go diff --git a/apk/apk.go b/apk/apk.go index 9631639..e841855 100644 --- a/apk/apk.go +++ b/apk/apk.go @@ -8,7 +8,9 @@ import ( ) // Apk wraps apk-tools operations. -type Apk struct{} +type Apk struct { + snack.Locker +} // New returns a new Apk manager. func New() *Apk { @@ -25,27 +27,37 @@ func (a *Apk) Name() string { return "apk" } func (a *Apk) Available() bool { return available() } // Install one or more packages. -func (a *Apk) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func (a *Apk) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + a.Lock() + defer a.Unlock() return install(ctx, pkgs, opts...) } // Remove one or more packages. -func (a *Apk) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func (a *Apk) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + a.Lock() + defer a.Unlock() return remove(ctx, pkgs, opts...) } // Purge removes packages including config files. -func (a *Apk) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func (a *Apk) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + a.Lock() + defer a.Unlock() return purge(ctx, pkgs, opts...) } // Upgrade all installed packages. func (a *Apk) Upgrade(ctx context.Context, opts ...snack.Option) error { + a.Lock() + defer a.Unlock() return upgrade(ctx, opts...) } // Update refreshes the package index. func (a *Apk) Update(ctx context.Context) error { + a.Lock() + defer a.Unlock() return update(ctx) } diff --git a/apk/apk_linux.go b/apk/apk_linux.go index 6fb0d4b..9f0063b 100644 --- a/apk/apk_linux.go +++ b/apk/apk_linux.go @@ -52,20 +52,34 @@ func run(ctx context.Context, base []string, opts ...snack.Option) (string, erro return strings.TrimSpace(string(out)), nil } -func install(ctx context.Context, pkgs []string, opts ...snack.Option) error { - args := append([]string{"add"}, pkgs...) +// formatTargets converts targets to apk CLI arguments. +// apk uses "pkg=version" for version pinning. +func formatTargets(targets []snack.Target) []string { + args := make([]string, 0, len(targets)) + for _, t := range targets { + 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 { + args := append([]string{"add"}, formatTargets(pkgs)...) _, err := run(ctx, args, opts...) return err } -func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { - args := append([]string{"del"}, pkgs...) +func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + args := append([]string{"del"}, snack.TargetNames(pkgs)...) _, err := run(ctx, args, opts...) return err } -func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { - args := append([]string{"del", "--purge"}, pkgs...) +func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + args := append([]string{"del", "--purge"}, snack.TargetNames(pkgs)...) _, err := run(ctx, args, opts...) return err } diff --git a/apk/apk_other.go b/apk/apk_other.go index 8e7ea61..ab5155a 100644 --- a/apk/apk_other.go +++ b/apk/apk_other.go @@ -10,15 +10,15 @@ import ( func available() bool { return false } -func install(_ context.Context, _ []string, _ ...snack.Option) error { +func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } -func remove(_ context.Context, _ []string, _ ...snack.Option) error { +func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } -func purge(_ context.Context, _ []string, _ ...snack.Option) error { +func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } diff --git a/apt/apt.go b/apt/apt.go index 7bb6620..ee2867d 100644 --- a/apt/apt.go +++ b/apt/apt.go @@ -8,7 +8,9 @@ import ( ) // Apt implements the snack.Manager interface using apt-get and apt-cache. -type Apt struct{} +type Apt struct { + snack.Locker +} // New returns a new Apt manager. func New() *Apt { @@ -19,27 +21,37 @@ func New() *Apt { func (a *Apt) Name() string { return "apt" } // Install one or more packages. -func (a *Apt) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func (a *Apt) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + a.Lock() + defer a.Unlock() return install(ctx, pkgs, opts...) } // Remove one or more packages. -func (a *Apt) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func (a *Apt) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + a.Lock() + defer a.Unlock() return remove(ctx, pkgs, opts...) } // Purge one or more packages including config files. -func (a *Apt) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func (a *Apt) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + a.Lock() + defer a.Unlock() return purge(ctx, pkgs, opts...) } // Upgrade all installed packages. func (a *Apt) Upgrade(ctx context.Context, opts ...snack.Option) error { + a.Lock() + defer a.Unlock() return upgrade(ctx, opts...) } // Update refreshes the package index. func (a *Apt) Update(ctx context.Context) error { + a.Lock() + defer a.Unlock() return update(ctx) } diff --git a/apt/apt_linux.go b/apt/apt_linux.go index 35e8229..8f8a6e3 100644 --- a/apt/apt_linux.go +++ b/apt/apt_linux.go @@ -17,7 +17,21 @@ func available() bool { return err == nil } -func buildArgs(command string, pkgs []string, opts ...snack.Option) []string { +// formatTargets converts targets to apt CLI arguments. +// apt uses "pkg=version" for version pinning. +func formatTargets(targets []snack.Target) []string { + args := make([]string, 0, len(targets)) + for _, t := range targets { + if t.Version != "" { + args = append(args, t.Name+"="+t.Version) + } else { + args = append(args, t.Name) + } + } + return args +} + +func buildArgs(command string, pkgs []snack.Target, opts ...snack.Option) []string { o := snack.ApplyOptions(opts...) var args []string if o.Sudo { @@ -30,11 +44,17 @@ func buildArgs(command string, pkgs []string, opts ...snack.Option) []string { if o.DryRun { args = append(args, "--dry-run") } - args = append(args, pkgs...) + if o.FromRepo != "" { + args = append(args, "-t", o.FromRepo) + } + if o.Reinstall && command == "install" { + args = append(args, "--reinstall") + } + args = append(args, formatTargets(pkgs)...) return args } -func runAptGet(ctx context.Context, command string, pkgs []string, opts ...snack.Option) error { +func runAptGet(ctx context.Context, command string, pkgs []snack.Target, opts ...snack.Option) error { args := buildArgs(command, pkgs, opts...) var cmd *exec.Cmd if args[0] == "sudo" { @@ -57,15 +77,15 @@ func runAptGet(ctx context.Context, command string, pkgs []string, opts ...snack return nil } -func install(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { return runAptGet(ctx, "install", pkgs, opts...) } -func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { return runAptGet(ctx, "remove", pkgs, opts...) } -func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { return runAptGet(ctx, "purge", pkgs, opts...) } diff --git a/apt/apt_other.go b/apt/apt_other.go index 55fa9d5..da908dd 100644 --- a/apt/apt_other.go +++ b/apt/apt_other.go @@ -10,15 +10,15 @@ import ( func available() bool { return false } -func install(_ context.Context, _ []string, _ ...snack.Option) error { +func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } -func remove(_ context.Context, _ []string, _ ...snack.Option) error { +func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } -func purge(_ context.Context, _ []string, _ ...snack.Option) error { +func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } diff --git a/dpkg/dpkg.go b/dpkg/dpkg.go index ae13949..3f6093a 100644 --- a/dpkg/dpkg.go +++ b/dpkg/dpkg.go @@ -8,7 +8,9 @@ import ( ) // Dpkg implements the snack.Manager interface using dpkg and dpkg-query. -type Dpkg struct{} +type Dpkg struct { + snack.Locker +} // New returns a new Dpkg manager. func New() *Dpkg { @@ -19,17 +21,23 @@ func New() *Dpkg { func (d *Dpkg) Name() string { return "dpkg" } // Install one or more .deb files. -func (d *Dpkg) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func (d *Dpkg) 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 *Dpkg) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func (d *Dpkg) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + d.Lock() + defer d.Unlock() return remove(ctx, pkgs, opts...) } // Purge one or more packages including config files. -func (d *Dpkg) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func (d *Dpkg) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + d.Lock() + defer d.Unlock() return purge(ctx, pkgs, opts...) } diff --git a/dpkg/dpkg_linux.go b/dpkg/dpkg_linux.go index a7cecaa..82ae927 100644 --- a/dpkg/dpkg_linux.go +++ b/dpkg/dpkg_linux.go @@ -17,7 +17,7 @@ func available() bool { return err == nil } -func install(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { o := snack.ApplyOptions(opts...) var args []string if o.Sudo { @@ -27,7 +27,14 @@ func install(ctx context.Context, pkgs []string, opts ...snack.Option) error { if o.DryRun { args = append(args, "--simulate") } - args = append(args, pkgs...) + // dpkg -i takes file paths; use Source if set, otherwise Name + for _, t := range pkgs { + if t.Source != "" { + args = append(args, t.Source) + } else { + args = append(args, t.Name) + } + } cmd := exec.CommandContext(ctx, args[0], args[1:]...) var stderr bytes.Buffer cmd.Stderr = &stderr @@ -41,7 +48,7 @@ func install(ctx context.Context, pkgs []string, opts ...snack.Option) error { return nil } -func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { o := snack.ApplyOptions(opts...) var args []string if o.Sudo { @@ -51,7 +58,7 @@ func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { if o.DryRun { args = append(args, "--simulate") } - args = append(args, pkgs...) + args = append(args, snack.TargetNames(pkgs)...) cmd := exec.CommandContext(ctx, args[0], args[1:]...) var stderr bytes.Buffer cmd.Stderr = &stderr @@ -61,7 +68,7 @@ func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { return nil } -func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { o := snack.ApplyOptions(opts...) var args []string if o.Sudo { @@ -71,7 +78,7 @@ func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { if o.DryRun { args = append(args, "--simulate") } - args = append(args, pkgs...) + args = append(args, snack.TargetNames(pkgs)...) cmd := exec.CommandContext(ctx, args[0], args[1:]...) var stderr bytes.Buffer cmd.Stderr = &stderr diff --git a/dpkg/dpkg_other.go b/dpkg/dpkg_other.go index a3e54d9..0109456 100644 --- a/dpkg/dpkg_other.go +++ b/dpkg/dpkg_other.go @@ -10,15 +10,15 @@ import ( func available() bool { return false } -func install(_ context.Context, _ []string, _ ...snack.Option) error { +func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } -func remove(_ context.Context, _ []string, _ ...snack.Option) error { +func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } -func purge(_ context.Context, _ []string, _ ...snack.Option) error { +func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } diff --git a/mutex.go b/mutex.go new file mode 100644 index 0000000..67f47bc --- /dev/null +++ b/mutex.go @@ -0,0 +1,27 @@ +package snack + +import "sync" + +// Locker is an embeddable type that provides per-provider mutex locking. +// Each package manager backend should embed this in its struct to serialize +// mutating operations (Install, Remove, Purge, Upgrade, Update). +// +// Read-only operations (List, Search, Info, IsInstalled, Version) generally +// don't need the lock, but backends may choose to lock them if the underlying +// tool doesn't support concurrent reads. +// +// Different providers use independent locks, so an apt Install and a snap +// Install can run concurrently, but two apt Installs will serialize. +type Locker struct { + mu sync.Mutex +} + +// Lock acquires the provider lock. Call before mutating operations. +func (l *Locker) Lock() { + l.mu.Lock() +} + +// Unlock releases the provider lock. Defer after Lock. +func (l *Locker) Unlock() { + l.mu.Unlock() +} diff --git a/pacman/pacman.go b/pacman/pacman.go index cb09899..5d7e7ea 100644 --- a/pacman/pacman.go +++ b/pacman/pacman.go @@ -8,7 +8,9 @@ import ( ) // Pacman wraps the pacman package manager CLI. -type Pacman struct{} +type Pacman struct { + snack.Locker +} // New returns a new Pacman manager. func New() *Pacman { @@ -22,27 +24,37 @@ func (p *Pacman) Name() string { return "pacman" } func (p *Pacman) Available() bool { return available() } // Install one or more packages. -func (p *Pacman) Install(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func (p *Pacman) Install(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + p.Lock() + defer p.Unlock() return install(ctx, pkgs, opts...) } // Remove one or more packages. -func (p *Pacman) Remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func (p *Pacman) Remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + p.Lock() + defer p.Unlock() return remove(ctx, pkgs, opts...) } // Purge removes packages including configuration files. -func (p *Pacman) Purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func (p *Pacman) Purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { + p.Lock() + defer p.Unlock() return purge(ctx, pkgs, opts...) } // Upgrade all installed packages to their latest versions. func (p *Pacman) Upgrade(ctx context.Context, opts ...snack.Option) error { + p.Lock() + defer p.Unlock() return upgrade(ctx, opts...) } // Update refreshes the package database. func (p *Pacman) Update(ctx context.Context) error { + p.Lock() + defer p.Unlock() return update(ctx) } diff --git a/pacman/pacman_linux.go b/pacman/pacman_linux.go index 62a9a02..2c2b0b7 100644 --- a/pacman/pacman_linux.go +++ b/pacman/pacman_linux.go @@ -58,23 +58,47 @@ func run(ctx context.Context, baseArgs []string, opts snack.Options) (string, er return stdout.String(), nil } -func install(ctx context.Context, pkgs []string, opts ...snack.Option) error { +// formatTargets converts targets to pacman CLI arguments. +// Pacman uses "pkg=version" for version pinning. +func formatTargets(targets []snack.Target) []string { + args := make([]string, 0, len(targets)) + for _, t := range targets { + 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...) - args := append([]string{"-S", "--noconfirm"}, pkgs...) + base := []string{"-S", "--noconfirm"} + if o.Refresh { + base = []string{"-Sy", "--noconfirm"} + } + for _, t := range pkgs { + if t.FromRepo != "" || o.FromRepo != "" { + // Not directly supported by pacman CLI; user should configure repos + break + } + } + args := append(base, formatTargets(pkgs)...) _, err := run(ctx, args, o) return err } -func remove(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func remove(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { o := snack.ApplyOptions(opts...) - args := append([]string{"-R", "--noconfirm"}, pkgs...) + args := append([]string{"-R", "--noconfirm"}, snack.TargetNames(pkgs)...) _, err := run(ctx, args, o) return err } -func purge(ctx context.Context, pkgs []string, opts ...snack.Option) error { +func purge(ctx context.Context, pkgs []snack.Target, opts ...snack.Option) error { o := snack.ApplyOptions(opts...) - args := append([]string{"-Rns", "--noconfirm"}, pkgs...) + args := append([]string{"-Rns", "--noconfirm"}, snack.TargetNames(pkgs)...) _, err := run(ctx, args, o) return err } diff --git a/pacman/pacman_other.go b/pacman/pacman_other.go index 585d2a3..08cc776 100644 --- a/pacman/pacman_other.go +++ b/pacman/pacman_other.go @@ -10,15 +10,15 @@ import ( func available() bool { return false } -func install(_ context.Context, _ []string, _ ...snack.Option) error { +func install(_ context.Context, _ []snack.Target, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } -func remove(_ context.Context, _ []string, _ ...snack.Option) error { +func remove(_ context.Context, _ []snack.Target, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } -func purge(_ context.Context, _ []string, _ ...snack.Option) error { +func purge(_ context.Context, _ []snack.Target, _ ...snack.Option) error { return snack.ErrUnsupportedPlatform } diff --git a/target.go b/target.go new file mode 100644 index 0000000..8c23244 --- /dev/null +++ b/target.go @@ -0,0 +1,10 @@ +package snack + +// TargetNames extracts just the package names from a slice of targets. +func TargetNames(targets []Target) []string { + names := make([]string, len(targets)) + for i, t := range targets { + names[i] = t.Name + } + return names +}