Skip to content

Commit 3d9459d

Browse files
feat(stack): add destroy command (#547)
1 parent 11b5992 commit 3d9459d

14 files changed

Lines changed: 627 additions & 20 deletions

File tree

internal/commands/all/list.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func (c *listCommand) InitCommand() {
3838

3939
// ExecuteWithoutArguments implements commands.NoArgumentCommand
4040
func (c *listCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) {
41-
resources, err := listResources(exec, c.include, c.exclude)
41+
resources, err := ListResources(exec, c.include, c.exclude)
4242
if err != nil {
4343
return nil, err
4444
}

internal/commands/all/purge.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@ func (c *purgeCommand) InitCommand() {
3636

3737
// ExecuteWithoutArguments implements commands.NoArgumentCommand
3838
func (c *purgeCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) {
39-
resources, err := listResources(exec, c.include, c.exclude)
39+
resources, err := ListResources(exec, c.include, c.exclude)
4040
if err != nil {
4141
return nil, err
4242
}
4343

44-
err = deleteResources(exec, resources, 16)
44+
err = DeleteResources(exec, resources, 16)
4545
if err != nil {
4646
return nil, err
4747
}

internal/commands/all/resource.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ func findResources[T any](exec commands.Executor, wg *sync.WaitGroup, returnChan
147147
}()
148148
}
149149

150-
func listResources(exec commands.Executor, include, exclude []string) ([]Resource, error) {
150+
func ListResources(exec commands.Executor, include, exclude []string) ([]Resource, error) {
151151
var resources []Resource
152152
returnChan := make(chan findResult, 12)
153153

@@ -301,7 +301,7 @@ type deleteResult struct {
301301
Error error
302302
}
303303

304-
func deleteResources(exec commands.Executor, resources []Resource, workerCount int) error {
304+
func DeleteResources(exec commands.Executor, resources []Resource, workerCount int) error {
305305
if len(resources) == 0 {
306306
return nil
307307
}

internal/commands/base/base.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,16 @@ func BuildCommands(rootCmd *cobra.Command, conf *config.Config) {
283283
commands.BuildCommand(all.PurgeCommand(), allCommand.Cobra(), conf)
284284
commands.BuildCommand(all.ListCommand(), allCommand.Cobra(), conf)
285285

286+
// Stack operations
286287
stackCommand := commands.BuildCommand(stack.BaseStackCommand(), rootCmd, conf)
287288
stackDeployCommand := commands.BuildCommand(stack.DeployCommand(), stackCommand.Cobra(), conf)
289+
stackDestroyCommand := commands.BuildCommand(stack.DestroyCommand(), stackCommand.Cobra(), conf)
288290
commands.BuildCommand(supabase.DeploySupabaseCommand(), stackDeployCommand.Cobra(), conf)
289291
commands.BuildCommand(dokku.DeployDokkuCommand(), stackDeployCommand.Cobra(), conf)
290292
commands.BuildCommand(starterkit.DeployStarterKitCommand(), stackDeployCommand.Cobra(), conf)
293+
commands.BuildCommand(supabase.DestroySupabaseCommand(), stackDestroyCommand.Cobra(), conf)
294+
commands.BuildCommand(dokku.DestroyDokkuCommand(), stackDestroyCommand.Cobra(), conf)
295+
commands.BuildCommand(starterkit.DestroyStarterKitCommand(), stackDestroyCommand.Cobra(), conf)
291296

292297
// Misc
293298
commands.BuildCommand(

internal/commands/stack/destroy.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package stack
2+
3+
import "github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
4+
5+
// DestroyCommand creates the "stack destroy" command
6+
func DestroyCommand() commands.Command {
7+
return &destroyCommand{
8+
BaseCommand: commands.New(
9+
"destroy",
10+
"Destroy a stack (EXPERIMENTAL)",
11+
"upctl stack destroy <stack-name>",
12+
"upctl stack destroy (supabase|dokku|starter-kit)",
13+
),
14+
}
15+
}
16+
17+
type destroyCommand struct {
18+
*commands.BaseCommand
19+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package dokku
2+
3+
import (
4+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands"
5+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/commands/stack"
6+
"github.com/UpCloudLtd/upcloud-cli/v3/internal/output"
7+
"github.com/spf13/pflag"
8+
)
9+
10+
func DestroyDokkuCommand() commands.Command {
11+
return &destroyDokkuCommand{
12+
BaseCommand: commands.New(
13+
"dokku",
14+
"Destroy a Dokku stack",
15+
"upctl stack destroy dokku --name <project-name> --zone <zone-name>",
16+
"upctl stack destroy dokku --name my-new-project --zone es-mad1",
17+
),
18+
}
19+
}
20+
21+
type destroyDokkuCommand struct {
22+
*commands.BaseCommand
23+
zone string
24+
name string
25+
}
26+
27+
func (s *destroyDokkuCommand) InitCommand() {
28+
fs := &pflag.FlagSet{}
29+
fs.StringVar(&s.zone, "zone", s.zone, "Zone for the stack deployment")
30+
fs.StringVar(&s.name, "name", s.name, "Supabase stack name")
31+
s.AddFlags(fs)
32+
33+
commands.Must(s.Cobra().MarkFlagRequired("zone"))
34+
commands.Must(s.Cobra().MarkFlagRequired("name"))
35+
}
36+
37+
// ExecuteWithoutArguments implements commands.NoArgumentCommand
38+
// TODO: This is not deleting the LB that dokku creates. Need to find a way to identify it.
39+
func (c *destroyDokkuCommand) ExecuteWithoutArguments(exec commands.Executor) (output.Output, error) {
40+
err := stack.DestroyStack(exec, c.name, c.zone, false, false, stack.StackTypeDokku)
41+
if err != nil {
42+
return nil, err
43+
}
44+
45+
return output.None{}, nil
46+
}

internal/commands/stack/dokku/dokku.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ import (
2020
)
2121

2222
func (s *deployDokkuCommand) deploy(exec commands.Executor, configDir string) error {
23-
clusterName := fmt.Sprintf("stack-dokku-cluster-%s-%s", s.name, s.zone)
24-
networkName := fmt.Sprintf("stack-dokku-net-%s-%s", s.name, s.zone)
23+
clusterName := fmt.Sprintf("%s-%s-%s", stack.DokkuResourceRootNameCluster, s.name, s.zone)
24+
networkName := fmt.Sprintf("%s-%s-%s", stack.DokkuResourceRootNameNetwork, s.name, s.zone)
2525
var network *upcloud.Network
2626

2727
// Check if the cluster already exists

internal/commands/stack/helm.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,45 @@ func DeployHelmReleaseFromRepo(
284284

285285
return nil
286286
}
287+
288+
// UninstallHelmRelease uninstalls a Helm release in namespace=releaseName.
289+
func UninstallHelmRelease(releaseName, logDir string) error {
290+
// return error if KUBECONFIG is not set
291+
if os.Getenv("KUBECONFIG") == "" {
292+
return errors.New("KUBECONFIG environment variable is not set")
293+
}
294+
295+
if err := os.Setenv("HELM_NAMESPACE", releaseName); err != nil {
296+
return fmt.Errorf("set HELM_NAMESPACE: %w", err)
297+
}
298+
299+
// Ensure logs are written to the same chartPath dir
300+
logFile, err := CreateHelmLogFile(logDir)
301+
if err != nil {
302+
return fmt.Errorf("creating Helm log file: %w", err)
303+
}
304+
defer logFile.Close()
305+
306+
// Initialize Helm action config
307+
actionConfig, err := InitHelmActionConfig(releaseName, logFile)
308+
if err != nil {
309+
return fmt.Errorf("initializing Helm action config: %w", err)
310+
}
311+
312+
// Prepare uninstall client
313+
uninstall := action.NewUninstall(actionConfig)
314+
uninstall.Wait = true
315+
uninstall.Timeout = 15 * time.Minute
316+
317+
// Run uninstall
318+
resp, err := uninstall.Run(releaseName)
319+
if err != nil {
320+
return fmt.Errorf("uninstalling release %q: %w", releaseName, err)
321+
}
322+
323+
if resp != nil {
324+
fmt.Fprintf(logFile, "Uninstalled release %q: %s\n", releaseName, resp.Info)
325+
}
326+
327+
return nil
328+
}

internal/commands/stack/kube.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,124 @@ func WriteKubeconfigToFile(exec commands.Executor, clusterID string, configDir s
279279

280280
return kubeconfigPath, nil
281281
}
282+
283+
// CollectPVCVolumeUUIDs collects the UUIDs of any UpCloud storage volumes that were provisioned
284+
// by PVCs in the given namespace (typically the Helm release namespace).
285+
func CollectPVCVolumeUUIDs(ctx context.Context, exec commands.Executor, kubeClient *kubernetes.Clientset, namespace string) ([]string, error) {
286+
var uuids []string
287+
288+
// List PVCs in the namespace
289+
pvcs, err := kubeClient.CoreV1().PersistentVolumeClaims(namespace).List(ctx, v1.ListOptions{})
290+
if err != nil {
291+
return nil, fmt.Errorf("listing PVCs in namespace %q: %w", namespace, err)
292+
}
293+
294+
if len(pvcs.Items) == 0 {
295+
msg := fmt.Sprintf("No PVCs found in namespace %q", namespace)
296+
exec.PushProgressStarted(msg)
297+
exec.PushProgressSuccess(msg)
298+
return nil, nil
299+
}
300+
301+
// Announce PVC check
302+
msg := fmt.Sprintf("Checking %d PVC(s) in namespace %q for backing volumes", len(pvcs.Items), namespace)
303+
exec.PushProgressStarted(msg)
304+
exec.PushProgressSuccess(msg)
305+
306+
// List PVs once
307+
pvs, err := kubeClient.CoreV1().PersistentVolumes().List(ctx, v1.ListOptions{})
308+
if err != nil {
309+
return nil, fmt.Errorf("listing PVs: %w", err)
310+
}
311+
312+
// Iterate over PVCs belonging to this namespace
313+
for _, pvc := range pvcs.Items {
314+
pvcMsg := fmt.Sprintf("PVC %s/%s", namespace, pvc.Name)
315+
exec.PushProgressStarted(pvcMsg)
316+
317+
if pvc.Spec.VolumeName == "" {
318+
exec.PushProgressStarted(fmt.Sprintf("%s → no bound PV, skipping", pvcMsg))
319+
exec.PushProgressSuccess(fmt.Sprintf("%s → no bound PV, skipping", pvcMsg))
320+
continue
321+
}
322+
323+
// Find the matching PV
324+
var matchedPV *corev1.PersistentVolume
325+
for _, pv := range pvs.Items {
326+
if pv.Name == pvc.Spec.VolumeName {
327+
matchedPV = &pv
328+
break
329+
}
330+
}
331+
if matchedPV == nil {
332+
exec.PushProgressStarted(fmt.Sprintf("%s → bound PV %q not found, skipping", pvcMsg, pvc.Spec.VolumeName))
333+
exec.PushProgressSuccess(fmt.Sprintf("%s → bound PV %q not found, skipping", pvcMsg, pvc.Spec.VolumeName))
334+
continue
335+
}
336+
337+
if matchedPV.Spec.CSI == nil {
338+
exec.PushProgressStarted(fmt.Sprintf("%s → PV %q has no CSI spec, skipping", pvcMsg, matchedPV.Name))
339+
exec.PushProgressSuccess(fmt.Sprintf("%s → PV %q has no CSI spec, skipping", pvcMsg, matchedPV.Name))
340+
continue
341+
}
342+
343+
storageUUID := matchedPV.Spec.CSI.VolumeHandle
344+
if storageUUID == "" {
345+
exec.PushProgressStarted(fmt.Sprintf("%s → PV %q has empty VolumeHandle, skipping", pvcMsg, matchedPV.Name))
346+
exec.PushProgressSuccess(fmt.Sprintf("%s → PV %q has empty VolumeHandle, skipping", pvcMsg, matchedPV.Name))
347+
continue
348+
}
349+
350+
uuids = append(uuids, storageUUID)
351+
exec.PushProgressSuccess(pvcMsg)
352+
}
353+
354+
return uuids, nil
355+
}
356+
357+
func DeletePVCVolumesByUUIDs(exec commands.Executor, uuids []string) error {
358+
for _, storageUUID := range uuids {
359+
deleteMsg := fmt.Sprintf("Deleting UpCloud storage %s", storageUUID)
360+
exec.PushProgressStarted(deleteMsg)
361+
362+
// Wait for the storage to be detached (in case the PVC was recently deleted)
363+
if err := waitForStorageToBeDetached(exec, storageUUID, 5*time.Minute); err != nil {
364+
exec.PushProgressUpdateMessage(fmt.Sprintf("storage %s", storageUUID), fmt.Sprintf("waiting failed: %v", err))
365+
continue
366+
}
367+
368+
req := &request.DeleteStorageRequest{UUID: storageUUID}
369+
if err := exec.All().DeleteStorage(exec.Context(), req); err != nil {
370+
exec.PushProgressStarted(fmt.Sprintf("Failed to delete UpCloud storage %s: %v", storageUUID, err))
371+
exec.PushProgressSuccess(fmt.Sprintf("Failed to delete UpCloud storage %s: %v", storageUUID, err))
372+
continue
373+
}
374+
375+
exec.PushProgressSuccess(deleteMsg)
376+
}
377+
return nil
378+
}
379+
380+
// WaitForNamespaceDeletion waits until the given namespace is deleted, or times out after the specified duration.
381+
func WaitForNamespaceDeletion(ctx context.Context, kubeClient *kubernetes.Clientset, name string, timeout time.Duration) error {
382+
ticker := time.NewTicker(2 * time.Second)
383+
defer ticker.Stop()
384+
timeoutCh := time.After(timeout)
385+
386+
for {
387+
select {
388+
case <-timeoutCh:
389+
return fmt.Errorf("namespace %q not deleted within %s", name, timeout)
390+
case <-ticker.C:
391+
_, err := kubeClient.CoreV1().Namespaces().Get(ctx, name, v1.GetOptions{})
392+
if err != nil {
393+
if apierrors.IsNotFound(err) {
394+
// Namespace gone
395+
return nil
396+
}
397+
return fmt.Errorf("error checking namespace %q: %w", name, err)
398+
}
399+
// still exists, keep waiting
400+
}
401+
}
402+
}

0 commit comments

Comments
 (0)