Skip to content

Commit fdf2ba6

Browse files
authored
Replace machines on unreachable hosts in bluegreen deployments (#5049)
handle host unreachable clause in deployments Bluegreen previously misreported machines on unreachable hosts as an image-version conflict (their ImageRef comes back empty, tallied as a bogus ':' image). Detect them via Machine.HostStatus instead — a host must be "ok" to be trusted; "unreachable", "unknown", or unset all mean the machine cannot be verified. A retried Get remains as fallback when the list API returns incomplete data for an ok host. Machines on non-ok hosts never block the deploy: a warning lists them and the deployment proceeds, replacing them with green machines on healthy hosts. They are skipped by the cordon/stop/wait teardown stages (they cannot respond, and waiting on them could burn the deploy timeout) and force-destroyed through the flaps API with kill=true and no lease nonce — the exact call behind 'fly machine destroy --force'. Machines that still fail to destroy are reported with a ready-to-run destroy command. This matches the rolling strategy, which already replaces machines on non-ok hosts unconditionally, so no opt-in flag is needed. A genuine image conflict among reachable machines still aborts.
1 parent 37b14bf commit fdf2ba6

3 files changed

Lines changed: 478 additions & 17 deletions

File tree

internal/command/deploy/mock_client_test.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ type mockFlapsClient struct {
3434
unhealthyGet bool
3535
launchInputs []fly.LaunchMachineInput
3636

37+
// GetFunc, when set, overrides the default Get behaviour. Useful for tests
38+
// that need fine-grained control over per-machine responses (e.g. to simulate
39+
// an unreachable host returning an empty ImageRef).
40+
GetFunc func(ctx context.Context, appName, machineID string) (*fly.Machine, error)
41+
3742
// uncordonTransientFailures causes Uncordon to fail this many times before
3843
// succeeding, simulating transient API errors for retry tests.
3944
uncordonTransientFailures int
@@ -43,6 +48,14 @@ type mockFlapsClient struct {
4348
machines []*fly.Machine
4449
leases map[string]struct{}
4550
nextMachineID int
51+
destroyCalls []destroyCall
52+
}
53+
54+
// destroyCall records one Destroy invocation so tests can assert how a
55+
// machine was destroyed (force/kill and which lease nonce was sent).
56+
type destroyCall struct {
57+
input fly.RemoveMachineInput
58+
nonce string
4659
}
4760

4861
func (m *mockFlapsClient) AcquireLease(ctx context.Context, appName, machineID string, ttl *int) (*fly.MachineLease, error) {
@@ -120,6 +133,10 @@ func (m *mockFlapsClient) DeleteVolume(ctx context.Context, appName, volumeId st
120133
}
121134

122135
func (m *mockFlapsClient) Destroy(ctx context.Context, appName string, input fly.RemoveMachineInput, nonce string) (err error) {
136+
m.mu.Lock()
137+
m.destroyCalls = append(m.destroyCalls, destroyCall{input: input, nonce: nonce})
138+
m.mu.Unlock()
139+
123140
if m.breakDestroy {
124141
return fmt.Errorf("failed to destroy %s", input.ID)
125142
}
@@ -146,9 +163,14 @@ func (m *mockFlapsClient) GenerateSecretKey(ctx context.Context, appName, name,
146163

147164
func (m *mockFlapsClient) Get(ctx context.Context, appName, machineID string) (*fly.Machine, error) {
148165
m.mu.Lock()
149-
defer m.mu.Unlock()
166+
getFn := m.GetFunc
167+
breakGet := m.breakGet
168+
m.mu.Unlock()
150169

151-
if m.breakGet {
170+
if getFn != nil {
171+
return getFn(ctx, appName, machineID)
172+
}
173+
if breakGet {
152174
return nil, fmt.Errorf("failed to get %s", machineID)
153175
}
154176
status := fly.Passing

internal/command/deploy/strategy_bluegreen.go

Lines changed: 180 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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

151168
func (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+
10541162
func (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.
10941262
func (bg *blueGreen) TagBlueMachinesAsSafeForDeletion(ctx context.Context) error {

0 commit comments

Comments
 (0)