From f552006318a2e986a4cef3af5a0cb8a0890204f2 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 15 Sep 2026 14:55:33 +0000 Subject: [PATCH] feat: flag Ubuntu phased updates and leave them out of pending counts 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. --- go.mod | 2 +- go.sum | 4 +- internal/sync/patch.go | 4 +- internal/sync/sync.go | 1 + internal/updates/aptphased.go | 78 ++++++++++++++++++++++++++++++ internal/updates/aptphased_test.go | 61 +++++++++++++++++++++++ internal/updates/updates.go | 1 + internal/updates/updates_linux.go | 6 ++- 8 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 internal/updates/aptphased.go create mode 100644 internal/updates/aptphased_test.go diff --git a/go.mod b/go.mod index aeb417a..3e03b56 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( ) require ( - gitea.hostxtra.co.uk/vantage/vantage-shared v0.5.0 + gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0 golang.org/x/net v0.58.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260908043556-f8649ddbbfe6 // indirect diff --git a/go.sum b/go.sum index 665a4e2..2be909d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -gitea.hostxtra.co.uk/vantage/vantage-shared v0.5.0 h1:xwSIEkQKTd4Qk+BYHvoGN+h84Isr2h5qqnitUWF1m2w= -gitea.hostxtra.co.uk/vantage/vantage-shared v0.5.0/go.mod h1:Zo66XhqF8No3dveIowLCepvMxVg8KnhsNMz0k0Xpuck= +gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0 h1:EtojZ1d3cN9foHpc/CAI3KzBewYGn4sKWdkWs2MV78Q= +gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0/go.mod h1:Zo66XhqF8No3dveIowLCepvMxVg8KnhsNMz0k0Xpuck= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= diff --git a/internal/sync/patch.go b/internal/sync/patch.go index 3c5cfce..8a90e96 100644 --- a/internal/sync/patch.go +++ b/internal/sync/patch.go @@ -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 { diff --git a/internal/sync/sync.go b/internal/sync/sync.go index ea85a56..94152c9 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -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) diff --git a/internal/updates/aptphased.go b/internal/updates/aptphased.go new file mode 100644 index 0000000..6c8b3f3 --- /dev/null +++ b/internal/updates/aptphased.go @@ -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 +} diff --git a/internal/updates/aptphased_test.go b/internal/updates/aptphased_test.go new file mode 100644 index 0000000..e79be39 --- /dev/null +++ b/internal/updates/aptphased_test.go @@ -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) + } +} diff --git a/internal/updates/updates.go b/internal/updates/updates.go index 4b54cb2..fbf8137 100644 --- a/internal/updates/updates.go +++ b/internal/updates/updates.go @@ -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. diff --git a/internal/updates/updates_linux.go b/internal/updates/updates_linux.go index e7f1f95..3f307a9 100644 --- a/internal/updates/updates_linux.go +++ b/internal/updates/updates_linux.go @@ -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":