diff --git a/doc/cloud-platform_environment.md b/doc/cloud-platform_environment.md index a32febc1..ede64700 100644 --- a/doc/cloud-platform_environment.md +++ b/doc/cloud-platform_environment.md @@ -20,6 +20,7 @@ Cloud Platform Environment actions * [cloud-platform environment apply](cloud-platform_environment_apply.md) - Perform a terraform apply and kubectl apply for a given namespace * [cloud-platform environment bump-module](cloud-platform_environment_bump-module.md) - Bump all specified module versions * [cloud-platform environment create](cloud-platform_environment_create.md) - Create an environment +* [cloud-platform environment destroy](cloud-platform_environment_destroy.md) - Perform a terraform destroy and kubectl delete for a given namespace * [cloud-platform environment divergence](cloud-platform_environment_divergence.md) - Check for divergence between the environments repository and the cluster * [cloud-platform environment ecr](cloud-platform_environment_ecr.md) - Add an ECR to a namespace * [cloud-platform environment plan](cloud-platform_environment_plan.md) - Perform a terraform plan and kubectl apply --dry-run=client for a given namespace using either -namespace flag or the diff --git a/doc/cloud-platform_environment_destroy.md b/doc/cloud-platform_environment_destroy.md new file mode 100644 index 00000000..880c8041 --- /dev/null +++ b/doc/cloud-platform_environment_destroy.md @@ -0,0 +1,59 @@ +## cloud-platform environment destroy + +Perform a terraform destroy and kubectl delete for a given namespace + +### Synopsis + + + Perform a kubectl destroy and a terraform delete for a given namespace using either -namespace flag or the + the namespace in the given PR Id/Number + + Along with the mandatory input flag, the below environments variables needs to be set + TF_VAR_cluster_name - e.g. "cp-1902-02" to get the vpc details for some modules like rds, es + TF_VAR_cluster_state_bucket - State where the cluster state is stored + TF_VAR_cluster_state_key - folder name/state key inside the state bucket where cluster state is stored + TF_VAR_github_owner - Github owner: ministryofjustice + TF_VAR_github_token - Personal access token with repo scope to push github action secrets + TF_VAR_kubernetes_cluster - Full name of the Cluster e.g. XXXXXX.gr7.eu-west2.eks.amazonaws.com + PINGDOM_API_TOKEN - API Token to access pingdom + PIPELINE_TERRAFORM_STATE_LOCK_TABLE - DynamoDB table where the state lock is stored + PIPELINE_STATE_BUCKET - State bucket where the environments state is stored e.g cloud-platform-terraform-state + PIPELINE_STATE_KEY_PREFIX - State key/ folder where the environments terraform state is stored e.g cloud-platform-environments + PIPELINE_STATE_REGION - State region of the bucket e.g. eu-west-1 + PIPELINE_CLUSTER - Cluster name/folder inside namespaces/ in cloud-platform-environments + PIPELINE_CLUSTER_STATE - Cluster name/folder inside the state bucket where the environments terraform state is stored + + +``` +cloud-platform environment destroy [flags] +``` + +### Examples + +``` +$ cloud-platform environment destroy -n + +``` + +### Options + +``` + --cluster string folder name under namespaces/ inside cloud-platform-environments repo refering to full cluster name + --github-token string Personal access Token from Github + -h, --help help for destroy + --kubecfg string path to kubeconfig file (default "/home/runner/.kube/config") + -n, --namespace string Namespace which you want to perform the destroy + --prNumber int Pull request ID or number to which you want to perform the destroy + --redact Redact the terraform output before printing (default true) +``` + +### Options inherited from parent commands + +``` + --skip-version-check don't check for updates +``` + +### SEE ALSO + +* [cloud-platform environment](cloud-platform_environment.md) - Cloud Platform Environment actions + diff --git a/pkg/commands/environment.go b/pkg/commands/environment.go index 6d196ffe..c5e9966c 100644 --- a/pkg/commands/environment.go +++ b/pkg/commands/environment.go @@ -33,6 +33,7 @@ func addEnvironmentCmd(topLevel *cobra.Command) { environmentApplyCmd, environmentBumpModuleCmd, environmentCreateCmd, + environmentDestroyCmd, environmentDivergenceCmd, environmentEcrCmd, environmentPlanCmd, @@ -63,28 +64,44 @@ func addEnvironmentCmd(topLevel *cobra.Command) { environmentApplyCmd.Flags().StringVar(&optFlags.ClusterCtx, "cluster", "", "folder name under namespaces/ inside cloud-platform-environments repo refering to full cluster name") environmentApplyCmd.PersistentFlags().BoolVar(&optFlags.RedactedEnv, "redact", true, "Redact the terraform output before printing") - // e.g. if this is the Pull rquest to perform the apply: https://github.com/ministryofjustice/cloud-platform-environments/pull/8370, the pr ID is 8370. - environmentPlanCmd.Flags().IntVar(&optFlags.PRNumber, "prNumber", 0, "Pull request ID or number to which you want to perform the plan") - environmentPlanCmd.Flags().StringVarP(&optFlags.Namespace, "namespace", "n", "", "Namespace which you want to perform the plan") - - // Re-use the environmental variable TF_VAR_github_token to call Github Client which is needed to perform terraform operations on each namespace - environmentPlanCmd.Flags().StringVar(&optFlags.GithubToken, "github-token", os.Getenv("TF_VAR_github_token"), "Personal access Token from Github ") - environmentPlanCmd.Flags().StringVar(&optFlags.KubecfgPath, "kubecfg", filepath.Join(homedir.HomeDir(), ".kube", "config"), "path to kubeconfig file") - environmentPlanCmd.Flags().StringVar(&optFlags.ClusterCtx, "cluster", "", "folder name under namespaces/ inside cloud-platform-environments repo refering to full cluster name") - environmentPlanCmd.PersistentFlags().BoolVar(&optFlags.RedactedEnv, "redact", true, "Redact the terraform output before printing") - environmentBumpModuleCmd.Flags().StringVarP(&module, "module", "m", "", "Module to upgrade the version") environmentBumpModuleCmd.Flags().StringVarP(&moduleVersion, "module-version", "v", "", "Semantic version to bump a module to") environmentCreateCmd.Flags().BoolVarP(&skipEnvCheck, "skip-env-check", "s", false, "Skip the environment check") environmentCreateCmd.Flags().StringVarP(&answersFile, "answers-file", "a", "", "Path to the answers file") + // e.g. if this is the Pull rquest to perform the apply: https://github.com/ministryofjustice/cloud-platform-environments/pull/8370, the pr ID is 8370. + environmentDestroyCmd.Flags().IntVar(&optFlags.PRNumber, "prNumber", 0, "Pull request ID or number to which you want to perform the destroy") + environmentDestroyCmd.Flags().StringVarP(&optFlags.Namespace, "namespace", "n", "", "Namespace which you want to perform the destroy") + + // Re-use the environmental variable TF_VAR_github_token to call Github Client which is needed to perform terraform operations on each namespace + environmentDestroyCmd.Flags().StringVar(&optFlags.GithubToken, "github-token", os.Getenv("TF_VAR_github_token"), "Personal access Token from Github ") + environmentDestroyCmd.Flags().StringVar(&optFlags.KubecfgPath, "kubecfg", filepath.Join(homedir.HomeDir(), ".kube", "config"), "path to kubeconfig file") + environmentDestroyCmd.Flags().StringVar(&optFlags.ClusterCtx, "cluster", "", "folder name under namespaces/ inside cloud-platform-environments repo refering to full cluster name") + environmentDestroyCmd.PersistentFlags().BoolVar(&optFlags.RedactedEnv, "redact", true, "Redact the terraform output before printing") + environmentDivergenceCmd.Flags().StringVarP(&clusterName, "cluster-name", "c", "live", "[optional] Cluster name") environmentDivergenceCmd.Flags().StringVarP(&githubToken, "github-token", "g", "", "[required] Github token") environmentDivergenceCmd.Flags().StringVarP(&kubeconfig, "kubeconfig", "k", "", "[optional] Kubeconfig file path") if err := environmentDivergenceCmd.MarkFlagRequired("github-token"); err != nil { log.Fatal(err) } + + // e.g. if this is the Pull rquest to perform the apply: https://github.com/ministryofjustice/cloud-platform-environments/pull/8370, the pr ID is 8370. + environmentPlanCmd.Flags().IntVar(&optFlags.PRNumber, "prNumber", 0, "Pull request ID or number to which you want to perform the plan") + environmentPlanCmd.Flags().StringVarP(&optFlags.Namespace, "namespace", "n", "", "Namespace which you want to perform the plan") + + // Re-use the environmental variable TF_VAR_github_token to call Github Client which is needed to perform terraform operations on each namespace + environmentPlanCmd.Flags().StringVar(&optFlags.GithubToken, "github-token", os.Getenv("TF_VAR_github_token"), "Personal access Token from Github ") + environmentPlanCmd.Flags().StringVar(&optFlags.KubecfgPath, "kubecfg", filepath.Join(homedir.HomeDir(), ".kube", "config"), "path to kubeconfig file") + environmentPlanCmd.Flags().StringVar(&optFlags.ClusterCtx, "cluster", "", "folder name under namespaces/ inside cloud-platform-environments repo refering to full cluster name") + environmentPlanCmd.PersistentFlags().BoolVar(&optFlags.RedactedEnv, "redact", true, "Redact the terraform output before printing") + + // Set KUBE_CONFIG_PATH to the path of the kubeconfig file + // This is needed for terraform to be able to connect to the cluster + if err := os.Setenv("KUBE_CONFIG_PATH", optFlags.KubecfgPath); err != nil { + log.Fatal(err) + } } var environmentCmd = &cobra.Command{ @@ -214,6 +231,52 @@ var environmentApplyCmd = &cobra.Command{ }, } +var environmentDestroyCmd = &cobra.Command{ + Use: "destroy", + Short: `Perform a terraform destroy and kubectl delete for a given namespace`, + Long: ` + Perform a kubectl destroy and a terraform delete for a given namespace using either -namespace flag or the + the namespace in the given PR Id/Number + + Along with the mandatory input flag, the below environments variables needs to be set + TF_VAR_cluster_name - e.g. "cp-1902-02" to get the vpc details for some modules like rds, es + TF_VAR_cluster_state_bucket - State where the cluster state is stored + TF_VAR_cluster_state_key - folder name/state key inside the state bucket where cluster state is stored + TF_VAR_github_owner - Github owner: ministryofjustice + TF_VAR_github_token - Personal access token with repo scope to push github action secrets + TF_VAR_kubernetes_cluster - Full name of the Cluster e.g. XXXXXX.gr7.eu-west2.eks.amazonaws.com + PINGDOM_API_TOKEN - API Token to access pingdom + PIPELINE_TERRAFORM_STATE_LOCK_TABLE - DynamoDB table where the state lock is stored + PIPELINE_STATE_BUCKET - State bucket where the environments state is stored e.g cloud-platform-terraform-state + PIPELINE_STATE_KEY_PREFIX - State key/ folder where the environments terraform state is stored e.g cloud-platform-environments + PIPELINE_STATE_REGION - State region of the bucket e.g. eu-west-1 + PIPELINE_CLUSTER - Cluster name/folder inside namespaces/ in cloud-platform-environments + PIPELINE_CLUSTER_STATE - Cluster name/folder inside the state bucket where the environments terraform state is stored + `, + Example: heredoc.Doc(` + $ cloud-platform environment destroy -n + `), + PreRun: upgradeIfNotLatest, + Run: func(cmd *cobra.Command, args []string) { + contextLogger := log.WithFields(log.Fields{"subcommand": "destroy"}) + + ghConfig := &github.GithubClientConfig{ + Repository: "cloud-platform-environments", + Owner: "ministryofjustice", + } + + applier := &environment.Apply{ + Options: &optFlags, + GithubClient: github.NewGithubClient(ghConfig, optFlags.GithubToken), + } + + err := applier.Destroy() + if err != nil { + contextLogger.Fatal(err) + } + }, +} + var environmentEcrCreateCmd = &cobra.Command{ Use: "create", Short: `Create "resources/ecr.tf" terraform file for an ECR`, diff --git a/pkg/environment/applier.go b/pkg/environment/applier.go index 550227fe..535756ee 100644 --- a/pkg/environment/applier.go +++ b/pkg/environment/applier.go @@ -17,8 +17,10 @@ const TerraformVersion = "1.2.5" type Applier interface { Initialize() KubectlApply(namespace, directory string, dryRun bool) (string, error) + KubectlDelete(namespace, directory string, dryRun bool) (string, error) TerraformInitAndPlan(namespace string, directory string) (string, error) TerraformInitAndApply(namespace string, directory string) (string, error) + TerraformInitAndDestroy(namespace string, directory string) (string, error) TerraformDestroy(directory string) error } @@ -153,6 +155,47 @@ func (m *ApplierImpl) TerraformInitAndPlan(namespace, directory string) (string, return out.String(), nil } +func (m *ApplierImpl) TerraformInitAndDestroy(namespace, directory string) (string, error) { + var out bytes.Buffer + terraform, err := tfexec.NewTerraform(directory, m.terraformBinaryPath) + if err != nil { + return "", errors.New("unable to instantiate Terraform: " + err.Error()) + } + + terraform.SetStdout(&out) + terraform.SetStderr(&out) + + // Sometimes the error text would be useful in the command output that's + // displayed in the UI. For this reason, we append the error to the + // output before we return it. + errReturn := func(out bytes.Buffer, err error) (string, error) { + if err != nil { + return fmt.Sprintf("%s\n%s", out.String(), err.Error()), err + } + + return out.String(), nil + } + + key := m.config.PipelineStateKeyPrefix + m.config.PipelineClusterState + "/" + namespace + "/terraform.tfstate" + + err = terraform.Init(context.Background(), + tfexec.BackendConfig(fmt.Sprintf("bucket=%s", m.config.PipelineStateBucket)), + tfexec.BackendConfig(fmt.Sprintf("key=%s", key)), + tfexec.BackendConfig(fmt.Sprintf("dynamodb_table=%s", m.config.PipelineTerraformStateLockTable)), + tfexec.BackendConfig(fmt.Sprintf("region=%s", m.config.PipelineStateRegion))) + if err != nil { + return errReturn(out, err) + } + + // ignore if any changes or no changes. + err = terraform.Destroy(context.Background()) + if err != nil { + return "", errors.New("unable to do Terraform Destroy: " + err.Error()) + } + + return out.String(), nil +} + func (m *ApplierImpl) TerraformDestroy(directory string) error { terraform, err := tfexec.NewTerraform(directory, m.terraformBinaryPath) if err != nil { @@ -177,3 +220,19 @@ func (m *ApplierImpl) KubectlApply(namespace, directory string, dryRun bool) (st return string(stdout), err } + +func (m *ApplierImpl) KubectlDelete(namespace, directory string, dryRun bool) (string, error) { + var args []string + if dryRun { + args = []string{"kubectl", "-n", namespace, "delete", "--dry-run=client", "-f", directory} + } else { + args = []string{"kubectl", "-n", namespace, "delete", "-f", directory} + } + + stdout, err := exec.Command(args[0], args[1:]...).CombinedOutput() + if err != nil { + err = fmt.Errorf("error: %v", err) + } + + return string(stdout), err +} diff --git a/pkg/environment/environmentApply.go b/pkg/environment/environmentApply.go index 16ff9293..5e6fb216 100644 --- a/pkg/environment/environmentApply.go +++ b/pkg/environment/environmentApply.go @@ -6,6 +6,7 @@ import ( "os" "strings" + gogithub "github.com/google/go-github/github" "github.com/kelseyhightower/envconfig" "github.com/ministryofjustice/cloud-platform-cli/pkg/github" "github.com/ministryofjustice/cloud-platform-cli/pkg/util" @@ -87,7 +88,11 @@ func (a *Apply) Plan() error { } return nil } else { - changedNamespaces, err := a.nsChangedInPR(a.Options.ClusterCtx, a.Options.PRNumber) + files, err := a.GithubClient.GetChangedFiles(a.Options.PRNumber) + if err != nil { + return fmt.Errorf("failed to fetch list of changed files: %s in PR %v", err, a.Options.PRNumber) + } + changedNamespaces, err := nsChangedInPR(files, a.Options.ClusterCtx, false) if err != nil { return err } @@ -124,7 +129,12 @@ func (a *Apply) Apply() error { return err } if isMerged { - changedNamespaces, err := a.nsChangedInPR(a.Options.ClusterCtx, a.Options.PRNumber) + repos, err := a.GithubClient.GetChangedFiles(a.Options.PRNumber) + if err != nil { + return err + } + + changedNamespaces, err := nsChangedInPR(repos, a.Options.ClusterCtx, false) if err != nil { return err } @@ -143,6 +153,41 @@ func (a *Apply) Apply() error { return nil } +// Destroy is the entry point for performing a namespace destroy. +// It checks if the working directory is in cloud-platform-environments, checks if a PR number is given and merged +// The method get the list of namespaces that are deleted in that merger PR, and for all namespaces in the PR does the +// terraform init and destroy and do a kubectl delete +func (a *Apply) Destroy() error { + fmt.Println("Destroying Namespaces in PR", a.Options.PRNumber) + if a.Options.PRNumber == 0 { + err := fmt.Errorf("a PR ID/Number is required to perform destroy") + return err + } + isMerged, err := a.GithubClient.IsMerged(a.Options.PRNumber) + if err != nil { + return err + } + if isMerged { + changedNamespaces, err := a.nsCreateRawChangedFilesInPR(a.Options.ClusterCtx, a.Options.PRNumber) + fmt.Println("Namespaces changed in PR", changedNamespaces) + if err != nil { + return err + } + for _, namespace := range changedNamespaces { + a.Options.Namespace = namespace + if _, err = os.Stat(a.Options.Namespace); err != nil { + fmt.Println("Destroying Namespace:", namespace) + err = a.destroyNamespace() + if err != nil { + return err + } + } + } + } + + return nil +} + // ApplyAll is the entry point for performing a namespace apply on all namespaces. // It checks if the working directory is in cloud-platform-environments, prepare the folder chunks based on the // numRoutines the apply has to run, then loop over the list of namespaces in each chunk and does the kubectl apply @@ -219,6 +264,19 @@ func (a *Apply) applyKubectl() (string, error) { return outputKubectl, nil } +// deleteKubectl calls the applier -> deleteKubectl with dry-run disabled and return the output from applier +func (a *Apply) deleteKubectl() (string, error) { + log.Printf("Running kubectl delete for namespace: %v in directory %v", a.Options.Namespace, a.Dir) + + outputKubectl, err := a.Applier.KubectlDelete(a.Options.Namespace, a.Dir, false) + if err != nil { + err := fmt.Errorf("error running kubectl delete on namespace %s: %v \n %v", a.Options.Namespace, err, outputKubectl) + return "", err + } + + return outputKubectl, nil +} + // planTerraform calls applier -> TerraformInitAndPlan and prints the output from applier func (a *Apply) planTerraform() (string, error) { log.Printf("Running Terraform Plan for namespace: %v", a.Options.Namespace) @@ -247,17 +305,26 @@ func (a *Apply) applyTerraform() (string, error) { return outputTerraform, nil } +// applyTerraform calls applier -> TerraformInitAndDestroy and prints the output from applier +func (a *Apply) destroyTerraform() (string, error) { + log.Printf("Running Terraform Destroy for namespace: %v", a.Options.Namespace) + + tfFolder := a.Dir + "/resources" + + outputTerraform, err := a.Applier.TerraformInitAndDestroy(a.Options.Namespace, tfFolder) + if err != nil { + err := fmt.Errorf("error running terraform on namespace %s: %v \n %v", a.Options.Namespace, err, outputTerraform) + return "", err + } + return outputTerraform, nil +} + // planNamespace intiates a new Apply object with options and env variables, and calls the // applyKubectl with dry-run enabled and calls applier TerraformInitAndPlan and prints the output func (a *Apply) planNamespace() error { applier := NewApply(*a.Options) repoPath := "namespaces/" + a.Options.ClusterCtx + "/" + a.Options.Namespace - if _, err := os.Stat(repoPath); os.IsNotExist(err) { - fmt.Printf("Namespace %s does not exist, skipping plan\n", a.Options.Namespace) - return nil - } - if util.IsYamlFileExists(repoPath) { outputKubectl, err := applier.planKubectl() if err != nil { @@ -271,11 +338,6 @@ func (a *Apply) planNamespace() error { exists, err := util.IsFilePathExists(repoPath + "/resources") if err == nil && exists { - // Set KUBE_CONFIG_PATH to the path of the kubeconfig file - // This is needed for terraform to be able to connect to the cluster - if err := os.Setenv("KUBE_CONFIG_PATH", a.Options.KubecfgPath); err != nil { - return err - } outputTerraform, err := applier.planTerraform() if err != nil { return err @@ -355,11 +417,6 @@ func (a *Apply) applyNamespace() error { exists, err := util.IsFilePathExists(repoPath + "/resources") if err == nil && exists { - // Set KUBE_CONFIG_PATH to the path of the kubeconfig file - // This is needed for terraform to be able to connect to the cluster - if err := os.Setenv("KUBE_CONFIG_PATH", a.Options.KubecfgPath); err != nil { - return err - } outputTerraform, err := applier.applyTerraform() if err != nil { return err @@ -373,26 +430,117 @@ func (a *Apply) applyNamespace() error { return nil } -// nsChangedInPR get the list of changed files for a given PR. checks if the namespaces exists in the given cluster -// folder and return the list of namespaces. -func (a *Apply) nsChangedInPR(cluster string, prNumber int) ([]string, error) { - repos, err := a.GithubClient.GetChangedFiles(prNumber) +// destroyNamespace intiates a apply object with options and env variables, and calls the +// calls applier TerraformInitAndDestroy, applyKubectl with dry-run disabled and prints the output +func (a *Apply) destroyNamespace() error { + repoPath := "namespaces/" + a.Options.ClusterCtx + "/" + a.Options.Namespace + + if _, err := os.Stat(repoPath); os.IsNotExist(err) { + fmt.Printf("Namespace %s does not exist, skipping destroy\n", a.Options.Namespace) + return nil + } + + applier := NewApply(*a.Options) + + exists, err := util.IsFilePathExists(repoPath + "/resources") + if err == nil && exists { + outputTerraform, err := applier.destroyTerraform() + if err != nil { + return err + } + + fmt.Println("\nOutput of terraform:") + util.RedactedEnv(os.Stdout, outputTerraform, a.Options.RedactedEnv) + + if util.IsYamlFileExists(repoPath) { + outputKubectl, err := applier.deleteKubectl() + if err != nil { + return err + } + + fmt.Println("\nOutput of kubectl:", outputKubectl) + } else { + fmt.Printf("Namespace %s does not have yaml resources folder, skipping kubectl delete", a.Options.Namespace) + } + + } else { + fmt.Printf("Namespace %s does not have terraform resources folder, skipping terraform destroy", a.Options.Namespace) + } + return nil +} + +// nsCreateRawChangedFilesInPR get the list of changed files for a given PR. checks if the file is deleted and +// write the deleted file to the namespace folder +func (a *Apply) nsCreateRawChangedFilesInPR(cluster string, prNumber int) ([]string, error) { + files, err := a.GithubClient.GetChangedFiles(prNumber) + if err != nil { + return nil, fmt.Errorf("failed to fetch list of changed files: %s", err) + } + + namespaces, err := nsChangedInPR(files, a.Options.ClusterCtx, true) + if err != nil { + return nil, fmt.Errorf("failed to get namespace for destroy from the PR: %s", err) + } + err = createNamespaceforDestroy(namespaces, cluster) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create namespace for destroy: %s", err) + } + + // Get the contents of the CommitFile from RawURL + // https://developer.github.com/v3/repos/contents/#get-contents + + for _, file := range files { + data, err := util.GetGithubRawContents(file.GetRawURL()) + if err != nil { + return nil, fmt.Errorf("failed to get raw contents: %s", err) + } + // Create List with changed files + if err := os.WriteFile(*file.Filename, data, 0644); err != nil { + return nil, fmt.Errorf("failed to write file list: %s", err) + } } + return namespaces, nil +} + +// nsChangedInPR get the list of changed files for a given PR. checks if the namespaces exists in the given cluster +// folder and return the list of namespaces. +func nsChangedInPR(files []*gogithub.CommitFile, cluster string, isDeleted bool) ([]string, error) { var namespaceNames []string - for _, repo := range repos { + for _, file := range files { + // check of the file is a deleted file + if isDeleted && *file.Status != "removed" { + return nil, fmt.Errorf("some of files are not marked for deletion: file %s is not deleted", *file.Filename) + } + // namespaces filepaths are assumed to come in // the format: namespaces/.cloud-platform.service.justice.gov.uk/ - s := strings.Split(*repo.Filename, "/") + s := strings.Split(*file.Filename, "/") //only get namespaces from the folder that belong to the given cluster and // ignore changes outside namespace directories if len(s) > 1 && s[1] == cluster { namespaceNames = append(namespaceNames, s[2]) } - } - return util.DeduplicateList(namespaceNames), nil } + +func createNamespaceforDestroy(namespaces []string, cluster string) error { + wd, _ := os.Getwd() + for _, ns := range namespaces { + // make directory if it doesn't exist + if _, err := os.Stat(wd + "/namespaces/" + cluster + "/" + ns); err != nil { + err := os.Mkdir(wd+"/namespaces/"+cluster+"/"+ns, 0755) + if err != nil { + return fmt.Errorf("error creating namespaces directory: %s", err) + } + err = os.Mkdir(wd+"/namespaces/"+cluster+"/"+ns+"/resources", 0755) + if err != nil { + return fmt.Errorf("error creating resources directory: %s", err) + } + } else { + return fmt.Errorf("error creating directory, namespace exists in the environments repo: %s", err) + } + } + return nil +} diff --git a/pkg/environment/environmentApply_test.go b/pkg/environment/environmentApply_test.go index 910ef7d5..0c96a90f 100644 --- a/pkg/environment/environmentApply_test.go +++ b/pkg/environment/environmentApply_test.go @@ -7,7 +7,6 @@ import ( "github.com/google/go-github/github" "github.com/ministryofjustice/cloud-platform-cli/pkg/environment/mocks" - ghMock "github.com/ministryofjustice/cloud-platform-cli/pkg/mocks/github" "github.com/stretchr/testify/assert" ) @@ -122,67 +121,6 @@ func TestApply_ApplyKubectl(t *testing.T) { } } -func TestApply_nsChangedInPR(t *testing.T) { - type args struct { - cluster string - prNumber int - } - tests := []struct { - name string - GetChangedFilesOutputs []*github.CommitFile - args args - want []string - wantErr bool - }{ - { - name: "pr with one namespace", - GetChangedFilesOutputs: []*github.CommitFile{ - { - SHA: github.String("6dcb09b5b57875f334f61aebed695e2e4193db5e"), - Filename: github.String("namespaces/testctx/ns1/file1.txt"), - Additions: github.Int(103), - Deletions: github.Int(21), - Changes: github.Int(124), - Status: github.String("added"), - Patch: github.String("@@ -132,7 +132,7 @@ module Test @@ -1000,7 +1000,7 @@ module Test"), - }, - { - SHA: github.String("f61aebed695e2e4193db5e6dcb09b5b57875f334"), - Filename: github.String("namespaces/testctx/ns1/file2.txt"), - Additions: github.Int(5), - Deletions: github.Int(3), - Changes: github.Int(103), - Status: github.String("modified"), - Patch: github.String("@@ -132,7 +132,7 @@ module Test @@ -1000,7 +1000,7 @@ module Test"), - }, - }, - args: args{ - cluster: "testctx", - prNumber: 8834, - }, - want: []string{"ns1"}, - wantErr: false, - }, - } - for _, tt := range tests { - ghClient := new(ghMock.GithubIface) - ghClient.On("GetChangedFiles", 8834).Return(tt.GetChangedFilesOutputs, nil) - t.Run(tt.name, func(t *testing.T) { - a := &Apply{ - GithubClient: ghClient, - } - got, err := a.nsChangedInPR(tt.args.cluster, tt.args.prNumber) - if (err != nil) != tt.wantErr { - t.Errorf("Apply.nsChangedInPR() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Apply.nsChangedInPR() = %v, want %v", got, tt.want) - } - }) - } -} - func TestSecretBlockerExists(t *testing.T) { tempDir := "namespaces/testCluster/testNamespace" tempFile := tempDir + "/SECRET_ROTATE_BLOCK" @@ -218,3 +156,238 @@ func Test_applySkipExists(t *testing.T) { defer os.RemoveAll("namespaces") } + +func Test_createNamespaceforDestroy(t *testing.T) { + repoPath := "namespaces/testCluster" + + err := os.MkdirAll(repoPath, os.ModePerm) + if err != nil { + t.Errorf("Failed to create repo path: %s", err) + } + + type args struct { + namespaces []string + cluster string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "Create namespace for destroy", + args: args{ + namespaces: []string{"testNamespace"}, + cluster: "testCluster", + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := createNamespaceforDestroy(tt.args.namespaces, tt.args.cluster); (err != nil) != tt.wantErr { + t.Errorf("createNamespaceforDestroy() error = %v, wantErr %v", err, tt.wantErr) + } + + }) + } + defer os.RemoveAll("namespaces") +} + +func Test_nsChangedInPR(t *testing.T) { + type args struct { + files []*github.CommitFile + cluster string + isDeleted bool + } + tests := []struct { + name string + args args + want []string + wantErr bool + }{ + { + name: "Pr for namespace change", + args: args{ + files: []*github.CommitFile{ + { + SHA: github.String("f61aebed695e2e4193db5euy686dcb09b5b57875f334"), + Filename: github.String("namespaces/testctx/ns1/file1.txt"), + Additions: github.Int(5), + Deletions: github.Int(3), + Changes: github.Int(103), + Status: github.String("modified"), + Patch: github.String("@@ -132,7 +132,7 @@ module Test @@ -1000,7 +1000,7 @@ module Test"), + }, + { + SHA: github.String("f61aebed695e2e4193db5e6dcb09b5b57875f334"), + Filename: github.String("namespaces/testctx/ns1/file2.txt"), + Additions: github.Int(5), + Deletions: github.Int(3), + Changes: github.Int(103), + Status: github.String("modified"), + Patch: github.String("@@ -132,7 +132,7 @@ module Test @@ -1000,7 +1000,7 @@ module Test"), + }, + }, + cluster: "testctx", + isDeleted: false, + }, + want: []string{"ns1"}, + wantErr: false, + }, + { + name: "Pr for namespace change with deleted file", + args: args{ + files: []*github.CommitFile{ + { + SHA: github.String("f61aebed695e2e4193db5euy686dcb09b5b57875f334"), + Filename: github.String("namespaces/testctx/ns2/file1.txt"), + Additions: github.Int(0), + Deletions: github.Int(3), + Changes: github.Int(0), + Status: github.String("removed"), + Patch: github.String("@@ -132,7 +132,7 @@ module Test @@ -1000,7 +1000,7 @@ module Test"), + }, + { + SHA: github.String("f61aebed695e2e4193db5e6dcb09b5b57875f334"), + Filename: github.String("namespaces/testctx/ns2/file2.txt"), + Additions: github.Int(0), + Deletions: github.Int(3), + Changes: github.Int(0), + Status: github.String("removed"), + Patch: github.String("@@ -132,7 +132,7 @@ module Test @@ -1000,7 +1000,7 @@ module Test"), + }, + }, + cluster: "testctx", + isDeleted: true, + }, + want: []string{"ns2"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := nsChangedInPR(tt.args.files, tt.args.cluster, tt.args.isDeleted) + if (err != nil) != tt.wantErr { + t.Errorf("nsChangedInPR() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("nsChangedInPR() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestApply_destroyTerraform(t *testing.T) { + type fields struct { + Options *Options + RequiredEnvVars RequiredEnvVars + Applier Applier + Dir string + } + tests := []struct { + name string + fields fields + TerraformOutputs string + checkExpectations func(t *testing.T, terraform *mocks.Applier, outputs string, err error) + }{ + { + name: "Destroy foobar namespace", + fields: fields{ + Options: &Options{ + Namespace: "foobar", + KubecfgPath: "/root/.kube/config", + ClusterCtx: "testctx", + }, + RequiredEnvVars: RequiredEnvVars{ + clustername: "cluster01", + clusterstatebucket: "clusterstatebucket", + kubernetescluster: "kubernetescluster01", + githubowner: "githubowner", + githubtoken: "githubtoken", + pingdomapitoken: "pingdomApikey", + }, + Dir: "/root/foobar", + }, + TerraformOutputs: "foobar", + checkExpectations: func(t *testing.T, apply *mocks.Applier, outputs string, err error) { + apply.AssertCalled(t, "TerraformInitAndDestroy", "foobar", "/root/foobar/resources") + assert.Nil(t, err) + assert.Len(t, outputs, 6) + }, + }, + } + for i := range tests { + terraform := new(mocks.Applier) + tfFolder := tests[i].fields.Dir + "/resources" + terraform.On("TerraformInitAndDestroy", tests[i].fields.Options.Namespace, tfFolder).Return(tests[i].TerraformOutputs, nil) + a := Apply{ + RequiredEnvVars: tests[i].fields.RequiredEnvVars, + Applier: terraform, + Dir: tests[i].fields.Dir, + Options: tests[i].fields.Options, + } + outputs, err := a.destroyTerraform() + t.Run(tests[i].name, func(t *testing.T) { + tests[i].checkExpectations(t, terraform, outputs, err) + }) + } + +} + +func TestApply_deleteKubectl(t *testing.T) { + type fields struct { + Options *Options + RequiredEnvVars RequiredEnvVars + Applier Applier + Dir string + } + tests := []struct { + name string + fields fields + KubectlOutputs string + checkExpectations func(t *testing.T, kubectl *mocks.Applier, outputs string, err error) + }{ + { + name: "Delete foo namespace", + fields: fields{ + Options: &Options{ + Namespace: "foobar", + KubecfgPath: "/root/.kube/config", + ClusterCtx: "testctx", + }, + RequiredEnvVars: RequiredEnvVars{ + clustername: "cluster01", + clusterstatebucket: "clusterstatebucket", + kubernetescluster: "kubernetescluster01", + githubowner: "githubowner", + githubtoken: "githubtoken", + pingdomapitoken: "pingdomApikey", + }, + Dir: "/root/foo", + }, + KubectlOutputs: "/root/foo", + checkExpectations: func(t *testing.T, apply *mocks.Applier, outputs string, err error) { + apply.AssertCalled(t, "KubectlDelete", "foobar", "/root/foo", false) + assert.Nil(t, err) + assert.Len(t, outputs, 9) + }, + }, + } + for i := range tests { + kubectl := new(mocks.Applier) + kubectl.On("KubectlDelete", "foobar", tests[i].fields.Dir, false).Return(tests[i].KubectlOutputs, nil) + a := Apply{ + RequiredEnvVars: tests[i].fields.RequiredEnvVars, + Applier: kubectl, + Dir: tests[i].fields.Dir, + Options: tests[i].fields.Options, + } + outputs, err := a.deleteKubectl() + t.Run(tests[i].name, func(t *testing.T) { + tests[i].checkExpectations(t, kubectl, outputs, err) + }) + } +} diff --git a/pkg/environment/mocks/Applier.go b/pkg/environment/mocks/Applier.go index a339f34d..4a9d85b3 100644 --- a/pkg/environment/mocks/Applier.go +++ b/pkg/environment/mocks/Applier.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.14.0. DO NOT EDIT. +// Code generated by mockery v2.30.16. DO NOT EDIT. package mocks @@ -19,13 +19,40 @@ func (_m *Applier) KubectlApply(namespace string, directory string, dryRun bool) ret := _m.Called(namespace, directory, dryRun) var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string, string, bool) (string, error)); ok { + return rf(namespace, directory, dryRun) + } if rf, ok := ret.Get(0).(func(string, string, bool) string); ok { r0 = rf(namespace, directory, dryRun) } else { r0 = ret.Get(0).(string) } + if rf, ok := ret.Get(1).(func(string, string, bool) error); ok { + r1 = rf(namespace, directory, dryRun) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// KubectlDelete provides a mock function with given fields: namespace, directory, dryRun +func (_m *Applier) KubectlDelete(namespace string, directory string, dryRun bool) (string, error) { + ret := _m.Called(namespace, directory, dryRun) + + var r0 string var r1 error + if rf, ok := ret.Get(0).(func(string, string, bool) (string, error)); ok { + return rf(namespace, directory, dryRun) + } + if rf, ok := ret.Get(0).(func(string, string, bool) string); ok { + r0 = rf(namespace, directory, dryRun) + } else { + r0 = ret.Get(0).(string) + } + if rf, ok := ret.Get(1).(func(string, string, bool) error); ok { r1 = rf(namespace, directory, dryRun) } else { @@ -54,13 +81,40 @@ func (_m *Applier) TerraformInitAndApply(namespace string, directory string) (st ret := _m.Called(namespace, directory) var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string, string) (string, error)); ok { + return rf(namespace, directory) + } if rf, ok := ret.Get(0).(func(string, string) string); ok { r0 = rf(namespace, directory) } else { r0 = ret.Get(0).(string) } + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(namespace, directory) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// TerraformInitAndDestroy provides a mock function with given fields: namespace, directory +func (_m *Applier) TerraformInitAndDestroy(namespace string, directory string) (string, error) { + ret := _m.Called(namespace, directory) + + var r0 string var r1 error + if rf, ok := ret.Get(0).(func(string, string) (string, error)); ok { + return rf(namespace, directory) + } + if rf, ok := ret.Get(0).(func(string, string) string); ok { + r0 = rf(namespace, directory) + } else { + r0 = ret.Get(0).(string) + } + if rf, ok := ret.Get(1).(func(string, string) error); ok { r1 = rf(namespace, directory) } else { @@ -75,13 +129,16 @@ func (_m *Applier) TerraformInitAndPlan(namespace string, directory string) (str ret := _m.Called(namespace, directory) var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string, string) (string, error)); ok { + return rf(namespace, directory) + } if rf, ok := ret.Get(0).(func(string, string) string); ok { r0 = rf(namespace, directory) } else { r0 = ret.Get(0).(string) } - var r1 error if rf, ok := ret.Get(1).(func(string, string) error); ok { r1 = rf(namespace, directory) } else { @@ -91,13 +148,12 @@ func (_m *Applier) TerraformInitAndPlan(namespace string, directory string) (str return r0, r1 } -type mockConstructorTestingTNewApplier interface { +// NewApplier creates a new instance of Applier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewApplier(t interface { mock.TestingT Cleanup(func()) -} - -// NewApplier creates a new instance of Applier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewApplier(t mockConstructorTestingTNewApplier) *Applier { +}) *Applier { mock := &Applier{} mock.Mock.Test(t) diff --git a/pkg/util/util.go b/pkg/util/util.go index 4e7aaacb..6b8c23bc 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "net/http" "os/exec" "strings" "time" @@ -171,3 +172,24 @@ func DeduplicateList(s []string) (list []string) { return } + +func GetGithubRawContents(rawUrl string) ([]byte, error) { + response, err := http.Get(rawUrl) + + if response.StatusCode != 200 { + return nil, fmt.Errorf("GetRawContents: Github File Raw Response is not 200 OK. Filename: %s, Response: %s", rawUrl, response.Status) + } + + if err != nil { + return nil, fmt.Errorf(" GetRawContents: Github File Raw Error: %s", err) + } + + defer response.Body.Close() + + data, err := io.ReadAll(response.Body) + + if err != nil { + return nil, fmt.Errorf("GetRawContents: Read Data Error: %s", err) + } + return data, nil +} diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go index f23cc8cf..2a3c7b81 100644 --- a/pkg/util/util_test.go +++ b/pkg/util/util_test.go @@ -322,3 +322,31 @@ func TestGetDatePastMinute(t *testing.T) { }) } } + +func TestGetGithubRawContents(t *testing.T) { + type args struct { + rawUrl string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "get github raw contents", + args: args{ + rawUrl: "https://raw.githubusercontent.com/ministryofjustice/cloud-platform-cli/main/README.md", + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := GetGithubRawContents(tt.args.rawUrl) + if (err != nil) != tt.wantErr { + t.Errorf("GetGithubRawContents() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +}