diff --git a/agent/internal/updates/updates.go b/agent/internal/updates/updates.go index 8a1ce12..293da52 100644 --- a/agent/internal/updates/updates.go +++ b/agent/internal/updates/updates.go @@ -1,238 +1,24 @@ package updates -import ( - "bufio" - "bytes" - "context" - "os/exec" - "strings" - "time" -) - +// PackageUpdate is one pending update. On Linux it is a package with a version +// on each side. On Windows CurrentVersion is empty and NewVersion carries the +// KB article ID: a Windows update is not a version bump of a named package, +// and inventing a current version would put a wrong string in front of an +// operator. type PackageUpdate struct { Name string CurrentVersion string NewVersion string } -func detectPM() string { - for _, pm := range []string{"apt-get", "dnf", "yum", "pacman", "zypper", "apk"} { - if _, err := exec.LookPath(pm); err == nil { - if pm == "apt-get" { - return "apt" - } - return pm - } - } - return "" -} +// CheckAvailable lists pending OS updates. +func CheckAvailable() ([]PackageUpdate, error) { return checkAvailable() } +// ApplyAll installs every pending update. It never reboots: a control plane +// silently restarting a production server is unrecoverable from the UI, so the +// reboot stays a decision a person or a workflow makes. RebootRequired reports +// when one is owed. +func ApplyAll() error { return applyAll() } - -func CheckAvailable() ([]PackageUpdate, error) { - switch detectPM() { - case "apt": - return checkApt() - case "dnf": - return checkDnfYum("dnf") - case "yum": - return checkDnfYum("yum") - case "pacman": - return checkPacman() - case "zypper": - return checkZypper() - case "apk": - return checkApk() - default: - return nil, nil - } -} - - -func ApplyAll() error { - switch detectPM() { - case "apt": - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil { - return err - } - return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run() - case "dnf": - return exec.Command("dnf", "upgrade", "-y").Run() - case "yum": - return exec.Command("yum", "upgrade", "-y").Run() - case "pacman": - return exec.Command("pacman", "-Syu", "--noconfirm").Run() - case "zypper": - return exec.Command("zypper", "update", "-y").Run() - case "apk": - return exec.Command("apk", "upgrade").Run() - default: - return nil - } -} - -func checkApt() ([]PackageUpdate, error) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - exec.CommandContext(ctx, "apt-get", "update", "-qq").Run() - - out, err := exec.Command("apt", "list", "--upgradable").Output() - if err != nil { - return nil, err - } - var updates []PackageUpdate - scanner := bufio.NewScanner(bytes.NewReader(out)) - for scanner.Scan() { - line := scanner.Text() - - if !strings.Contains(line, "[upgradable from:") { - continue - } - parts := strings.Fields(line) - if len(parts) < 2 { - continue - } - name := strings.SplitN(parts[0], "/", 2)[0] - newVer := parts[1] - oldVer := "" - if idx := strings.Index(line, "upgradable from: "); idx != -1 { - rest := line[idx+len("upgradable from: "):] - oldVer = strings.TrimSuffix(strings.TrimSpace(rest), "]") - } - updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer}) - } - return updates, nil -} - -func checkDnfYum(pm string) ([]PackageUpdate, error) { - cmd := exec.Command(pm, "check-update") - out, err := cmd.Output() - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 { - err = nil - } - if err != nil { - return nil, err - } - var updates []PackageUpdate - pastHeader := false - scanner := bufio.NewScanner(bytes.NewReader(out)) - for scanner.Scan() { - line := scanner.Text() - if !pastHeader { - if strings.TrimSpace(line) == "" { - pastHeader = true - } - continue - } - parts := strings.Fields(line) - if len(parts) < 2 { - continue - } - - name := strings.SplitN(parts[0], ".", 2)[0] - updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]}) - } - return updates, nil -} - -func checkPacman() ([]PackageUpdate, error) { - out, _ := exec.Command("pacman", "-Qu").Output() - var updates []PackageUpdate - scanner := bufio.NewScanner(bytes.NewReader(out)) - for scanner.Scan() { - parts := strings.Fields(scanner.Text()) - - if len(parts) < 4 { - continue - } - updates = append(updates, PackageUpdate{Name: parts[0], CurrentVersion: parts[1], NewVersion: parts[3]}) - } - return updates, nil -} - -func checkZypper() ([]PackageUpdate, error) { - out, err := exec.Command("zypper", "list-updates").Output() - if err != nil { - return nil, err - } - var updates []PackageUpdate - scanner := bufio.NewScanner(bytes.NewReader(out)) - for scanner.Scan() { - line := scanner.Text() - - if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") { - continue - } - parts := strings.Split(line, "|") - if len(parts) < 5 { - continue - } - updates = append(updates, PackageUpdate{ - Name: strings.TrimSpace(parts[2]), - CurrentVersion: strings.TrimSpace(parts[3]), - NewVersion: strings.TrimSpace(parts[4]), - }) - } - return updates, nil -} - -func checkApk() ([]PackageUpdate, error) { - out, err := exec.Command("apk", "list", "--upgradable").Output() - if err != nil { - return nil, err - } - var updates []PackageUpdate - scanner := bufio.NewScanner(bytes.NewReader(out)) - for scanner.Scan() { - line := scanner.Text() - if !strings.Contains(line, "[upgradable") { - continue - } - parts := strings.Fields(line) - if len(parts) < 1 { - continue - } - pkgVer := parts[0] - name := apkName(pkgVer) - newVer := apkVersion(pkgVer) - oldVer := "" - if idx := strings.Index(line, "upgradable from:"); idx != -1 { - rest := strings.TrimSpace(line[idx+len("upgradable from:"):]) - rest = strings.TrimSuffix(rest, "]") - oldVer = apkVersion(strings.TrimSpace(rest)) - } - updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer}) - } - return updates, nil -} - -func apkName(pkgVer string) string { - parts := strings.Split(pkgVer, "-") - var name []string - for _, p := range parts { - if len(p) > 0 && p[0] >= '0' && p[0] <= '9' { - break - } - name = append(name, p) - } - return strings.Join(name, "-") -} - -func apkVersion(pkgVer string) string { - parts := strings.Split(pkgVer, "-") - var ver []string - inVer := false - for _, p := range parts { - if !inVer && len(p) > 0 && p[0] >= '0' && p[0] <= '9' { - inVer = true - } - if inVer { - ver = append(ver, p) - } - } - return strings.Join(ver, "-") -} +// RebootRequired reports whether this host is waiting on a restart. +func RebootRequired() bool { return rebootRequired() } diff --git a/agent/internal/updates/updates_linux.go b/agent/internal/updates/updates_linux.go new file mode 100644 index 0000000..4c16402 --- /dev/null +++ b/agent/internal/updates/updates_linux.go @@ -0,0 +1,252 @@ +package updates + +import ( + "bufio" + "bytes" + "context" + "os" + "os/exec" + "strings" + "time" +) + +func detectPM() string { + for _, pm := range []string{"apt-get", "dnf", "yum", "pacman", "zypper", "apk"} { + if _, err := exec.LookPath(pm); err == nil { + if pm == "apt-get" { + return "apt" + } + return pm + } + } + return "" +} + + + +func checkAvailable() ([]PackageUpdate, error) { + switch detectPM() { + case "apt": + return checkApt() + case "dnf": + return checkDnfYum("dnf") + case "yum": + return checkDnfYum("yum") + case "pacman": + return checkPacman() + case "zypper": + return checkZypper() + case "apk": + return checkApk() + default: + return nil, nil + } +} + + +func applyAll() error { + switch detectPM() { + case "apt": + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil { + return err + } + return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run() + case "dnf": + return exec.Command("dnf", "upgrade", "-y").Run() + case "yum": + return exec.Command("yum", "upgrade", "-y").Run() + case "pacman": + return exec.Command("pacman", "-Syu", "--noconfirm").Run() + case "zypper": + return exec.Command("zypper", "update", "-y").Run() + case "apk": + return exec.Command("apk", "upgrade").Run() + default: + return nil + } +} + +// rebootRequired reads what the distributions themselves record. Debian and +// Ubuntu drop a file; the RPM family answers through needs-restarting, whose +// exit code is 1 when a reboot is owed and 0 when it is not. +func rebootRequired() bool { + if _, err := os.Stat("/var/run/reboot-required"); err == nil { + return true + } + if _, err := exec.LookPath("dnf"); err == nil { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := exec.CommandContext(ctx, "dnf", "needs-restarting", "-r").Run(); err != nil { + if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { + return true + } + } + } + return false +} + +func checkApt() ([]PackageUpdate, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + exec.CommandContext(ctx, "apt-get", "update", "-qq").Run() + + out, err := exec.Command("apt", "list", "--upgradable").Output() + if err != nil { + return nil, err + } + var updates []PackageUpdate + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + line := scanner.Text() + + if !strings.Contains(line, "[upgradable from:") { + continue + } + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + name := strings.SplitN(parts[0], "/", 2)[0] + newVer := parts[1] + oldVer := "" + if idx := strings.Index(line, "upgradable from: "); idx != -1 { + rest := line[idx+len("upgradable from: "):] + oldVer = strings.TrimSuffix(strings.TrimSpace(rest), "]") + } + updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer}) + } + return updates, nil +} + +func checkDnfYum(pm string) ([]PackageUpdate, error) { + cmd := exec.Command(pm, "check-update") + out, err := cmd.Output() + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 { + err = nil + } + if err != nil { + return nil, err + } + var updates []PackageUpdate + pastHeader := false + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + line := scanner.Text() + if !pastHeader { + if strings.TrimSpace(line) == "" { + pastHeader = true + } + continue + } + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + + name := strings.SplitN(parts[0], ".", 2)[0] + updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]}) + } + return updates, nil +} + +func checkPacman() ([]PackageUpdate, error) { + out, _ := exec.Command("pacman", "-Qu").Output() + var updates []PackageUpdate + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + parts := strings.Fields(scanner.Text()) + + if len(parts) < 4 { + continue + } + updates = append(updates, PackageUpdate{Name: parts[0], CurrentVersion: parts[1], NewVersion: parts[3]}) + } + return updates, nil +} + +func checkZypper() ([]PackageUpdate, error) { + out, err := exec.Command("zypper", "list-updates").Output() + if err != nil { + return nil, err + } + var updates []PackageUpdate + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + line := scanner.Text() + + if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") { + continue + } + parts := strings.Split(line, "|") + if len(parts) < 5 { + continue + } + updates = append(updates, PackageUpdate{ + Name: strings.TrimSpace(parts[2]), + CurrentVersion: strings.TrimSpace(parts[3]), + NewVersion: strings.TrimSpace(parts[4]), + }) + } + return updates, nil +} + +func checkApk() ([]PackageUpdate, error) { + out, err := exec.Command("apk", "list", "--upgradable").Output() + if err != nil { + return nil, err + } + var updates []PackageUpdate + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + line := scanner.Text() + if !strings.Contains(line, "[upgradable") { + continue + } + parts := strings.Fields(line) + if len(parts) < 1 { + continue + } + pkgVer := parts[0] + name := apkName(pkgVer) + newVer := apkVersion(pkgVer) + oldVer := "" + if idx := strings.Index(line, "upgradable from:"); idx != -1 { + rest := strings.TrimSpace(line[idx+len("upgradable from:"):]) + rest = strings.TrimSuffix(rest, "]") + oldVer = apkVersion(strings.TrimSpace(rest)) + } + updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer}) + } + return updates, nil +} + +func apkName(pkgVer string) string { + parts := strings.Split(pkgVer, "-") + var name []string + for _, p := range parts { + if len(p) > 0 && p[0] >= '0' && p[0] <= '9' { + break + } + name = append(name, p) + } + return strings.Join(name, "-") +} + +func apkVersion(pkgVer string) string { + parts := strings.Split(pkgVer, "-") + var ver []string + inVer := false + for _, p := range parts { + if !inVer && len(p) > 0 && p[0] >= '0' && p[0] <= '9' { + inVer = true + } + if inVer { + ver = append(ver, p) + } + } + return strings.Join(ver, "-") +} diff --git a/agent/internal/updates/updates_other.go b/agent/internal/updates/updates_other.go new file mode 100644 index 0000000..f97615b --- /dev/null +++ b/agent/internal/updates/updates_other.go @@ -0,0 +1,9 @@ +//go:build !linux && !windows + +// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so +// without it this file compiles on Linux too and collides with updates_linux.go. +package updates + +func checkAvailable() ([]PackageUpdate, error) { return nil, nil } +func applyAll() error { return nil } +func rebootRequired() bool { return false }