diff --git a/internal/command/image/show.go b/internal/command/image/show.go index 10ec8a6c92..1d9c1b27f0 100644 --- a/internal/command/image/show.go +++ b/internal/command/image/show.go @@ -165,8 +165,11 @@ func showMachineImage(ctx context.Context, app *fly.AppCompact) error { } } - // Exclude machines that are already running the latest version - if machine.ImageRef.Digest == latest.Digest { + // Exclude machines that are already running the latest version, and + // skip cases where the resolver returned an older or non-newer build + // (e.g. a stale pinned minor tag at a lower fly.version than the + // machine's current rolling tag) to avoid suggesting downgrades. + if !IsUpdateCandidate(machine, latest) { continue } updatable = append(updatable, machine) diff --git a/internal/command/image/update_check.go b/internal/command/image/update_check.go new file mode 100644 index 0000000000..0cbdb664f7 --- /dev/null +++ b/internal/command/image/update_check.go @@ -0,0 +1,43 @@ +package image + +import ( + "strings" + + "github.com/hashicorp/go-version" + fly "github.com/superfly/fly-go" +) + +// IsUpdateCandidate reports whether latest is a strictly newer build than the +// machine's current image. It guards against a backend resolver bug where +// asking for the latest details of a rolling tag (e.g. "flyio/postgres-flex:16") +// returns an older fly.version than the rolling tag's current one, which would +// otherwise surface as a spurious "update available" — actually a downgrade. +// +// When either fly.version label is missing or unparseable (e.g. custom images), +// falls back to a digest-inequality check so we preserve the prior behavior for +// cases that don't use semver-tagged builds. +func IsUpdateCandidate(machine *fly.Machine, latest *fly.ImageVersion) bool { + if machine == nil || latest == nil { + return false + } + if machine.ImageRef.Digest == latest.Digest { + return false + } + + current := strings.TrimPrefix(machine.ImageVersion(), "v") + candidate := strings.TrimPrefix(latest.Version, "v") + if current == "" || candidate == "" { + return true + } + + curV, err := version.NewVersion(current) + if err != nil { + return true + } + latV, err := version.NewVersion(candidate) + if err != nil { + return true + } + + return latV.GreaterThan(curV) +} diff --git a/internal/command/image/update_machines.go b/internal/command/image/update_machines.go index ec71243cfe..97f905db44 100644 --- a/internal/command/image/update_machines.go +++ b/internal/command/image/update_machines.go @@ -288,7 +288,7 @@ func resolveImage(ctx context.Context, machine fly.Machine) (string, error) { return "", err } - if latestImage != nil { + if latestImage != nil && IsUpdateCandidate(&machine, latestImage) { image = latestImage.FullImageRef() } diff --git a/internal/command/status/machines.go b/internal/command/status/machines.go index b2f56cb52a..0ab4f74c3a 100644 --- a/internal/command/status/machines.go +++ b/internal/command/status/machines.go @@ -10,6 +10,7 @@ import ( "strings" fly "github.com/superfly/fly-go" + imagecmd "github.com/superfly/flyctl/internal/command/image" "github.com/superfly/flyctl/internal/command/postgres" "github.com/superfly/flyctl/internal/config" "github.com/superfly/flyctl/internal/flapsutil" @@ -125,8 +126,10 @@ func RenderMachineStatus(ctx context.Context, app *fly.AppCompact, out io.Writer latest = latestImage } - // Exclude machines that are already running the latest version - if machine.ImageRef.Digest == latest.Digest { + // Exclude machines that are already running the latest version, and + // skip cases where the resolver returned an older or non-newer build + // to avoid suggesting downgrades. + if !imagecmd.IsUpdateCandidate(machine, latest) { continue } updatable = append(updatable, machine) @@ -319,8 +322,10 @@ func renderPGStatus(ctx context.Context, app *fly.AppCompact, machines []*fly.Ma return fmt.Errorf("major version mismatch detected") } - // Exclude machines that are already running the latest version - if machine.ImageRef.Digest == latest.Digest { + // Exclude machines that are already running the latest version, and + // skip cases where the resolver returned an older or non-newer build + // to avoid suggesting downgrades. + if !imagecmd.IsUpdateCandidate(machine, latest) { continue } updatable = append(updatable, machine)