@@ -18,6 +18,7 @@ package cmd
1818import (
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
6162var (
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+
6774func 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
170199func getStringVar (vars config.Dict , key string ) (string , error ) {
@@ -195,14 +224,45 @@ func getProjectAndDeploymentVars(vars config.Dict) (string, string, error) {
195224}
196225
197226func 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
208268func confirmAction (prompt string ) bool {
0 commit comments