Files
nadir-agent/internal/modules/packages/packages.go
T
2026-06-24 17:29:45 +02:00

421 lines
13 KiB
Go

package packages
import (
"context"
"os/exec"
"regexp"
"strings"
"nadir/internal/oscmd"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/sse"
)
const tagPackages = "Packages"
var (
readErrors = []int{401, 403, 500}
writeErrors = []int{400, 401, 403, 500}
)
// pkgNameRe matches a plain package name (no version specifiers, no shell
// metacharacters). Combined with a leading-dash reject and "--" before the name
// on every command line, it keeps user input from being read as a flag.
var pkgNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9@._+-]*$`)
// manager identifies the host package manager. Its name drives a switch in each
// operation rather than an interface - three concrete tools, no polymorphism.
type manager struct{ name string }
// detect picks the package manager. dnf/pacman are checked before apt because a
// few rpm-based boxes also ship an apt-get shim; the native tool should win.
func detect() manager {
for _, m := range []struct{ name, bin string }{
{"dnf", "dnf"},
{"pacman", "pacman"},
{"apt", "apt-get"},
} {
if _, err := exec.LookPath(m.bin); err == nil {
return manager{name: m.name}
}
}
return manager{}
}
type Package struct {
Name string `json:"name" example:"openssh-server" doc:"Package name"`
Version string `json:"version" example:"9.6p1" doc:"Installed version, or the available version for updates"`
}
type ListOutput struct {
Body struct {
Manager string `json:"manager" example:"dnf" doc:"Detected package manager"`
Packages []Package `json:"packages"`
}
}
type InstallInput struct {
Body struct {
Name string `json:"name" example:"htop" doc:"Package to install"`
}
}
type RemoveInput struct {
Name string `path:"name" example:"htop" doc:"Package to remove"`
}
type UpgradeOneInput struct {
Name string `path:"name" example:"htop" doc:"Package to upgrade"`
}
// SSE event types for streaming package operations.
type PkgOutputEvent struct {
Line string `json:"line" doc:"One line of the package manager's terminal output"`
}
type PkgErrorEvent struct {
Message string `json:"message"`
}
type PkgDoneEvent struct {
Success bool `json:"success" doc:"True if the package manager exited 0"`
Error string `json:"error,omitempty" doc:"Exit error when it failed"`
}
// pkgSem limits concurrent package manager operations. Package managers are
// heavy (dnf/apt/pacman grab a lock), so running more than a few in parallel
// just thrashes; 3 concurrent ops is generous for an admin panel.
var pkgSem = make(chan struct{}, 3)
// pkgEvents maps SSE event names to their payload types for the streaming
// install/remove/upgrade operations.
var pkgEvents = map[string]any{
"output": PkgOutputEvent{},
"done": PkgDoneEvent{},
"error": PkgErrorEvent{},
}
func registerPackages(api huma.API, pm manager) {
huma.Register(api, huma.Operation{
OperationID: "packages-list-installed",
Method: "GET",
Path: "/api/packages",
Summary: "List installed packages",
Tags: []string{tagPackages},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListOutput, error) {
return listInstalled(pm)
})
huma.Register(api, huma.Operation{
OperationID: "packages-list-updates",
Method: "GET",
Path: "/api/packages/updates",
Summary: "List available updates",
Tags: []string{tagPackages},
Metadata: op("read"),
Errors: readErrors,
}, func(ctx context.Context, _ *struct{}) (*ListOutput, error) {
return listUpdates(pm)
})
// Install/remove/upgrade stream the package manager's terminal output live
// via SSE (one `output` event per line, a final `done` with exit status),
// rather than blocking the request until a long operation finishes.
sse.Register(api, huma.Operation{
OperationID: "packages-install",
Method: "POST",
Path: "/api/packages",
Summary: "Install a package (streamed)",
Description: "Installs a package, streaming the package manager's output as " +
"`output` events and ending with a `done` event carrying the exit status.",
Tags: []string{tagPackages},
Metadata: op("write"),
}, pkgEvents, func(ctx context.Context, in *InstallInput, send sse.Sender) {
if validateName(in.Body.Name) != nil {
send.Data(PkgErrorEvent{Message: "invalid package name: " + in.Body.Name})
return
}
bin, args := pm.installArgs(in.Body.Name)
streamOp(ctx, send, bin, args)
})
sse.Register(api, huma.Operation{
OperationID: "packages-remove",
Method: "DELETE",
Path: "/api/packages/{name}",
Summary: "Remove a package (streamed)",
Tags: []string{tagPackages},
Metadata: op("write"),
}, pkgEvents, func(ctx context.Context, in *RemoveInput, send sse.Sender) {
if validateName(in.Name) != nil {
send.Data(PkgErrorEvent{Message: "invalid package name: " + in.Name})
return
}
bin, args := pm.removeArgs(in.Name)
streamOp(ctx, send, bin, args)
})
sse.Register(api, huma.Operation{
OperationID: "packages-upgrade",
Method: "POST",
Path: "/api/packages/upgrade",
Summary: "Upgrade all packages (streamed)",
Description: "Upgrades every package to its latest version, streaming the " +
"package manager's output live.",
Tags: []string{tagPackages},
Metadata: op("write"),
}, pkgEvents, func(ctx context.Context, _ *struct{}, send sse.Sender) {
bin, args := pm.upgradeArgs()
streamOp(ctx, send, bin, args)
})
sse.Register(api, huma.Operation{
OperationID: "packages-upgrade-one",
Method: "POST",
Path: "/api/packages/upgrade/{name}",
Summary: "Upgrade a single package (streamed)",
Description: "Upgrades the named package to its latest version, streaming the " +
"package manager's output live. apt uses `install --only-upgrade` so the " +
"package must already be installed; dnf/pacman handle this natively.",
Tags: []string{tagPackages},
Metadata: op("write"),
}, pkgEvents, func(ctx context.Context, in *UpgradeOneInput, send sse.Sender) {
if validateName(in.Name) != nil {
send.Data(PkgErrorEvent{Message: "invalid package name: " + in.Name})
return
}
bin, args := pm.upgradeOneArgs(in.Name)
streamOp(ctx, send, bin, args)
})
}
// streamOp runs a package write and streams its combined output to the client.
func streamOp(ctx context.Context, send sse.Sender, bin string, args []string) {
if bin == "" {
send.Data(PkgErrorEvent{Message: "no supported package manager found"})
return
}
select {
case pkgSem <- struct{}{}:
default:
send.Data(PkgErrorEvent{Message: "too many concurrent package operations, try again later"})
return
}
defer func() { <-pkgSem }()
// DEBIAN_FRONTEND keeps apt from blocking on an interactive prompt.
lines, errc, err := oscmd.RunStreamCombined(ctx, []string{"DEBIAN_FRONTEND=noninteractive"}, bin, args...)
if err != nil {
send.Data(PkgErrorEvent{Message: err.Error()})
return
}
for line := range lines {
if send.Data(PkgOutputEvent{Line: line}) != nil {
return // client gone; ctx cancel kills the process
}
}
done := PkgDoneEvent{Success: true}
if werr := <-errc; werr != nil {
done.Success = false
done.Error = werr.Error()
}
send.Data(done)
}
func validateName(name string) error {
if !pkgNameRe.MatchString(name) {
return huma.Error400BadRequest("invalid package name: " + name)
}
return nil
}
// --- reads -------------------------------------------------------------------
func listInstalled(pm manager) (*ListOutput, error) {
var out string
var err error
switch pm.name {
case "dnf":
out, err = oscmd.Run("rpm", "-qa", "--qf", "%{NAME}\t%{VERSION}-%{RELEASE}\n")
case "apt":
out, err = oscmd.Run("dpkg-query", "-W", "-f=${Package}\t${Version}\n")
case "pacman":
out, err = oscmd.Run("pacman", "-Q")
default:
return nil, huma.Error500InternalServerError("no supported package manager found")
}
if err != nil {
return nil, huma.Error500InternalServerError("listing installed packages failed", err)
}
pkgs := parseTabbed(out)
if pm.name == "pacman" {
pkgs = parseSpaced(out)
}
return result(pm, pkgs), nil
}
func listUpdates(pm manager) (*ListOutput, error) {
switch pm.name {
case "dnf":
// check-update exits 100 when updates exist, 0 when none - both fine.
out, code, err := oscmd.RunStatus("dnf", "-q", "check-update")
if err != nil || (code != 0 && code != 100) {
return nil, huma.Error500InternalServerError("dnf check-update failed", err)
}
return result(pm, parseDnf(out)), nil
case "apt":
out, err := oscmd.Run("apt", "list", "--upgradable", "-qq")
if err != nil {
return nil, huma.Error500InternalServerError("apt list failed", err)
}
return result(pm, parseApt(out)), nil
case "pacman":
// -Qu exits 1 when there is nothing to upgrade - not an error.
out, code, err := oscmd.RunStatus("pacman", "-Qu")
if err != nil || (code != 0 && code != 1) {
return nil, huma.Error500InternalServerError("pacman -Qu failed", err)
}
return result(pm, parsePacmanUpdates(out)), nil
default:
return nil, huma.Error500InternalServerError("no supported package manager found")
}
}
func result(pm manager, pkgs []Package) *ListOutput {
out := &ListOutput{}
out.Body.Manager = pm.name
out.Body.Packages = pkgs
return out
}
// --- writes ------------------------------------------------------------------
func (m manager) installArgs(name string) (string, []string) {
switch m.name {
case "dnf":
return "dnf", []string{"install", "-y", name}
case "apt":
return "apt-get", []string{"install", "-y", name}
case "pacman":
return "pacman", []string{"-S", "--noconfirm", name}
}
return "", nil
}
func (m manager) removeArgs(name string) (string, []string) {
switch m.name {
case "dnf":
return "dnf", []string{"remove", "-y", name}
case "apt":
return "apt-get", []string{"remove", "-y", name}
case "pacman":
return "pacman", []string{"-R", "--noconfirm", name}
}
return "", nil
}
func (m manager) upgradeArgs() (string, []string) {
switch m.name {
case "dnf":
return "dnf", []string{"upgrade", "-y"}
case "apt":
return "apt-get", []string{"upgrade", "-y"}
case "pacman":
return "pacman", []string{"-Su", "--noconfirm"}
}
return "", nil
}
// upgradeOneArgs upgrades a single package to its latest version. apt's
// `install --only-upgrade` is the safe variant (won't install if absent);
// pacman -S re-syncs to latest; dnf upgrade is naturally scoped by name.
func (m manager) upgradeOneArgs(name string) (string, []string) {
switch m.name {
case "dnf":
return "dnf", []string{"upgrade", "-y", name}
case "apt":
return "apt-get", []string{"install", "--only-upgrade", "-y", name}
case "pacman":
return "pacman", []string{"-S", "--noconfirm", name}
}
return "", nil
}
// --- parsers (pure, tested) --------------------------------------------------
// parseTabbed reads "name\tversion" lines (dpkg-query / rpm output).
func parseTabbed(out string) []Package {
pkgs := []Package{}
for line := range strings.SplitSeq(out, "\n") {
if name, ver, ok := strings.Cut(line, "\t"); ok && name != "" {
pkgs = append(pkgs, Package{Name: name, Version: ver})
}
}
return pkgs
}
// parseSpaced reads "name version" lines (pacman -Q).
func parseSpaced(out string) []Package {
pkgs := []Package{}
for line := range strings.SplitSeq(out, "\n") {
if f := strings.Fields(line); len(f) >= 2 {
pkgs = append(pkgs, Package{Name: f[0], Version: f[1]})
}
}
return pkgs
}
// parseDnf reads `dnf check-update` lines ("name.arch version repo"), skipping
// the section headers and blank lines that dnf5 emits.
func parseDnf(out string) []Package {
pkgs := []Package{}
for line := range strings.SplitSeq(out, "\n") {
f := strings.Fields(line)
// Real rows have 3 columns and an arch suffix on the name; headers like
// "Upgrades" or "Obsoleting Packages" don't.
if len(f) != 3 || !strings.Contains(f[0], ".") {
continue
}
pkgs = append(pkgs, Package{Name: stripArch(f[0]), Version: f[1]})
}
return pkgs
}
// parseApt reads `apt list --upgradable` lines ("name/repo version arch [...]").
func parseApt(out string) []Package {
pkgs := []Package{}
for line := range strings.SplitSeq(out, "\n") {
f := strings.Fields(line)
if len(f) < 2 || !strings.Contains(f[0], "/") {
continue
}
name, _, _ := strings.Cut(f[0], "/")
pkgs = append(pkgs, Package{Name: name, Version: f[1]})
}
return pkgs
}
// parsePacmanUpdates reads `pacman -Qu` lines ("name oldver -> newver").
func parsePacmanUpdates(out string) []Package {
pkgs := []Package{}
for line := range strings.SplitSeq(out, "\n") {
f := strings.Fields(line)
if len(f) < 4 || f[2] != "->" {
continue
}
pkgs = append(pkgs, Package{Name: f[0], Version: f[3]})
}
return pkgs
}
// stripArch removes the trailing ".arch" from an rpm "name.arch" token. The
// arch is always the final dotted segment, so cut at the last dot.
func stripArch(s string) string {
if i := strings.LastIndex(s, "."); i > 0 {
return s[:i]
}
return s
}