feat: show Ubuntu phased updates apart and leave them out of pending counts
apt lists phased updates as upgradable while an upgrade defers them until Ubuntu selects the host, so a freshly patched server kept reporting pending updates. The agent now flags them; the server stores the flag and leaves them out of patch run counts, and the server page shows them in their own section.
This commit is contained in:
+1
-1
@@ -47,7 +47,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.5.0
|
||||
gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0
|
||||
github.com/bytedance/sonic v1.15.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
|
||||
+2
-2
@@ -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/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30xLN2sUZcMXl50hg+PJCIDdJgIvIbVcKqLJ/ZrtM=
|
||||
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
|
||||
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57 h1:A3Lz/9ip/qigafSxqBWcu7S8i+tJbQS7DB2V0XibOKs=
|
||||
|
||||
@@ -1389,6 +1389,10 @@
|
||||
},
|
||||
"new_version": {
|
||||
"type": "string"
|
||||
},
|
||||
"phased": {
|
||||
"description": "Phased is an Ubuntu phased update the host is not yet selected for: apt\nlists it but an upgrade defers it, so pending counts leave it out.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -103,6 +103,7 @@ func (s *vantageServer) ReportUpdates(ctx context.Context, req *pb.ReportUpdates
|
||||
Name: u.Name,
|
||||
CurrentVersion: u.CurrentVersion,
|
||||
NewVersion: u.NewVersion,
|
||||
Phased: u.Phased,
|
||||
}
|
||||
}
|
||||
if err := services.StoreAvailableUpdates(srv.ServerID, pkgs); err != nil {
|
||||
|
||||
@@ -80,6 +80,8 @@ type pendingUpdate struct {
|
||||
Package string `json:"package"`
|
||||
CurrentVersion string `json:"current_version,omitempty"`
|
||||
NewVersion string `json:"new_version"`
|
||||
// Phased is an Ubuntu phased update apt defers until the host is selected.
|
||||
Phased bool `json:"phased,omitempty"`
|
||||
}
|
||||
|
||||
type listPendingUpdatesResult struct {
|
||||
@@ -390,7 +392,7 @@ func init() {
|
||||
}
|
||||
out = append(out, pendingUpdate{
|
||||
ServerID: srv.ServerID, Hostname: srv.Hostname,
|
||||
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion,
|
||||
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion, Phased: u.Phased,
|
||||
})
|
||||
}
|
||||
return listPendingUpdatesResult{Updates: out, Shown: len(out)}, nil
|
||||
@@ -411,7 +413,7 @@ func init() {
|
||||
}
|
||||
out = append(out, pendingUpdate{
|
||||
ServerID: srv.ServerID, Hostname: srv.Hostname,
|
||||
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion,
|
||||
Package: u.Name, CurrentVersion: u.CurrentVersion, NewVersion: u.NewVersion, Phased: u.Phased,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ type PackageUpdate struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
CurrentVersion string `bson:"current_version,omitempty" json:"current_version,omitempty"`
|
||||
NewVersion string `bson:"new_version" json:"new_version"`
|
||||
// Phased is an Ubuntu phased update the host is not yet selected for: apt
|
||||
// lists it but an upgrade defers it, so pending counts leave it out.
|
||||
Phased bool `bson:"phased,omitempty" json:"phased,omitempty"`
|
||||
}
|
||||
|
||||
type CPUInfo struct {
|
||||
|
||||
@@ -83,7 +83,7 @@ func serverBootTime(ctx context.Context, instanceID, serverID string) *time.Time
|
||||
}
|
||||
|
||||
func newServerRun(s models.Server, now time.Time) models.PatchServerRun {
|
||||
r := models.PatchServerRun{ServerID: s.ServerID, Hostname: s.Hostname, Status: models.PatchSrvQueued, PendingBefore: len(s.AvailableUpdates)}
|
||||
r := models.PatchServerRun{ServerID: s.ServerID, Hostname: s.Hostname, Status: models.PatchSrvQueued, PendingBefore: InstallableUpdateCount(s.AvailableUpdates)}
|
||||
if !patchrun.AgentSupportsPatchResults(s.AgentVersion) {
|
||||
v := s.AgentVersion
|
||||
if v == "" {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package services
|
||||
|
||||
import "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
|
||||
// InstallableUpdateCount is the number of pending updates an upgrade would
|
||||
// install now. Ubuntu phased updates are listed by apt but deferred until the
|
||||
// host's phase comes up, so they are not counted: counting them made a patch
|
||||
// run look as if it installed less than it did.
|
||||
func InstallableUpdateCount(ups []models.PackageUpdate) int {
|
||||
n := 0
|
||||
for _, u := range ups {
|
||||
if !u.Phased {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
// Phased updates are listed but not installable yet, so they never count as
|
||||
// pending: they must not lower "updates installed" on a patch run.
|
||||
func TestInstallableUpdateCount(t *testing.T) {
|
||||
ups := []models.PackageUpdate{
|
||||
{Name: "curl"},
|
||||
{Name: "netplan.io", Phased: true},
|
||||
{Name: "openssl"},
|
||||
}
|
||||
if got := InstallableUpdateCount(ups); got != 2 {
|
||||
t.Fatalf("got %d, want 2", got)
|
||||
}
|
||||
if got := InstallableUpdateCount(nil); got != 0 {
|
||||
t.Fatalf("got %d for nil, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { api, GenerateKeyOptions, ServerStatus, vulnerabilities, workloads as workloadsApi } from "@/lib/api";
|
||||
import { api, GenerateKeyOptions, installableUpdates, ServerStatus, vulnerabilities, workloads as workloadsApi } from "@/lib/api";
|
||||
import { Badge, friendlyMessage, useToast } from "@/components/ui";
|
||||
import { useLicense } from "@/lib/useLicense";
|
||||
import { TagChips } from "@/components/servers/TagChips";
|
||||
@@ -163,7 +163,7 @@ export default function ServerDetailPage() {
|
||||
|
||||
const openFindings = useMemo(() => (findings ?? []).filter((f) => f.state === "open"), [findings]);
|
||||
const seriousFindings = openFindings.filter((f) => f.severity === "critical" || f.severity === "high").length;
|
||||
const updateCount = server?.available_updates?.length ?? 0;
|
||||
const updateCount = installableUpdates(server?.available_updates).length;
|
||||
const workloadCount = workloadSnapshot?.workloads?.length ?? 0;
|
||||
const activeKeys = (server?.keys ?? []).filter((a) => a.key && !a.revoked_at).length;
|
||||
const agentOutOfDate = !!latestVersion && !!server?.agent_version && server.agent_version !== latestVersion.version;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { api, Server } from "@/lib/api";
|
||||
import { api, installableUpdates, Server } from "@/lib/api";
|
||||
import { AsyncBoundary, Button, Card, CenteredSpinner, EmptyState, TableSkeleton } from "@/components/ui";
|
||||
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
import { TagChips } from "@/components/servers/TagChips";
|
||||
@@ -32,7 +32,8 @@ const STATUS_ORDER: Record<DotStatus, number> = {
|
||||
function resolveStatus(server: Server, latestVersion: string | undefined): DotStatus {
|
||||
if (server.status === "offline" || server.status === "pending") return "offline";
|
||||
if (latestVersion && server.agent_version && server.agent_version !== latestVersion) return "needs-update";
|
||||
if (server.available_updates && server.available_updates.length > 0) return "has-package-updates";
|
||||
// Phased updates are deferred by apt, so they do not make a server need patching.
|
||||
if (installableUpdates(server.available_updates).length > 0) return "has-package-updates";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, ServerWithKeys } from "@/lib/api";
|
||||
import { api, installableUpdates, ServerWithKeys } from "@/lib/api";
|
||||
import { Badge, Button, Card, ConfirmDialog, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui";
|
||||
import { matchesTags } from "@/lib/targets";
|
||||
import { RUN_STATUS } from "@/components/patching/status";
|
||||
@@ -41,7 +41,10 @@ export function MaintenanceTab({
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const updates = server.available_updates ?? [];
|
||||
// Phased updates are listed by apt but deferred until Ubuntu selects this
|
||||
// host, so they are shown apart and never count as pending.
|
||||
const installable = installableUpdates(server.available_updates);
|
||||
const phased = (server.available_updates ?? []).filter((u) => u.phased);
|
||||
const command = api.getUpdateCommand(server.os_info);
|
||||
const isWindows = server.os_info?.toLowerCase().includes("windows");
|
||||
const agentCurrent = !!latestVersion && !!server.agent_version && server.agent_version === latestVersion;
|
||||
@@ -63,7 +66,8 @@ export function MaintenanceTab({
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">OS updates</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{updates.length > 0 ? <Badge variant="warning">{updates.length} pending</Badge> : <Badge variant="success">up to date</Badge>}
|
||||
{installable.length > 0 ? <Badge variant="warning">{installable.length} pending</Badge> : <Badge variant="success">up to date</Badge>}
|
||||
{phased.length > 0 && <Badge variant="neutral">{phased.length} phased</Badge>}
|
||||
{/* Sits with the updates panel because that is what
|
||||
caused it. The agent never reboots a host itself. */}
|
||||
{server.inventory?.reboot_required && <Badge variant="warning">reboot required</Badge>}
|
||||
@@ -88,7 +92,7 @@ export function MaintenanceTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{updates.length === 0 ? (
|
||||
{installable.length === 0 ? (
|
||||
<p className="px-6 py-10 text-center text-sm text-text-secondary">
|
||||
{isWindows ? "No pending Windows updates. The agent checks hourly." : "No pending package updates. The agent checks hourly."}
|
||||
</p>
|
||||
@@ -106,7 +110,7 @@ export function MaintenanceTab({
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{updates.map((u) => (
|
||||
{installable.map((u) => (
|
||||
<Tr key={u.name}>
|
||||
<Td label={isWindows ? "Update" : "Package"}>
|
||||
<span className="font-mono text-sm font-medium">{u.name}</span>
|
||||
@@ -131,6 +135,16 @@ export function MaintenanceTab({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phased.length > 0 && (
|
||||
<div className="border-t border-border px-6 py-4">
|
||||
<p className="text-sm text-text-secondary">
|
||||
<span className="text-text-primary">{phased.length} deferred by Ubuntu phasing.</span> Ubuntu releases these to a share of machines at a time and
|
||||
apt holds them back until this server is selected, usually within a few days. They install on a later run; nothing needs doing.
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-xs text-text-tertiary">{phased.map((u) => u.name).join(", ")}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -5,6 +5,13 @@ export interface PackageUpdate {
|
||||
name: string;
|
||||
current_version?: string;
|
||||
new_version: string;
|
||||
/** Ubuntu phased update this host is not yet selected for: apt defers it. */
|
||||
phased?: boolean;
|
||||
}
|
||||
|
||||
/** Updates an upgrade would install now. Phased updates are pending but deferred by apt. */
|
||||
export function installableUpdates(updates: PackageUpdate[] | undefined): PackageUpdate[] {
|
||||
return (updates ?? []).filter((u) => !u.phased);
|
||||
}
|
||||
|
||||
export interface Inventory {
|
||||
|
||||
Reference in New Issue
Block a user