@@ -88,6 +88,20 @@ type blueGreen struct {
8888
8989 uncordonRetryAttempts uint
9090 uncordonRetryDelay time.Duration
91+
92+ // imageRefRetryAttempts / imageRefRetryDelay control the back-off used when
93+ // DetectMultipleImageVersions re-fetches a machine whose ImageRef came back
94+ // empty from the list API (e.g. due to a transient API error).
95+ imageRefRetryAttempts uint
96+ imageRefRetryDelay time.Duration
97+ }
98+
99+ // hostIsOk reports whether the machine's host is confirmed healthy. Anything
100+ // else — "unreachable", "unknown", or unset — means the host cannot be
101+ // trusted to respond: such machines are excluded from image verification and
102+ // from the cordon/stop stages, and are force-destroyed instead.
103+ func hostIsOk (m * fly.Machine ) bool {
104+ return m .HostStatus == fly .HostStatusOk
91105}
92106
93107// machineHasConfiguredChecks returns true if the machine config has any health
@@ -146,6 +160,9 @@ func (bg *blueGreen) initialize() {
146160
147161 bg .uncordonRetryAttempts = 5
148162 bg .uncordonRetryDelay = 500 * time .Millisecond
163+
164+ bg .imageRefRetryAttempts = 3
165+ bg .imageRefRetryDelay = 1 * time .Second
149166}
150167
151168func (bg * blueGreen ) isAborted () bool {
@@ -592,6 +609,11 @@ func (bg *blueGreen) CordonBlueMachines(ctx context.Context) error {
592609 WithFirstError ().
593610 WithMaxGoroutines (bg .maxConcurrent )
594611 for _ , gm := range bg .blueMachines {
612+ // A machine on a non-ok host can't respond to a cordon; it gets
613+ // force-destroyed at the end of the deployment instead.
614+ if ! hostIsOk (gm .leasableMachine .Machine ()) {
615+ continue
616+ }
595617 p .Go (func () error {
596618 if bg .isAborted () {
597619 return ErrAborted
@@ -622,6 +644,11 @@ func (bg *blueGreen) StopBlueMachines(ctx context.Context) error {
622644 WithFirstError ().
623645 WithMaxGoroutines (bg .maxConcurrent )
624646 for _ , gm := range bg .blueMachines {
647+ // A machine on a non-ok host can't react to a stop signal; it gets
648+ // force-destroyed at the end of the deployment instead.
649+ if ! hostIsOk (gm .leasableMachine .Machine ()) {
650+ continue
651+ }
625652 p .Go (func () error {
626653 if bg .isAborted () {
627654 return ErrAborted
@@ -646,17 +673,26 @@ func (bg *blueGreen) WaitForBlueMachinesToBeStopped(ctx context.Context) error {
646673 ctx , span := tracing .GetTracer ().Start (ctx , "blue_machines_stop_wait" )
647674 defer span .End ()
648675
676+ // Machines on non-ok hosts were never stopped — waiting on them would only
677+ // burn the timeout; they get force-destroyed instead.
678+ waitable := machineUpdateEntries {}
679+ for _ , gm := range bg .blueMachines {
680+ if hostIsOk (gm .leasableMachine .Machine ()) {
681+ waitable = append (waitable , gm )
682+ }
683+ }
684+
649685 wait := time .NewTicker (bg .timeout )
650686 machineIDToState := map [string ]string {}
651- for _ , gm := range bg . blueMachines .machines () {
687+ for _ , gm := range waitable .machines () {
652688 machineIDToState [gm .FormattedMachineId ()] = gm .Machine ().State
653689 }
654690
655691 render := bg .renderMachineStates (machineIDToState )
656692 errChan := make (chan error )
657693
658694 var done atomic.Uint32
659- for _ , gm := range bg . blueMachines {
695+ for _ , gm := range waitable {
660696 id := gm .leasableMachine .FormattedMachineId ()
661697
662698 go func (lm machine.LeasableMachine ) {
@@ -674,7 +710,7 @@ func (bg *blueGreen) WaitForBlueMachinesToBeStopped(ctx context.Context) error {
674710
675711 var merr * multierror.Error
676712 for {
677- if done .Load () == uint32 (len (bg . blueMachines )) {
713+ if done .Load () == uint32 (len (waitable )) {
678714 return merr .ErrorOrNil ()
679715 }
680716
@@ -712,7 +748,21 @@ func (bg *blueGreen) DestroyBlueMachines(ctx context.Context) error {
712748 return ErrAborted
713749 }
714750
715- err := gm .leasableMachine .Destroy (ctx , true )
751+ var err error
752+ if hostIsOk (gm .leasableMachine .Machine ()) {
753+ err = gm .leasableMachine .Destroy (ctx , true )
754+ } else {
755+ // No lease exists for a machine on a non-ok host (lease
756+ // acquisition skips them), so bypass the leasable wrapper and
757+ // issue the destroy straight through the flaps API with
758+ // kill=true and no nonce — the exact call behind
759+ // `fly machine destroy --force`. A stale nonce from the list
760+ // response could otherwise make flaps reject the destroy.
761+ err = bg .flaps .Destroy (ctx , bg .app .Name , fly.RemoveMachineInput {
762+ ID : gm .leasableMachine .Machine ().ID ,
763+ Kill : true ,
764+ }, "" )
765+ }
716766
717767 mu .Lock ()
718768 defer mu .Unlock ()
@@ -733,6 +783,14 @@ func (bg *blueGreen) DestroyBlueMachines(ctx context.Context) error {
733783 return err
734784 }
735785
786+ // Machines that could not be destroyed (typically because their host is
787+ // down) would otherwise linger silently. Tell the user how to finish the
788+ // cleanup by hand.
789+ if len (bg .hangingBlueMachines ) > 0 {
790+ fmt .Fprintf (bg .io .ErrOut , "\n Failed to destroy %d machine(s). Remove them manually with:\n \n %s\n \n " ,
791+ len (bg .hangingBlueMachines ), formatDestroyCommand (bg .appConfig .AppName , bg .hangingBlueMachines ))
792+ }
793+
736794 return nil
737795}
738796
@@ -1048,32 +1106,125 @@ func (bg *blueGreen) Rollback(ctx context.Context, err error) error {
10481106 return nil
10491107}
10501108
1051- // This method aggregates images for machines in an app
1052- // If they are greater than 1, it suggest how to remove them and unblock the app
1053- // It also uses the bg_deployment_tag to suggest blue machines that can be safely deleted.
1109+ // imageRefIsEmpty reports whether a machine's ImageRef fields are both empty,
1110+ // which is how the API signals that full machine data is unavailable (e.g. the
1111+ // host is unreachable). ImageRefWithVersion() would return ":" in this case,
1112+ // which is not a real image identifier.
1113+ func imageRefIsEmpty (m * fly.Machine ) bool {
1114+ return m .ImageRef .Repository == "" && m .ImageRef .Tag == ""
1115+ }
1116+
1117+ // refreshMachineImageRef fetches fresh data for a single machine and retries
1118+ // on transient API errors using exponential backoff (circuit-break after
1119+ // imageRefRetryAttempts). A successful response that still carries an empty
1120+ // ImageRef is a stable platform signal (the host is unreachable) and is
1121+ // returned to the caller as-is — retrying won't change that outcome.
1122+ func (bg * blueGreen ) refreshMachineImageRef (ctx context.Context , machineID string ) (* fly.Machine , error ) {
1123+ var fresh * fly.Machine
1124+
1125+ err := retry .Do (
1126+ func () error {
1127+ var apiErr error
1128+ fresh , apiErr = bg .flaps .Get (ctx , bg .app .Name , machineID )
1129+
1130+ return apiErr // only retry on hard API errors, not on empty-ImageRef responses
1131+ },
1132+ retry .Context (ctx ),
1133+ retry .Attempts (bg .imageRefRetryAttempts ),
1134+ retry .Delay (bg .imageRefRetryDelay ),
1135+ retry .MaxDelay (5 * time .Second ),
1136+ retry .DelayType (retry .BackOffDelay ),
1137+ retry .OnRetry (func (n uint , err error ) {
1138+ fmt .Fprintf (bg .io .ErrOut , " Retrying image lookup for machine %s (attempt %d/%d): %v\n " ,
1139+ machineID , n + 1 , bg .imageRefRetryAttempts , err )
1140+ }),
1141+ )
1142+
1143+ return fresh , err
1144+ }
1145+
1146+ // formatDestroyCommand returns a ready-to-run destroy command for one or more
1147+ // unreachable machines. When there are multiple IDs the command uses backslash
1148+ // continuation so users can copy-paste each ID individually or the whole block.
1149+ func formatDestroyCommand (appName string , machineIDs []string ) string {
1150+ base := fmt .Sprintf ("fly machine destroy --force -a %s" , appName )
1151+ if len (machineIDs ) == 1 {
1152+ return base + " " + machineIDs [0 ]
1153+ }
1154+ lines := make ([]string , len (machineIDs ))
1155+ for i , id := range machineIDs {
1156+ lines [i ] = " " + id
1157+ }
1158+
1159+ return base + " \\ \n " + strings .Join (lines , " \\ \n " )
1160+ }
1161+
10541162func (bg * blueGreen ) DetectMultipleImageVersions (ctx context.Context ) error {
10551163 imageToMachineIDs := map [string ][]string {}
10561164 safeToDelete := map [string ]int {}
1165+ var unreachableIDs []string // machines whose image could not be determined
10571166
10581167 for _ , mach := range bg .blueMachines {
1059- image := mach .leasableMachine .Machine ().ImageRefWithVersion ()
1060- imageToMachineIDs [image ] = append (imageToMachineIDs [image ], mach .leasableMachine .Machine ().ID )
1168+ m := mach .leasableMachine .Machine ()
1169+
1170+ // The platform doesn't report this machine's host as ok: its image
1171+ // cannot be verified and its config is incomplete. Leave it out of the
1172+ // image tally — its green replacement runs the new image on a healthy
1173+ // host regardless of what this machine was running.
1174+ if ! hostIsOk (m ) {
1175+ unreachableIDs = append (unreachableIDs , m .ID )
1176+
1177+ continue
1178+ }
1179+
1180+ // If the list API returned incomplete data for this machine (empty ImageRef),
1181+ // attempt a targeted re-fetch with exponential backoff before drawing any
1182+ // conclusions. This recovers transient errors and avoids misidentifying a
1183+ // lookup failure as an image-version conflict.
1184+ if imageRefIsEmpty (m ) {
1185+ fmt .Fprintf (bg .io .ErrOut , " Machine %s has no image data — retrying lookup...\n " , m .ID )
1186+ freshMachine , err := bg .refreshMachineImageRef (ctx , m .ID )
1187+
1188+ if err != nil || ! hostIsOk (freshMachine ) || imageRefIsEmpty (freshMachine ) {
1189+ // Still no image data after retries: treat it like an
1190+ // unreachable host and replace the machine.
1191+ unreachableIDs = append (unreachableIDs , m .ID )
1192+
1193+ continue
1194+ }
1195+ m = freshMachine
1196+ }
1197+
1198+ image := m .ImageRefWithVersion ()
1199+ imageToMachineIDs [image ] = append (imageToMachineIDs [image ], m .ID )
10611200 if mach .launchInput .Config .Metadata [fly .MachineConfigMetadataKeyFlyctlBGTag ] == safeToDestroyValue {
10621201 safeToDelete [image ] = 1
10631202 }
10641203 }
10651204
1066- if len (imageToMachineIDs ) == 1 {
1205+ // Unreachable machines never block the deploy: warn and proceed. This is
1206+ // the "080d92df225538 returned ':' " production scenario — pre-existing
1207+ // behavior misreported it as an image-version conflict.
1208+ if len (unreachableIDs ) > 0 {
1209+ bg .warnUnreachableMachines (unreachableIDs )
1210+ }
1211+
1212+ // Clean state: all reachable machines agree on a single image, or every
1213+ // machine sits on an unreachable host (all get replaced). An app with no
1214+ // machines at all still falls through to the error below, preserving
1215+ // long-standing behavior.
1216+ if len (imageToMachineIDs ) == 1 || (len (imageToMachineIDs ) == 0 && len (unreachableIDs ) > 0 ) {
10671217 return nil
10681218 }
10691219
1220+ // Genuine image-version conflict across reachable machines.
10701221 fmt .Fprintf (bg .io .ErrOut , "\n Found %d different images in your app (for bluegreen to work, all machines need to run a single image)\n " , len (imageToMachineIDs ))
10711222 for image , ids := range imageToMachineIDs {
1072- fmt .Fprintf (bg .io .ErrOut , " [x] %s - %v machine(s) (%s)\n " , image , len (ids ), strings .Join (imageToMachineIDs [ image ] , "," ))
1223+ fmt .Fprintf (bg .io .ErrOut , " [x] %s - %v machine(s) (%s)\n " , image , len (ids ), strings .Join (ids , "," ))
10731224 }
10741225
10751226 if len (safeToDelete ) > 0 {
1076- fmt .Fprintf (bg .io .ErrOut , "\n These image(s) can be safely destroyed:\n " )
1227+ fmt .Fprintf (bg .io .ErrOut , "\n These image(s) are from a previous failed deployment and can be safely destroyed:\n " )
10771228 for image := range safeToDelete {
10781229 fmt .Fprintf (bg .io .ErrOut , " [x] %s - %v machine(s) ('fly machines destroy --force --image=%s --app=%s')\n " , image , len (imageToMachineIDs [image ]), image , bg .appConfig .AppName )
10791230 }
@@ -1089,6 +1240,23 @@ func (bg *blueGreen) DetectMultipleImageVersions(ctx context.Context) error {
10891240 return ErrMultipleImageVersions
10901241}
10911242
1243+ // warnUnreachableMachines prints a standout warning when some machines could
1244+ // not be reached for image verification. The deployment proceeds: green
1245+ // machines are created on healthy hosts and the unreachable blues are
1246+ // destroyed, or reported as hanging when the platform cannot destroy them.
1247+ func (bg * blueGreen ) warnUnreachableMachines (unreachableIDs []string ) {
1248+ sep := bg .colorize .Yellow (strings .Repeat ("!" , 70 ))
1249+ fmt .Fprintf (bg .io .ErrOut , "\n %s\n " , sep )
1250+ fmt .Fprint (bg .io .ErrOut , bg .colorize .Yellow (" WARNING: some machines are on hosts that are not ok — skipping image check for them\n " ))
1251+ fmt .Fprintf (bg .io .ErrOut , "\n %d machine(s) could not be reached to verify their running image:\n " , len (unreachableIDs ))
1252+ for _ , id := range unreachableIDs {
1253+ fmt .Fprintf (bg .io .ErrOut , " · %s\n " , id )
1254+ }
1255+ fmt .Fprintf (bg .io .ErrOut , "\n Deployment proceeding. These machines will be replaced on healthy hosts\n " +
1256+ " and force-destroyed at the end of the deployment.\n " )
1257+ fmt .Fprintf (bg .io .ErrOut , "%s\n \n " , sep )
1258+ }
1259+
10921260// This method tags blue-machines with a safe to destroy value.
10931261// This way, a user can easily remove blue machines that are hanging around from deployment.
10941262func (bg * blueGreen ) TagBlueMachinesAsSafeForDeletion (ctx context.Context ) error {
0 commit comments