421 lines
13 KiB
Go
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
|
|
}
|