Skip to content

Commit 0df2940

Browse files
authored
fix(destroy): recover when GKE is unreachable (#5897)
1 parent 4010e66 commit 0df2940

5 files changed

Lines changed: 454 additions & 49 deletions

File tree

cmd/destroy.go

Lines changed: 104 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package cmd
1818
import (
1919
"bufio"
2020
"context"
21+
"errors"
2122
"fmt"
2223
"hpc-toolkit/pkg/config"
2324
"hpc-toolkit/pkg/logging"
@@ -59,11 +60,17 @@ var (
5960
)
6061

6162
var (
62-
destroyGroupsFunc = destroyGroups
63-
cleanupFirewallRulesFunc = cleanupFirewallRules
64-
destroyTerraformGroupFunc = destroyTerraformGroup
63+
destroyGroupsFunc = destroyGroups
64+
cleanupFirewallRulesFunc = cleanupFirewallRules
65+
destroyTerraformGroupFunc = destroyTerraformGroup
66+
shellConfigureTerraformFunc = shell.ConfigureTerraform
67+
shellDestroyFunc = shell.Destroy
68+
shellRemoveKubernetesResourcesFromStateFunc = shell.RemoveKubernetesResourcesFromState
69+
confirmActionFunc = confirmAction
6570
)
6671

72+
var errUserAborted = errors.New("user declined state cleanup")
73+
6774
func runDestroyCmd(cmd *cobra.Command, args []string) {
6875
deplRoot := args[0]
6976
artifactsDir := getArtifactsDir(deplRoot)
@@ -88,14 +95,18 @@ func destroyRunner(deplRoot string, artifactsDir string, bp config.Blueprint, ct
8895
for attempt := 1; attempt <= maxRetries; attempt++ {
8996
logging.Info("Destroy attempt %d of %d", attempt, maxRetries)
9097

91-
destroyFailed, packerManifests := destroyGroupsFunc(deplRoot, artifactsDir, bp, ctx)
98+
destroyFailed, packerManifests, err := destroyGroupsFunc(deplRoot, artifactsDir, bp, ctx)
9299

93100
if !destroyFailed {
94101
logging.Info("Successfully destroyed all selected groups.")
95102
modulewriter.WritePackerDestroyInstructions(os.Stdout, packerManifests)
96103
return // Exit runDestroyCmd successfully
97104
}
98105

106+
if errors.Is(err, errUserAborted) {
107+
logging.Fatal("Destruction of %q failed: user declined state cleanup", deplRoot)
108+
}
109+
99110
if attempt == maxRetries {
100111
logging.Fatal("Destruction of %q failed after %d attempts", deplRoot, maxRetries)
101112
}
@@ -112,7 +123,7 @@ func groupHasNetworkModule(group config.Group) bool {
112123
return false
113124
}
114125

115-
func destroyGroups(deplRoot string, artifactsDir string, bp config.Blueprint, ctx *config.YamlCtx) (bool, []string) {
126+
func destroyGroups(deplRoot string, artifactsDir string, bp config.Blueprint, ctx *config.YamlCtx) (bool, []string, error) {
116127
// destroy in reverse order of creation!
117128
packerManifests := []string{}
118129
destroyFailed := false
@@ -123,48 +134,66 @@ func destroyGroups(deplRoot string, artifactsDir string, bp config.Blueprint, ct
123134
continue
124135
}
125136

126-
if robustDestroy && groupHasNetworkModule(group) {
127-
projectID, deploymentName, err := getProjectAndDeploymentVars(bp.Vars)
128-
if err != nil {
129-
logging.Error("Skipping firewall cleanup: could not get required variables. %v", err)
130-
destroyFailed = true
131-
break
132-
} else if err := cleanupFirewallRulesFunc(projectID, deploymentName); err != nil {
133-
logging.Error("Failed to cleanup firewall rules for group %s: %v", group.Name, err)
134-
destroyFailed = true
135-
break
136-
}
137+
manifests, failed, shouldBreak, err := destroyGroup(group, deplRoot, artifactsDir, bp, ctx, i)
138+
packerManifests = append(packerManifests, manifests...)
139+
if failed {
140+
destroyFailed = true
137141
}
138-
139-
groupDir := filepath.Join(deplRoot, string(group.Name))
140-
141-
if err := shell.ImportInputs(groupDir, artifactsDir, bp); err != nil {
142-
logging.Error("failed to import inputs for group %q: %v", group.Name, err)
143-
// still proceed with destroying the group
142+
if errors.Is(err, errUserAborted) {
143+
return true, packerManifests, err
144144
}
145-
146-
var err error
147-
switch group.Kind() {
148-
case config.PackerKind:
149-
// Packer groups are enforced to have length 1
150-
// TODO: destroyPackerGroup(moduleDir)
151-
moduleDir := filepath.Join(groupDir, string(group.Modules[0].ID))
152-
packerManifests = append(packerManifests, filepath.Join(moduleDir, "packer-manifest.json"))
153-
case config.TerraformKind:
154-
err = destroyTerraformGroupFunc(groupDir)
155-
default:
156-
err = fmt.Errorf("group %q is an unsupported kind %q", groupDir, group.Kind().String())
145+
if shouldBreak {
146+
break
157147
}
148+
}
149+
return destroyFailed, packerManifests, nil
150+
}
158151

152+
func destroyGroup(group config.Group, deplRoot string, artifactsDir string, bp config.Blueprint, ctx *config.YamlCtx, i int) ([]string, bool, bool, error) {
153+
packerManifests := []string{}
154+
destroyFailed := false
155+
if robustDestroy && groupHasNetworkModule(group) {
156+
projectID, deploymentName, err := getProjectAndDeploymentVars(bp.Vars)
159157
if err != nil {
160-
logging.Error("failed to destroy group %q:\n%s", group.Name, renderError(err, *ctx))
161-
destroyFailed = true
162-
if i == 0 || !destroyChoice(bp.Groups[i-1].Name) {
163-
break // Stop processing groups for this attempt
164-
}
158+
logging.Error("Skipping firewall cleanup: could not get required variables. %v", err)
159+
return nil, true, true, nil
160+
} else if err := cleanupFirewallRulesFunc(projectID, deploymentName); err != nil {
161+
logging.Error("Failed to cleanup firewall rules for group %s: %v", group.Name, err)
162+
return nil, true, true, nil
163+
}
164+
}
165+
166+
groupDir := filepath.Join(deplRoot, string(group.Name))
167+
168+
if err := shell.ImportInputs(groupDir, artifactsDir, bp); err != nil {
169+
logging.Error("failed to import inputs for group %q: %v", group.Name, err)
170+
// still proceed with destroying the group
171+
}
172+
173+
var err error
174+
switch group.Kind() {
175+
case config.PackerKind:
176+
// Packer groups are enforced to have length 1
177+
// TODO: destroyPackerGroup(moduleDir)
178+
moduleDir := filepath.Join(groupDir, string(group.Modules[0].ID))
179+
packerManifests = append(packerManifests, filepath.Join(moduleDir, "packer-manifest.json"))
180+
case config.TerraformKind:
181+
err = destroyTerraformGroupFunc(groupDir)
182+
default:
183+
err = fmt.Errorf("group %q is an unsupported kind %q", groupDir, group.Kind().String())
184+
}
185+
186+
if err != nil {
187+
if errors.Is(err, errUserAborted) {
188+
return packerManifests, true, true, err
189+
}
190+
logging.Error("failed to destroy group %q:\n%s", group.Name, renderError(err, *ctx))
191+
destroyFailed = true
192+
if i == 0 || !destroyChoice(bp.Groups[i-1].Name) {
193+
return packerManifests, destroyFailed, true, nil
165194
}
166195
}
167-
return destroyFailed, packerManifests
196+
return packerManifests, destroyFailed, false, nil
168197
}
169198

170199
func getStringVar(vars config.Dict, key string) (string, error) {
@@ -195,14 +224,45 @@ func getProjectAndDeploymentVars(vars config.Dict) (string, string, error) {
195224
}
196225

197226
func destroyTerraformGroup(groupDir string) error {
198-
tf, err := shell.ConfigureTerraform(groupDir)
227+
tf, err := shellConfigureTerraformFunc(groupDir)
199228
if err != nil {
200229
return err
201230
}
202231

203-
// Always output text when destroying the cluster
204-
// The current implementation outputs JSON only for the "deploy" command
205-
return shell.Destroy(tf, getApplyBehavior(), shell.TextOutput)
232+
err = shellDestroyFunc(tf, getApplyBehavior(), shell.TextOutput)
233+
if err != nil {
234+
if shell.IsKubernetesUnreachableError(err) {
235+
if robustDestroy {
236+
applyBehavior := getApplyBehavior()
237+
forceDestroyApproved := false
238+
239+
if applyBehavior == shell.PromptBeforeApply {
240+
promptMsg := "GKE cluster is unreachable. Do you want to force destroy the remaining infrastructure by removing GKE resources from the state? [y/n]: "
241+
if confirmActionFunc(promptMsg) {
242+
forceDestroyApproved = true
243+
} else {
244+
return errUserAborted
245+
}
246+
} else if applyBehavior == shell.AutomaticApply {
247+
forceDestroyApproved = true
248+
} else {
249+
return err
250+
}
251+
252+
if forceDestroyApproved {
253+
logging.Warn("GKE cluster is unreachable during destroy. Attempting to remove Kubernetes-related resources from state and retrying.")
254+
if rmErr := shellRemoveKubernetesResourcesFromStateFunc(tf); rmErr != nil {
255+
return fmt.Errorf("failed to clean unreachable Kubernetes resources from state: %w (original error: %v)", rmErr, err)
256+
}
257+
return shellDestroyFunc(tf, shell.AutomaticApply, shell.TextOutput)
258+
}
259+
} else {
260+
return fmt.Errorf("GKE cluster is unreachable. You can attempt a robust destroy to force-clean the state by using the --robust flag (original error: %w)", err)
261+
}
262+
}
263+
return err
264+
}
265+
return nil
206266
}
207267

208268
func confirmAction(prompt string) bool {

0 commit comments

Comments
 (0)