From 4bf6d8393af2d8a59ee405107a771993ae52cca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=81nis=20Bebr=C4=ABtis?= Date: Tue, 9 Dec 2025 18:05:52 +0200 Subject: [PATCH] Allows orphaned release resource removal --- cmd/ciReleaseDeleteResources.go | 63 ++++++ docs/silta_ci_release.md | 1 + docs/silta_ci_release_delete-resources.md | 29 +++ docs/silta_cloud_login.md | 4 + internal/common/ciReleaseFunctions.go | 255 ++++++++++++++++++++++ 5 files changed, 352 insertions(+) create mode 100644 cmd/ciReleaseDeleteResources.go create mode 100644 docs/silta_ci_release_delete-resources.md diff --git a/cmd/ciReleaseDeleteResources.go b/cmd/ciReleaseDeleteResources.go new file mode 100644 index 0000000..6b1a8f3 --- /dev/null +++ b/cmd/ciReleaseDeleteResources.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "log" + "os" + + "github.com/spf13/cobra" + "github.com/wunderio/silta-cli/internal/common" + _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" // gcp auth provider + + helmAction "helm.sh/helm/v3/pkg/action" + helmCli "helm.sh/helm/v3/pkg/cli" +) + +var ciReleaseDeleteResourcesCmd = &cobra.Command{ + Use: "delete-resources", + Short: "Delete orphaned release resources", + Long: `Deletes release resources based on labels ("release", "app.kubernetes.io/instance" and "app=-es" (for Elasticsearch storage)) + This command can be used to clean up resources when helm release configmaps are absent. + `, + Run: func(cmd *cobra.Command, args []string) { + releaseName, _ := cmd.Flags().GetString("release-name") + namespace, _ := cmd.Flags().GetString("namespace") + deletePVCs, _ := cmd.Flags().GetBool("delete-pvcs") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + clientset, err := common.GetKubeClient() + if err != nil { + log.Fatalf("failed to get kube client: %v", err) + } + + // Helm client init logic + settings := helmCli.New() + settings.SetNamespace(namespace) // Ensure Helm uses the correct namespace + + actionConfig := new(helmAction.Configuration) + if err := actionConfig.Init(settings.RESTClientGetter(), namespace, os.Getenv("HELM_DRIVER"), log.Printf); err != nil { + log.Printf("%+v", err) + os.Exit(1) + } + + fmt.Printf("Finding orphaned resources for release %s in namespace %s\n", releaseName, namespace) + + err = common.DeleteOrphanedReleaseResources(clientset, actionConfig, namespace, releaseName, deletePVCs, dryRun) + if err != nil { + log.Fatalf("Error removing a release: %s", err) + } + + }, +} + +func init() { + ciReleaseCmd.AddCommand(ciReleaseDeleteResourcesCmd) + + ciReleaseDeleteResourcesCmd.Flags().String("release-name", "", "Release name") + ciReleaseDeleteResourcesCmd.Flags().String("namespace", "", "Project name (namespace, i.e. \"drupal-project\")") + ciReleaseDeleteResourcesCmd.Flags().Bool("delete-pvcs", true, "Delete PVCs (default: true)") + ciReleaseDeleteResourcesCmd.Flags().Bool("dry-run", true, "Dry run (default: true)") + + ciReleaseDeleteResourcesCmd.MarkFlagRequired("release-name") + ciReleaseDeleteResourcesCmd.MarkFlagRequired("namespace") +} diff --git a/docs/silta_ci_release.md b/docs/silta_ci_release.md index 72be06c..ed44b3f 100644 --- a/docs/silta_ci_release.md +++ b/docs/silta_ci_release.md @@ -24,6 +24,7 @@ silta ci release [flags] * [silta ci](silta_ci.md) - Silta CI Commands * [silta ci release clean-failed](silta_ci_release_clean-failed.md) - Clean failed releases * [silta ci release delete](silta_ci_release_delete.md) - Delete a release +* [silta ci release delete-resources](silta_ci_release_delete-resources.md) - Delete orphhaned release resources * [silta ci release deploy](silta_ci_release_deploy.md) - Deploy release * [silta ci release diff](silta_ci_release_diff.md) - Diff release resources * [silta ci release environmentname](silta_ci_release_environmentname.md) - Return environment name diff --git a/docs/silta_ci_release_delete-resources.md b/docs/silta_ci_release_delete-resources.md new file mode 100644 index 0000000..64d18ea --- /dev/null +++ b/docs/silta_ci_release_delete-resources.md @@ -0,0 +1,29 @@ +## silta ci release delete-resources + +Delete orphhaned release resources + +``` +silta ci release delete-resources [flags] +``` + +### Options + +``` + --delete-pvcs Delete PVCs (default: true) (default true) + --dry-run Dry run (default: true) (default true) + -h, --help help for delete-resources + --namespace string Project name (namespace, i.e. "drupal-project") + --release-name string Release name +``` + +### Options inherited from parent commands + +``` + --debug Print variables, do not execute external commands, rather print them + --use-env Use environment variables for value assignment (default true) +``` + +### SEE ALSO + +* [silta ci release](silta_ci_release.md) - CI release related commands + diff --git a/docs/silta_cloud_login.md b/docs/silta_cloud_login.md index 25dea56..1d9a161 100644 --- a/docs/silta_cloud_login.md +++ b/docs/silta_cloud_login.md @@ -33,6 +33,9 @@ Log in to kubernetes cluster using different methods: - "--aks-tenant-id" flag or "AKS_TENANT_ID" environment variable - "--aks-sp-app-id" flag or "AKS_SP_APP_ID" environment variable - "--aks-sp-password" flag or "AKS_SP_PASSWORD" environment variable + + After login, the connection is tested by running "kubectl can-i get pods" command, + disable with "--test-connection=false" flag. ``` @@ -57,6 +60,7 @@ silta cloud login [flags] -h, --help help for login --kubeconfig string Kubernetes config content (plaintext, base64 encoded) --kubeconfigpath string Kubernetes config path (default "~/.kube/config") + --test-connection Test connection after login (default true) ``` ### Options inherited from parent commands diff --git a/internal/common/ciReleaseFunctions.go b/internal/common/ciReleaseFunctions.go index 9c92759..bc2a631 100644 --- a/internal/common/ciReleaseFunctions.go +++ b/internal/common/ciReleaseFunctions.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "time" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" @@ -127,3 +128,257 @@ func FailedReleaseCleanup(releaseName string, namespace string) { } } } + +func DeleteOrphanedReleaseResources(kubernetesClient *kubernetes.Clientset, helmClient *helmAction.Configuration, namespace string, releaseName string, deletePVCs bool, dryRun bool) error { + // Select related resources by label selectors + selectorLabels := []string{ + "release", + "app.kubernetes.io/instance", + "app" + "=" + releaseName + "-es", + } + + for _, l := range selectorLabels { + selector := l + "=" + releaseName + // Delete deployments + dpList, _ := kubernetesClient.AppsV1().Deployments(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range dpList.Items { + if dryRun { + fmt.Printf("Dry run: deployment/%s\n", v.Name) + } else { + fmt.Printf("Removing deployment/%s\n", v.Name) + kubernetesClient.AppsV1().Deployments(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + + // Delete statefulsets + stsList, _ := kubernetesClient.AppsV1().StatefulSets(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range stsList.Items { + if dryRun { + fmt.Printf("Dry run: statefulset/%s\n", v.Name) + } else { + log.Printf("Removing statefulset/%s\n", v.Name) + kubernetesClient.AppsV1().StatefulSets(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + + // Delete cronjobs + cjList, _ := kubernetesClient.BatchV1().CronJobs(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range cjList.Items { + if dryRun { + fmt.Printf("Dry run: cronjob/%s\n", v.Name) + } else { + fmt.Printf("Removing cronjob/%s\n", v.Name) + kubernetesClient.BatchV1().CronJobs(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + + // Delete jobs + jobList, _ := kubernetesClient.BatchV1().Jobs(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range jobList.Items { + if dryRun { + fmt.Printf("Dry run: job/%s\n", v.Name) + } else { + fmt.Printf("Removing job/%s\n", v.Name) + kubernetesClient.BatchV1().Jobs(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + + // Delete horizontal pod autoscalers + hpaList, _ := kubernetesClient.AutoscalingV1().HorizontalPodAutoscalers(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range hpaList.Items { + if dryRun { + fmt.Printf("Dry run: horizontalpodautoscaler/%s\n", v.Name) + } else { + fmt.Printf("Removing horizontalpodautoscaler/%s\n", v.Name) + kubernetesClient.AutoscalingV1().HorizontalPodAutoscalers(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + + // Delete ingresses + ingressList, _ := kubernetesClient.NetworkingV1().Ingresses(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range ingressList.Items { + if dryRun { + fmt.Printf("Dry run: ingress/%s\n", v.Name) + } else { + fmt.Printf("Removing ingress/%s\n", v.Name) + kubernetesClient.NetworkingV1().Ingresses(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + + // Delete pods + podList, _ := kubernetesClient.CoreV1().Pods(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range podList.Items { + if dryRun { + fmt.Printf("Dry run: pod/%s\n", v.Name) + } else { + fmt.Printf("Removing pod/%s\n", v.Name) + kubernetesClient.CoreV1().Pods(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + + // Delete services + svcList, _ := kubernetesClient.CoreV1().Services(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range svcList.Items { + if dryRun { + fmt.Printf("Dry run: service/%s\n", v.Name) + } else { + fmt.Printf("Removing service/%s\n", v.Name) + kubernetesClient.CoreV1().Services(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + + // Delete backendconfigs + bcList, _ := kubernetesClient.NetworkingV1().Ingresses(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range bcList.Items { + if dryRun { + fmt.Printf("Dry run: backendconfig/%s\n", v.Name) + } else { + fmt.Printf("Removing backendconfig/%s\n", v.Name) + kubernetesClient.NetworkingV1().Ingresses(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + + // Delete configmaps + cmList, _ := kubernetesClient.CoreV1().ConfigMaps(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range cmList.Items { + if dryRun { + fmt.Printf("Dry run: configmap/%s\n", v.Name) + } else { + fmt.Printf("Removing configmap/%s\n", v.Name) + kubernetesClient.CoreV1().ConfigMaps(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + + // Delete secrets + secretList, _ := kubernetesClient.CoreV1().Secrets(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range secretList.Items { + if dryRun { + fmt.Printf("Dry run: secret/%s\n", v.Name) + } else { + fmt.Printf("Removing secret/%s\n", v.Name) + kubernetesClient.CoreV1().Secrets(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + + // Delete cerficates + certList, _ := kubernetesClient.CoreV1().Secrets(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range certList.Items { + if dryRun { + fmt.Printf("Dry run: certificate/%s\n", v.Name) + } else { + fmt.Printf("Removing certificate/%s\n", v.Name) + kubernetesClient.CoreV1().Secrets(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + + // Delete persistent volume claims + if deletePVCs { + pvcList, _ := kubernetesClient.CoreV1().PersistentVolumeClaims(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range pvcList.Items { + if dryRun { + fmt.Printf("Dry run: persistentvolumeclaim/%s\n", v.Name) + } else { + fmt.Printf("Removing persistentvolumeclaim/%s\n", v.Name) + kubernetesClient.CoreV1().PersistentVolumeClaims(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + } + + // Delete persistent volumes + if deletePVCs { + pvList, _ := kubernetesClient.CoreV1().PersistentVolumes().List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range pvList.Items { + if dryRun { + fmt.Printf("Dry run: persistentvolume/%s\n", v.Name) + } else { + fmt.Printf("Removing persistentvolume/%s\n", v.Name) + kubernetesClient.CoreV1().PersistentVolumes().Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + time.Sleep(1 * time.Second) + } + } + } + + // Delete network policies + npList, _ := kubernetesClient.NetworkingV1().NetworkPolicies(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range npList.Items { + if dryRun { + fmt.Printf("Dry run: networkpolicy/%s\n", v.Name) + continue + } else { + fmt.Printf("Removing networkpolicy/%s\n", v.Name) + kubernetesClient.NetworkingV1().NetworkPolicies(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + + // Delete rolebindings + rbList, _ := kubernetesClient.RbacV1().RoleBindings(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range rbList.Items { + if dryRun { + fmt.Printf("Dry run: rolebinding/%s\n", v.Name) + } else { + fmt.Printf("Removing rolebinding/%s\n", v.Name) + kubernetesClient.RbacV1().RoleBindings(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + + // Delete roles + roleList, _ := kubernetesClient.RbacV1().Roles(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range roleList.Items { + if dryRun { + fmt.Printf("Dry run: role/%s\n", v.Name) + } else { + fmt.Printf("Removing role/%s\n", v.Name) + kubernetesClient.RbacV1().Roles(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + + // Delete serviceaccounts + saList, _ := kubernetesClient.CoreV1().ServiceAccounts(namespace).List(context.TODO(), v1.ListOptions{ + LabelSelector: selector, + }) + for _, v := range saList.Items { + if dryRun { + fmt.Printf("Dry run: serviceaccount/%s\n", v.Name) + } else { + fmt.Printf("Removing serviceaccount/%s\n", v.Name) + kubernetesClient.CoreV1().ServiceAccounts(namespace).Delete(context.TODO(), v.Name, v1.DeleteOptions{}) + } + } + } + + return nil +}