Pulse/internal/api/update_readiness.go
rcourtman e17916e3bc Surface v5 agent migration security guidance
Warn when update readiness includes v5 agents and document the trusted-transport first hop.
2026-06-03 09:18:07 +01:00

339 lines
11 KiB
Go

package api
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
)
const (
updateReadinessReady = "ready"
updateReadinessAttention = "attention"
updateReadinessBlocked = "blocked"
updateReadinessCheckPass = "pass"
updateReadinessCheckWarning = "warning"
updateReadinessCheckBlocked = "blocked"
)
type updateReadinessInputs struct {
cfg *config.Config
hosts []models.Host
targetVersion string
plan updates.UpdatePlan
now time.Time
}
func buildUpdateReadiness(in updateReadinessInputs) *updates.UpdateReadiness {
now := in.now
if now.IsZero() {
now = time.Now()
}
checks := []updates.UpdateReadinessCheck{
buildUpdatePathReadinessCheck(in.plan),
buildAgentContinuityReadinessCheck(in.hosts, in.targetVersion, now),
buildAgentMigrationSecurityReadinessCheck(in.hosts),
buildAgentTokenReadinessCheck(in.cfg, len(in.hosts), now),
}
status := updateReadinessReady
for _, check := range checks {
switch check.Status {
case updateReadinessCheckBlocked:
status = updateReadinessBlocked
case updateReadinessCheckWarning:
if status != updateReadinessBlocked {
status = updateReadinessAttention
}
}
}
return &updates.UpdateReadiness{
Status: status,
Summary: updateReadinessSummary(status, checks),
Checks: checks,
}
}
func updateReadinessSummary(status string, checks []updates.UpdateReadinessCheck) string {
blocked := 0
warnings := 0
for _, check := range checks {
switch check.Status {
case updateReadinessCheckBlocked:
blocked++
case updateReadinessCheckWarning:
warnings++
}
}
switch status {
case updateReadinessBlocked:
return fmt.Sprintf("Resolve %d blocked upgrade %s before installing this update.", blocked, pluralNoun(blocked, "check", "checks"))
case updateReadinessAttention:
return fmt.Sprintf("Update can proceed, but review %d upgrade %s before installing.", warnings, pluralNoun(warnings, "warning", "warnings"))
default:
return "Upgrade checks passed for this Pulse instance."
}
}
func buildUpdatePathReadinessCheck(plan updates.UpdatePlan) updates.UpdateReadinessCheck {
if plan.CanAutoUpdate && plan.RollbackSupport {
return updates.UpdateReadinessCheck{
ID: "server-update-path",
Status: updateReadinessCheckPass,
Title: "Server update path",
Summary: "Automatic install and rollback support are available for this deployment.",
Details: []string{
"Pulse will stage the update through the configured updater.",
"A rollback-capable backup is part of this update path.",
},
}
}
if plan.CanAutoUpdate {
return updates.UpdateReadinessCheck{
ID: "server-update-path",
Status: updateReadinessCheckWarning,
Title: "Server update path",
Summary: "Automatic install is available, but rollback support is not advertised by this updater.",
Details: []string{"Keep console access and an external backup available before installing."},
}
}
return updates.UpdateReadinessCheck{
ID: "server-update-path",
Status: updateReadinessCheckWarning,
Title: "Server update path",
Summary: "This deployment uses a manual update path.",
Details: []string{"Follow the generated update instructions and keep a backup available before restarting Pulse."},
}
}
func buildAgentContinuityReadinessCheck(hosts []models.Host, targetVersion string, now time.Time) updates.UpdateReadinessCheck {
if len(hosts) == 0 {
return updates.UpdateReadinessCheck{
ID: "agent-continuity",
Status: updateReadinessCheckPass,
Title: "Agent continuity",
Summary: "No installed Pulse agents are currently registered.",
}
}
connections := buildConnections(aggregatorInputs{
hosts: hosts,
expectedAgentVersion: strings.TrimSpace(targetVersion),
now: now,
})
active := 0
pendingOrStale := 0
behind := 0
legacy := countLegacyAgents(hosts)
unknownVersion := 0
for _, conn := range connections {
if conn.Type != ConnectionTypeAgent {
continue
}
switch conn.State {
case ConnectionStateActive:
active++
case ConnectionStatePending, ConnectionStateStale:
pendingOrStale++
}
if conn.AgentUpdateAvailable {
behind++
}
if strings.TrimSpace(conn.AgentVersion) == "" {
unknownVersion++
}
}
details := []string{fmt.Sprintf("%s currently registered.", countWithNoun(len(hosts), "agent", "agents"))}
if active > 0 {
details = append(details, fmt.Sprintf("%s have a recent heartbeat.", countWithNoun(active, "agent", "agents")))
}
if legacy > 0 {
details = append(details, fmt.Sprintf("%s can continue reporting through the v6 compatibility route.", countWithNoun(legacy, "v5 or legacy agent", "v5 or legacy agents")))
}
if behind > 0 {
details = append(details, fmt.Sprintf("%s should move toward %s after the server update.", countWithNoun(behind, "agent", "agents"), targetVersionLabel(targetVersion)))
}
if unknownVersion > 0 {
details = append(details, fmt.Sprintf("%s did not report a version.", countWithNoun(unknownVersion, "agent", "agents")))
}
if pendingOrStale > 0 {
return updates.UpdateReadinessCheck{
ID: "agent-continuity",
Status: updateReadinessCheckWarning,
Title: "Agent continuity",
Summary: fmt.Sprintf("%s %s not recently connected, so Pulse cannot prove they will update cleanly.", countWithNoun(pendingOrStale, "agent", "agents"), pluralVerb(pendingOrStale, "is", "are")),
Details: details,
}
}
if unknownVersion > 0 {
return updates.UpdateReadinessCheck{
ID: "agent-continuity",
Status: updateReadinessCheckWarning,
Title: "Agent continuity",
Summary: "Some agents are connected but did not report a version.",
Details: details,
}
}
return updates.UpdateReadinessCheck{
ID: "agent-continuity",
Status: updateReadinessCheckPass,
Title: "Agent continuity",
Summary: "Registered agents have recent heartbeats and can continue through the v6 compatibility path.",
Details: details,
}
}
func buildAgentMigrationSecurityReadinessCheck(hosts []models.Host) updates.UpdateReadinessCheck {
legacy := countLegacyAgents(hosts)
if legacy == 0 {
return updates.UpdateReadinessCheck{
ID: "agent-migration-security",
Status: updateReadinessCheckPass,
Title: "Agent migration security",
Summary: "No v5 or legacy agents need a first-hop automatic migration.",
}
}
return updates.UpdateReadinessCheck{
ID: "agent-migration-security",
Status: updateReadinessCheckWarning,
Title: "Agent migration security",
Summary: "v5 agents can auto-update to v6, but the first hop depends on trusted transport.",
Details: []string{
fmt.Sprintf("%s will use the v5 updater before v6 signature and self-test protections are available.", countWithNoun(legacy, "v5 or legacy agent", "v5 or legacy agents")),
"Use HTTPS, or keep the Pulse-to-agent migration path on a trusted local network; v5 checksum validation alone does not protect plain HTTP from an on-path attacker.",
"For high-assurance environments, reinstall the v6 pulse-agent through the signed installer path instead of relying on automatic first-hop migration.",
},
}
}
func buildAgentTokenReadinessCheck(cfg *config.Config, agentCount int, now time.Time) updates.UpdateReadinessCheck {
if cfg == nil {
return updates.UpdateReadinessCheck{
ID: "agent-token-scopes",
Status: updateReadinessCheckWarning,
Title: "Agent token scopes",
Summary: "Pulse could not read the loaded API token set for this readiness check.",
}
}
if agentCount == 0 {
return updates.UpdateReadinessCheck{
ID: "agent-token-scopes",
Status: updateReadinessCheckPass,
Title: "Agent token scopes",
Summary: "No registered agents need an agent reporting token during this upgrade.",
}
}
agentScoped := 0
expired := 0
expiring := 0
for _, record := range cfg.APITokens {
if !record.HasScope(config.ScopeAgentReport) {
continue
}
agentScoped++
if record.ExpiresAt != nil {
if now.After(*record.ExpiresAt) {
expired++
} else if record.ExpiresAt.Sub(now) <= 14*24*time.Hour {
expiring++
}
}
}
if agentScoped == 0 {
return updates.UpdateReadinessCheck{
ID: "agent-token-scopes",
Status: updateReadinessCheckBlocked,
Title: "Agent token scopes",
Summary: "Registered agents exist, but no loaded API token grants agent reporting scope.",
Details: []string{"Existing v5 host-agent scopes are normally normalized to agent:report on load."},
}
}
if expired >= agentScoped {
return updates.UpdateReadinessCheck{
ID: "agent-token-scopes",
Status: updateReadinessCheckBlocked,
Title: "Agent token scopes",
Summary: "All loaded agent reporting tokens are expired.",
Details: []string{"Create or refresh an agent install token before relying on agent reconnect after the update."},
}
}
if expired > 0 || expiring > 0 {
return updates.UpdateReadinessCheck{
ID: "agent-token-scopes",
Status: updateReadinessCheckWarning,
Title: "Agent token scopes",
Summary: "At least one agent reporting token is expired or expires soon.",
Details: []string{
fmt.Sprintf("%s loaded.", countWithNoun(agentScoped, "agent reporting token", "agent reporting tokens")),
fmt.Sprintf("%s expired, %s expiring within 14 days.", countWithNoun(expired, "token", "tokens"), countWithNoun(expiring, "token", "tokens")),
},
}
}
return updates.UpdateReadinessCheck{
ID: "agent-token-scopes",
Status: updateReadinessCheckPass,
Title: "Agent token scopes",
Summary: "Loaded API tokens include agent reporting scope for registered agents.",
Details: []string{fmt.Sprintf("%s available.", countWithNoun(agentScoped, "agent reporting token", "agent reporting tokens"))},
}
}
func countLegacyAgents(hosts []models.Host) int {
legacy := 0
for _, host := range hosts {
if host.IsLegacy || looksLikePreV6Version(host.AgentVersion) {
legacy++
}
}
return legacy
}
func looksLikePreV6Version(version string) bool {
version = strings.TrimSpace(strings.TrimPrefix(version, "v"))
if version == "" {
return false
}
major, _, ok := strings.Cut(version, ".")
if !ok || major == "" {
return false
}
majorNumber, err := strconv.Atoi(major)
return err == nil && majorNumber < 6
}
func targetVersionLabel(version string) string {
version = strings.TrimSpace(version)
if version == "" {
return "the target server version"
}
return version
}
func pluralNoun(count int, singular, plural string) string {
if count == 1 {
return singular
}
return plural
}
func countWithNoun(count int, singular, plural string) string {
return fmt.Sprintf("%d %s", count, pluralNoun(count, singular, plural))
}