feat: flag Ubuntu phased updates and leave them out of pending counts
Agent Release / build (push) Successful in 3m36s
Agent Release / msi (push) Successful in 2m30s

apt lists phased updates as upgradable, but an upgrade defers them until the
host is selected. A simulated upgrade names them; they are reported with the
phased flag (vantage-shared v0.6.0) and not counted in pending_after.
This commit is contained in:
2026-09-15 14:55:33 +00:00
parent b5b9775d2b
commit f552006318
8 changed files with 151 additions and 6 deletions
+2 -2
View File
@@ -120,12 +120,12 @@ func reportPendingUpdates(cfg *config.Config) int {
}
list := make([]pb.PackageUpdate, len(pkgs))
for i, p := range pkgs {
list[i] = pb.PackageUpdate{Name: p.Name, CurrentVersion: p.CurrentVersion, NewVersion: p.NewVersion}
list[i] = pb.PackageUpdate{Name: p.Name, CurrentVersion: p.CurrentVersion, NewVersion: p.NewVersion, Phased: p.Phased}
}
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
log.Printf("post-apply report dial: %v", err)
return len(pkgs)
return updates.CountInstallable(pkgs)
}
defer client.Close()
if err := client.ReportUpdates(cfg.ServerID, cfg.AgentToken, list); err != nil {
+1
View File
@@ -406,6 +406,7 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
Name: p.Name,
CurrentVersion: p.CurrentVersion,
NewVersion: p.NewVersion,
Phased: p.Phased,
}
}
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
+78
View File
@@ -0,0 +1,78 @@
package updates
import (
"context"
"os"
"os/exec"
"strings"
"time"
)
// Ubuntu phases some -updates releases: a host is selected at random for each
// update, and until it is, apt lists the package as upgradable while an
// upgrade defers it. Reporting those as pending made a freshly patched host
// look unpatched, so they are flagged and left out of pending counts. The
// -security pocket is never phased.
// aptPhasedDeferred asks apt which upgrades it would defer, by simulating an
// upgrade. The simulation takes no lock and changes nothing. LC_ALL=C keeps
// the heading parsePhasedDeferred looks for in English. Any failure reports no
// phased packages: the update list is then exactly what it was before this
// existed, never shorter.
func aptPhasedDeferred() map[string]bool {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "apt-get", "-s", "-o", "Debug::NoLocking=1", "upgrade")
cmd.Env = append(os.Environ(), "LC_ALL=C", "DEBIAN_FRONTEND=noninteractive")
out, err := cmd.Output()
if err != nil {
return nil
}
return parsePhasedDeferred(string(out))
}
// parsePhasedDeferred reads the package names apt lists under "The following
// upgrades have been deferred due to phasing:". The names follow on indented
// lines; the block ends at the first line that is not indented.
func parsePhasedDeferred(out string) map[string]bool {
phased := map[string]bool{}
in := false
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "deferred due to phasing") {
in = true
continue
}
if !in {
continue
}
if line == "" || (line[0] != ' ' && line[0] != '\t') {
in = false
continue
}
for _, name := range strings.Fields(line) {
phased[name] = true
}
}
return phased
}
// markPhased flags the updates apt would defer.
func markPhased(ups []PackageUpdate, phased map[string]bool) {
for i := range ups {
if phased[ups[i].Name] {
ups[i].Phased = true
}
}
}
// CountInstallable is the number of updates an upgrade would actually install
// now: phased updates are pending but not yet installable.
func CountInstallable(ups []PackageUpdate) int {
n := 0
for _, u := range ups {
if !u.Phased {
n++
}
}
return n
}
+61
View File
@@ -0,0 +1,61 @@
package updates
import (
"reflect"
"testing"
)
const simulatedUpgrade = `Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
The following upgrades have been deferred due to phasing:
libgssapi-krb5-2 libk5crypto3 libkrb5-3 libkrb5support0 libnetplan1
netplan-generator netplan.io python3-netplan
The following packages will be upgraded:
curl libcurl4
2 upgraded, 0 newly installed, 0 to remove and 8 not upgraded.
Inst curl [8.5.0-2ubuntu10.5] (8.5.0-2ubuntu10.6 Ubuntu:24.04/noble-updates [amd64])
`
func TestParsePhasedDeferred(t *testing.T) {
got := parsePhasedDeferred(simulatedUpgrade)
want := map[string]bool{
"libgssapi-krb5-2": true, "libk5crypto3": true, "libkrb5-3": true, "libkrb5support0": true,
"libnetplan1": true, "netplan-generator": true, "netplan.io": true, "python3-netplan": true,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v\nwant %v", got, want)
}
}
func TestParsePhasedDeferredNone(t *testing.T) {
out := "Calculating upgrade...\nThe following packages will be upgraded:\n curl\n1 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\n"
if got := parsePhasedDeferred(out); len(got) != 0 {
t.Fatalf("got %v, want none", got)
}
}
// Packages kept back for other reasons are not phased and must not be flagged.
func TestParsePhasedDeferredIgnoresKeptBack(t *testing.T) {
out := "The following packages have been kept back:\n linux-generic\nThe following upgrades have been deferred due to phasing:\n netplan.io\n0 upgraded\n"
got := parsePhasedDeferred(out)
if !reflect.DeepEqual(got, map[string]bool{"netplan.io": true}) {
t.Fatalf("got %v", got)
}
}
func TestMarkPhased(t *testing.T) {
ups := []PackageUpdate{{Name: "curl"}, {Name: "netplan.io"}}
markPhased(ups, map[string]bool{"netplan.io": true})
if ups[0].Phased || !ups[1].Phased {
t.Fatalf("got %+v", ups)
}
}
func TestCountInstallable(t *testing.T) {
ups := []PackageUpdate{{Name: "a"}, {Name: "b", Phased: true}, {Name: "c"}}
if got := CountInstallable(ups); got != 2 {
t.Fatalf("got %d, want 2", got)
}
}
+1
View File
@@ -15,6 +15,7 @@ type PackageUpdate struct {
Name string
CurrentVersion string
NewVersion string
Phased bool // Ubuntu phased update this host is not yet selected for
}
// CheckAvailable lists pending OS updates.
+5 -1
View File
@@ -30,7 +30,11 @@ func detectPM() string {
func checkAvailable() ([]PackageUpdate, error) {
switch detectPM() {
case "apt":
return checkApt()
ups, err := checkApt()
if err == nil && len(ups) > 0 {
markPhased(ups, aptPhasedDeferred())
}
return ups, err
case "dnf":
return checkDnfYum("dnf")
case "yum":