From cdeef8ae9101f6c6d5ab20c757a905e8469e7218 Mon Sep 17 00:00:00 2001 From: adohe Date: Mon, 15 Apr 2024 17:13:58 +0800 Subject: [PATCH] refactor: update delete cmd to follow the common cmd pattern --- pkg/cmd/build/build.go | 20 - pkg/cmd/build/options.go | 88 ----- pkg/cmd/cmd.go | 2 +- pkg/cmd/destroy/destroy.go | 363 ++++++++++++++++-- pkg/cmd/destroy/destroy_test.go | 337 +++++++++++++++- pkg/cmd/destroy/options.go | 292 -------------- pkg/cmd/destroy/options_test.go | 310 --------------- .../builders/appconfig_builder.go | 14 + .../builders/appconfig_builder_test.go | 28 +- pkg/cmd/generate/builders/testdata/kcl.mod | 10 + pkg/cmd/generate/generator/generator.go | 2 +- 11 files changed, 722 insertions(+), 744 deletions(-) delete mode 100644 pkg/cmd/build/build.go delete mode 100644 pkg/cmd/build/options.go delete mode 100644 pkg/cmd/destroy/options.go delete mode 100644 pkg/cmd/destroy/options_test.go rename pkg/cmd/{build => generate}/builders/appconfig_builder.go (65%) rename pkg/cmd/{build => generate}/builders/appconfig_builder_test.go (65%) create mode 100644 pkg/cmd/generate/builders/testdata/kcl.mod diff --git a/pkg/cmd/build/build.go b/pkg/cmd/build/build.go deleted file mode 100644 index 07b6c508..00000000 --- a/pkg/cmd/build/build.go +++ /dev/null @@ -1,20 +0,0 @@ -package build - -import ( - "github.com/spf13/cobra" - - "kusionstack.io/kusion/pkg/util/i18n" -) - -func (o *Options) AddBuildFlags(cmd *cobra.Command) { - cmd.Flags().StringVarP(&o.WorkDir, "workdir", "w", "", - i18n.T("Specify the work directory")) - cmd.Flags().StringSliceVarP(&o.Settings, "setting", "Y", []string{}, - i18n.T("Specify the command line setting files")) - cmd.Flags().StringToStringVarP(&o.Arguments, "argument", "D", map[string]string{}, - i18n.T("Specify the top-level argument")) - cmd.Flags().StringVarP(&o.Backend, "backend", "", "", - i18n.T("the backend name")) - cmd.Flags().StringVarP(&o.Backend, "workspace", "", "", - i18n.T("the workspace name")) -} diff --git a/pkg/cmd/build/options.go b/pkg/cmd/build/options.go deleted file mode 100644 index f4f6885f..00000000 --- a/pkg/cmd/build/options.go +++ /dev/null @@ -1,88 +0,0 @@ -package build - -import ( - "fmt" - "os" - "path/filepath" - - "kcl-lang.io/kpm/pkg/api" - - "kusionstack.io/kusion/pkg/project" -) - -const ( - KclFile = "kcl.yaml" -) - -type Options struct { - KclPkg *api.KclPackage - Filenames []string - Flags -} - -type Flags struct { - Output string - WorkDir string - Settings []string - Arguments map[string]string - NoStyle bool - Backend string - Workspace string -} - -const Stdout = "stdout" - -func NewBuildOptions() *Options { - return &Options{ - Filenames: []string{}, - Flags: Flags{ - Arguments: map[string]string{}, - Settings: make([]string, 0), - }, - } -} - -func (o *Options) Complete(args []string) error { - o.Filenames = args - return o.PreSet(project.IsStack) -} - -func (o *Options) Validate() error { - var wrongFiles []string - for _, filename := range o.Filenames { - if filepath.Ext(filename) != ".k" { - wrongFiles = append(wrongFiles, filename) - } - } - if len(wrongFiles) != 0 { - return fmt.Errorf("you can only compile files with suffix .k, these are wrong files: %v", wrongFiles) - } - return nil -} - -func (o *Options) PreSet(preCheck func(cur string) bool) error { - curDir := o.WorkDir - if o.WorkDir == "" { - curDir, _ = os.Getwd() - } - if ok := preCheck(curDir); !ok { - if o.Output == "" { - o.Output = Stdout - } - return nil - } - - var err error - o.KclPkg, err = api.GetKclPackage(o.WorkDir) - if err != nil { - return err - } - - if len(o.Settings) == 0 { - // if kcl.yaml exists, use it as settings - if _, err := os.Stat(filepath.Join(o.WorkDir, KclFile)); err == nil { - o.Settings = []string{KclFile} - } - } - return nil -} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index ab775cb3..aed74aac 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -102,7 +102,7 @@ func NewKusionctlCmd(o KusionctlOptions) *cobra.Command { Commands: []*cobra.Command{ preview.NewCmdPreview(o.IOStreams), apply.NewCmdApply(o.IOStreams), - destroy.NewCmdDestroy(), + destroy.NewCmdDestroy(o.IOStreams), }, }, { diff --git a/pkg/cmd/destroy/destroy.go b/pkg/cmd/destroy/destroy.go index 7100ad0f..dfecce7e 100644 --- a/pkg/cmd/destroy/destroy.go +++ b/pkg/cmd/destroy/destroy.go @@ -1,51 +1,370 @@ +// Copyright 2024 KusionStack Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package destroy import ( + "fmt" + "os" + "strings" + "sync" + + "github.com/AlecAivazis/survey/v2" + "github.com/pterm/pterm" "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericiooptions" "k8s.io/kubectl/pkg/util/i18n" "k8s.io/kubectl/pkg/util/templates" - "kusionstack.io/kusion/pkg/cmd/util" + apiv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + v1 "kusionstack.io/kusion/pkg/apis/status/v1" + "kusionstack.io/kusion/pkg/cmd/meta" + cmdutil "kusionstack.io/kusion/pkg/cmd/util" + "kusionstack.io/kusion/pkg/engine/operation" + "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/runtime/terraform" + "kusionstack.io/kusion/pkg/engine/state" + "kusionstack.io/kusion/pkg/log" + "kusionstack.io/kusion/pkg/util/pretty" + "kusionstack.io/kusion/pkg/util/signals" ) -func NewCmdDestroy() *cobra.Command { - var ( - destroyShort = i18n.T(`Destroy resources within the stack.`) - - destroyLong = i18n.T(` +var ( + destroyLong = i18n.T(` Destroy resources within the stack. Please note that the destroy command does NOT perform resource version checks. Therefore, if someone submits an update to a resource at the same time you execute a destroy command, their update will be lost along with the rest of the resource.`) - destroyExample = i18n.T(` + destroyExample = i18n.T(` # Delete resources of current stack kusion destroy`) - ) +) + +// DeleteFlags directly reflect the information that CLI is gathering via flags. They will be converted to +// DeleteOptions, which reflect the runtime requirements for the command. +// +// This structure reduces the transformation to wiring and makes the logic itself easy to unit test. +type DeleteFlags struct { + MetaFlags *meta.MetaFlags + + Operator string + Yes bool + Detail bool + + genericiooptions.IOStreams +} + +// DeleteOptions defines flags and other configuration parameters for the `delete` command. +type DeleteOptions struct { + *meta.MetaOptions + + Operator string + Yes bool + Detail bool + + genericiooptions.IOStreams +} + +// NewDeleteFlags returns a default DeleteFlags +func NewDeleteFlags(streams genericiooptions.IOStreams) *DeleteFlags { + return &DeleteFlags{ + MetaFlags: meta.NewMetaFlags(), + IOStreams: streams, + } +} + +// NewCmdDestroy creates the `delete` command. +func NewCmdDestroy(ioStreams genericiooptions.IOStreams) *cobra.Command { + flags := NewDeleteFlags(ioStreams) - o := NewDestroyOptions() cmd := &cobra.Command{ Use: "destroy", - Short: destroyShort, + Short: "Destroy resources within the stack.", Long: templates.LongDesc(destroyLong), Example: templates.Examples(destroyExample), - RunE: func(_ *cobra.Command, args []string) (err error) { - defer util.RecoverErr(&err) - o.Complete(args) - util.CheckErr(o.Validate()) - util.CheckErr(o.Run()) + RunE: func(cmd *cobra.Command, args []string) (err error) { + o, err := flags.ToOptions() + defer cmdutil.RecoverErr(&err) + cmdutil.CheckErr(err) + cmdutil.CheckErr(o.Validate(cmd, args)) + cmdutil.CheckErr(o.Run()) return }, } - o.AddBuildFlags(cmd) - cmd.Flags().StringVarP(&o.Operator, "operator", "", "", - i18n.T("Specify the operator")) - cmd.Flags().BoolVarP(&o.Yes, "yes", "y", false, - i18n.T("Automatically approve and perform the update after previewing it")) - cmd.Flags().BoolVarP(&o.Detail, "detail", "d", false, - i18n.T("Automatically show preview details after previewing it")) + flags.AddFlags(cmd) return cmd } + +// AddFlags registers flags for a cli. +func (flags *DeleteFlags) AddFlags(cmd *cobra.Command) { + // bind flag structs + flags.MetaFlags.AddFlags(cmd) + + cmd.Flags().StringVarP(&flags.Operator, "operator", "", flags.Operator, i18n.T("Specify the operator")) + cmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, i18n.T("Automatically approve and perform the update after previewing it")) + cmd.Flags().BoolVarP(&flags.Detail, "detail", "d", false, i18n.T("Automatically show preview details after previewing it")) +} + +// ToOptions converts from CLI inputs to runtime inputs. +func (flags *DeleteFlags) ToOptions() (*DeleteOptions, error) { + // Convert meta options + metaOptions, err := flags.MetaFlags.ToOptions() + if err != nil { + return nil, err + } + + o := &DeleteOptions{ + MetaOptions: metaOptions, + Operator: flags.Operator, + Detail: flags.Detail, + Yes: flags.Yes, + IOStreams: flags.IOStreams, + } + + return o, nil +} + +// Validate verifies if DeleteOptions are valid and without conflicts. +func (o *DeleteOptions) Validate(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return cmdutil.UsageErrorf(cmd, "Unexpected args: %v", args) + } + + return nil +} + +// Run executes the `delete` command. +func (o *DeleteOptions) Run() error { + // listen for interrupts or the SIGTERM signal + signals.HandleInterrupt() + + // only destroy resources we managed + storage := o.StorageBackend.StateStorage(o.RefProject.Name, o.RefStack.Name, o.RefWorkspace.Name) + priorState, err := storage.Get() + if err != nil || priorState == nil { + return fmt.Errorf("can not find DeprecatedState in this stack") + } + destroyResources := priorState.Resources + + if destroyResources == nil || len(priorState.Resources) == 0 { + pterm.Println(pterm.Green("No managed resources to destroy")) + return nil + } + + // compute changes for preview + i := &apiv1.Spec{Resources: destroyResources} + changes, err := o.preview(i, o.RefProject, o.RefStack, storage) + if err != nil { + return err + } + + // preview + changes.Summary(os.Stdout) + + // detail detection + if o.Detail { + changes.OutputDiff("all") + return nil + } + // prompt + if !o.Yes { + for { + var input string + input, err = prompt() + if err != nil { + return err + } + + if input == "yes" { + break + } else if input == "details" { + var target string + target, err = changes.PromptDetails() + if err != nil { + return err + } + changes.OutputDiff(target) + } else { + fmt.Println("Operation destroy canceled") + return nil + } + } + } + + // destroy + fmt.Println("Start destroying resources......") + if err = o.destroy(i, changes, storage); err != nil { + return err + } + return nil +} + +func (o *DeleteOptions) preview( + planResources *apiv1.Spec, + proj *apiv1.Project, + stack *apiv1.Stack, + stateStorage state.Storage, +) (*models.Changes, error) { + log.Info("Start compute preview changes ...") + + // check and install terraform executable binary for + // resources with the type of Terraform. + tfInstaller := terraform.CLIInstaller{ + Intent: planResources, + } + if err := tfInstaller.CheckAndInstall(); err != nil { + return nil, err + } + + pc := &operation.PreviewOperation{ + Operation: models.Operation{ + OperationType: models.DestroyPreview, + Stack: stack, + StateStorage: stateStorage, + ChangeOrder: &models.ChangeOrder{StepKeys: []string{}, ChangeSteps: map[string]*models.ChangeStep{}}, + }, + } + + log.Info("Start call pc.Preview() ...") + + rsp, s := pc.Preview(&operation.PreviewRequest{ + Request: models.Request{ + Project: proj, + Stack: stack, + Operator: o.Operator, + Intent: planResources, + }, + }) + if v1.IsErr(s) { + return nil, fmt.Errorf("preview failed, status: %v", s) + } + + return models.NewChanges(proj, stack, rsp.Order), nil +} + +func (o *DeleteOptions) destroy(planResources *apiv1.Spec, changes *models.Changes, stateStorage state.Storage) error { + destroyOpt := &operation.DestroyOperation{ + Operation: models.Operation{ + Stack: changes.Stack(), + StateStorage: stateStorage, + MsgCh: make(chan models.Message), + }, + } + + // line summary + var deleted int + + // progress bar, print dag walk detail + progressbar, err := pterm.DefaultProgressbar.WithMaxWidth(0).WithTotal(len(changes.StepKeys)).Start() + if err != nil { + return err + } + // wait msgCh close + var wg sync.WaitGroup + // receive msg and print detail + go func() { + defer func() { + if p := recover(); p != nil { + log.Errorf("failed to receive msg and print detail as %v", p) + } + }() + wg.Add(1) + + for { + select { + case msg, ok := <-destroyOpt.MsgCh: + if !ok { + wg.Done() + return + } + changeStep := changes.Get(msg.ResourceID) + + switch msg.OpResult { + case models.Success, models.Skip: + var title string + if changeStep.Action == models.UnChanged { + title = fmt.Sprintf("%s %s, %s", + changeStep.Action.String(), + pterm.Bold.Sprint(changeStep.ID), + strings.ToLower(string(models.Skip)), + ) + } else { + title = fmt.Sprintf("%s %s %s", + changeStep.Action.String(), + pterm.Bold.Sprint(changeStep.ID), + strings.ToLower(string(msg.OpResult)), + ) + } + pretty.SuccessT.Println(title) + progressbar.UpdateTitle(title) + progressbar.Increment() + deleted++ + case models.Failed: + title := fmt.Sprintf("%s %s %s", + changeStep.Action.String(), + pterm.Bold.Sprint(changeStep.ID), + strings.ToLower(string(msg.OpResult)), + ) + pretty.ErrorT.Printf("%s\n", title) + default: + title := fmt.Sprintf("%s %s %s", + changeStep.Action.Ing(), + pterm.Bold.Sprint(changeStep.ID), + strings.ToLower(string(msg.OpResult)), + ) + progressbar.UpdateTitle(title) + } + } + } + }() + + st := destroyOpt.Destroy(&operation.DestroyRequest{ + Request: models.Request{ + Project: changes.Project(), + Stack: changes.Stack(), + Operator: o.Operator, + Intent: planResources, + }, + }) + if v1.IsErr(st) { + return fmt.Errorf("destroy failed, status: %v", st) + } + + // wait for msgCh closed + wg.Wait() + // print summary + pterm.Println() + pterm.Printf("Destroy complete! Resources: %d deleted.\n", deleted) + return nil +} + +func prompt() (string, error) { + p := &survey.Select{ + Message: `Do you want to destroy these diffs?`, + Options: []string{"yes", "details", "no"}, + Default: "details", + } + + var input string + err := survey.AskOne(p, &input) + if err != nil { + fmt.Printf("Prompt failed %v\n", err) + return "", err + } + return input, nil +} diff --git a/pkg/cmd/destroy/destroy_test.go b/pkg/cmd/destroy/destroy_test.go index 3996d391..1238df68 100644 --- a/pkg/cmd/destroy/destroy_test.go +++ b/pkg/cmd/destroy/destroy_test.go @@ -1,15 +1,344 @@ +// Copyright 2024 KusionStack Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package destroy import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" "testing" + "github.com/AlecAivazis/survey/v2" + "github.com/bytedance/mockey" "github.com/stretchr/testify/assert" + + apiv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + internalv1 "kusionstack.io/kusion/pkg/apis/internal.kusion.io/v1" + v1 "kusionstack.io/kusion/pkg/apis/status/v1" + "kusionstack.io/kusion/pkg/backend" + "kusionstack.io/kusion/pkg/backend/storages" + "kusionstack.io/kusion/pkg/cmd/meta" + "kusionstack.io/kusion/pkg/engine" + "kusionstack.io/kusion/pkg/engine/operation" + "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/runtime" + "kusionstack.io/kusion/pkg/engine/runtime/kubernetes" + statestorages "kusionstack.io/kusion/pkg/engine/state/storages" + "kusionstack.io/kusion/pkg/project" + workspacestorages "kusionstack.io/kusion/pkg/workspace/storages" +) + +var ( + proj = &apiv1.Project{ + Name: "testdata", + } + stack = &apiv1.Stack{ + Name: "dev", + } + workspace = &apiv1.Workspace{ + Name: "default", + } +) + +func NewDeleteOptions() *DeleteOptions { + cwd, _ := os.Getwd() + storageBackend := storages.NewLocalStorage(&internalv1.BackendLocalConfig{ + Path: filepath.Join(cwd, "state.yaml"), + }) + return &DeleteOptions{ + MetaOptions: &meta.MetaOptions{ + RefProject: proj, + RefStack: stack, + RefWorkspace: workspace, + StorageBackend: storageBackend, + }, + Operator: "", + Detail: false, + } +} + +func TestDestroyOptionsRun(t *testing.T) { + mockey.PatchConvey("Detail is true", t, func() { + mockGetState() + mockWorkspaceStorage() + mockNewKubernetesRuntime() + mockOperationPreview() + + o := NewDeleteOptions() + o.Detail = true + err := o.Run() + assert.Nil(t, err) + }) + + mockey.PatchConvey("prompt no", t, func() { + mockDetectProjectAndStack() + mockGetState() + mockBackend() + mockWorkspaceStorage() + mockNewKubernetesRuntime() + mockOperationPreview() + + o := NewDeleteOptions() + mockPromptOutput("no") + err := o.Run() + assert.Nil(t, err) + }) + + mockey.PatchConvey("prompt yes", t, func() { + mockDetectProjectAndStack() + mockGetState() + mockBackend() + mockWorkspaceStorage() + mockNewKubernetesRuntime() + mockOperationPreview() + mockOperationDestroy(models.Success) + + o := NewDeleteOptions() + mockPromptOutput("yes") + err := o.Run() + assert.Nil(t, err) + }) +} + +func mockDetectProjectAndStack() { + mockey.Mock(project.DetectProjectAndStackFrom).To(func(stackDir string) (*apiv1.Project, *apiv1.Stack, error) { + proj.Path = stackDir + stack.Path = stackDir + return proj, stack, nil + }).Build() +} + +func mockGetState() { + mockey.Mock((*statestorages.LocalStorage).Get).Return(&apiv1.DeprecatedState{Resources: []apiv1.Resource{sa1}}, nil).Build() +} + +func TestPreview(t *testing.T) { + mockey.PatchConvey("preview success", t, func() { + mockNewKubernetesRuntime() + mockOperationPreview() + + o := NewDeleteOptions() + stateStorage := o.StorageBackend.StateStorage(o.RefProject.Name, o.RefStack.Name, o.RefWorkspace.Name) + _, err := o.preview(&apiv1.Spec{Resources: []apiv1.Resource{sa1}}, proj, stack, stateStorage) + assert.Nil(t, err) + }) +} + +func mockNewKubernetesRuntime() { + mockey.Mock(kubernetes.NewKubernetesRuntime).To(func() (runtime.Runtime, error) { + return &fakerRuntime{}, nil + }).Build() +} + +var _ runtime.Runtime = (*fakerRuntime)(nil) + +type fakerRuntime struct{} + +func (f *fakerRuntime) Import(_ context.Context, request *runtime.ImportRequest) *runtime.ImportResponse { + return &runtime.ImportResponse{Resource: request.PlanResource} +} + +func (f *fakerRuntime) Apply(_ context.Context, request *runtime.ApplyRequest) *runtime.ApplyResponse { + return &runtime.ApplyResponse{ + Resource: request.PlanResource, + Status: nil, + } +} + +func (f *fakerRuntime) Read(_ context.Context, request *runtime.ReadRequest) *runtime.ReadResponse { + if request.PlanResource.ResourceKey() == "fake-id" { + return &runtime.ReadResponse{ + Resource: nil, + Status: nil, + } + } + return &runtime.ReadResponse{ + Resource: request.PlanResource, + Status: nil, + } +} + +func (f *fakerRuntime) Delete(_ context.Context, _ *runtime.DeleteRequest) *runtime.DeleteResponse { + return nil +} + +func (f *fakerRuntime) Watch(_ context.Context, _ *runtime.WatchRequest) *runtime.WatchResponse { + return nil +} + +func mockOperationPreview() { + mockey.Mock((*operation.PreviewOperation).Preview).To( + func(*operation.PreviewOperation, *operation.PreviewRequest) (rsp *operation.PreviewResponse, s v1.Status) { + return &operation.PreviewResponse{ + Order: &models.ChangeOrder{ + StepKeys: []string{sa1.ID}, + ChangeSteps: map[string]*models.ChangeStep{ + sa1.ID: { + ID: sa1.ID, + Action: models.Delete, + From: nil, + }, + }, + }, + }, nil + }, + ).Build() +} + +const ( + apiVersion = "v1" + kind = "ServiceAccount" + namespace = "test-ns" +) + +var ( + sa1 = newSA("sa1") + sa2 = newSA("sa2") ) -func TestDestroyCommandRun(t *testing.T) { - t.Run("validate error", func(t *testing.T) { - cmd := NewCmdDestroy() - err := cmd.Execute() +func newSA(name string) apiv1.Resource { + return apiv1.Resource{ + ID: engine.BuildID(apiVersion, kind, namespace, name), + Type: "Kubernetes", + Attributes: map[string]interface{}{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]interface{}{ + "name": name, + "namespace": namespace, + }, + }, + } +} + +func TestDestroy(t *testing.T) { + mockey.PatchConvey("destroy success", t, func() { + mockNewKubernetesRuntime() + mockOperationDestroy(models.Success) + + o := NewDeleteOptions() + planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa2}} + order := &models.ChangeOrder{ + StepKeys: []string{sa1.ID, sa2.ID}, + ChangeSteps: map[string]*models.ChangeStep{ + sa1.ID: { + ID: sa1.ID, + Action: models.Delete, + From: nil, + }, + sa2.ID: { + ID: sa2.ID, + Action: models.UnChanged, + From: &sa2, + }, + }, + } + changes := models.NewChanges(proj, stack, order) + + stateStorage := o.StorageBackend.StateStorage(o.RefProject.Name, o.RefStack.Name, o.RefWorkspace.Name) + err := o.destroy(planResources, changes, stateStorage) + assert.Nil(t, err) + }) + mockey.PatchConvey("destroy failed", t, func() { + mockNewKubernetesRuntime() + mockOperationDestroy(models.Failed) + + o := NewDeleteOptions() + planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa1}} + order := &models.ChangeOrder{ + StepKeys: []string{sa1.ID}, + ChangeSteps: map[string]*models.ChangeStep{ + sa1.ID: { + ID: sa1.ID, + Action: models.Delete, + From: nil, + }, + }, + } + changes := models.NewChanges(proj, stack, order) + + stateStorage := o.StorageBackend.StateStorage(o.RefProject.Name, o.RefStack.Name, o.RefWorkspace.Name) + err := o.destroy(planResources, changes, stateStorage) + assert.NotNil(t, err) + }) +} + +func mockOperationDestroy(res models.OpResult) { + mockey.Mock((*operation.DestroyOperation).Destroy).To( + func(o *operation.DestroyOperation, request *operation.DestroyRequest) v1.Status { + var err error + if res == models.Failed { + err = errors.New("mock error") + } + for _, r := range request.Intent.Resources { + // ing -> $res + o.MsgCh <- models.Message{ + ResourceID: r.ResourceKey(), + OpResult: "", + OpErr: nil, + } + o.MsgCh <- models.Message{ + ResourceID: r.ResourceKey(), + OpResult: res, + OpErr: err, + } + } + close(o.MsgCh) + if res == models.Failed { + return v1.NewErrorStatus(err) + } + return nil + }).Build() +} + +func mockBackend() { + mockey.Mock(backend.NewBackend).Return(&storages.LocalStorage{}, nil).Build() +} + +func mockWorkspaceStorage() { + mockey.Mock((*storages.LocalStorage).WorkspaceStorage).Return(&workspacestorages.LocalStorage{}, nil).Build() + mockey.Mock((*workspacestorages.LocalStorage).GetCurrent).Return("default", nil).Build() +} + +func TestPrompt(t *testing.T) { + mockey.PatchConvey("prompt error", t, func() { + mockey.Mock( + survey.AskOne).To( + func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { + return errors.New("mock error") + }, + ).Build() + _, err := prompt() assert.NotNil(t, err) }) + + mockey.PatchConvey("prompt yes", t, func() { + mockPromptOutput("yes") + _, err := prompt() + assert.Nil(t, err) + }) +} + +func mockPromptOutput(res string) { + mockey.Mock( + survey.AskOne).To( + func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { + reflect.ValueOf(response).Elem().Set(reflect.ValueOf(res)) + return nil + }, + ).Build() } diff --git a/pkg/cmd/destroy/options.go b/pkg/cmd/destroy/options.go deleted file mode 100644 index c287e491..00000000 --- a/pkg/cmd/destroy/options.go +++ /dev/null @@ -1,292 +0,0 @@ -package destroy - -import ( - "fmt" - "os" - "strings" - "sync" - - "github.com/AlecAivazis/survey/v2" - "github.com/pterm/pterm" - - apiv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" - v1 "kusionstack.io/kusion/pkg/apis/status/v1" - "kusionstack.io/kusion/pkg/backend" - "kusionstack.io/kusion/pkg/cmd/build" - "kusionstack.io/kusion/pkg/engine/operation" - "kusionstack.io/kusion/pkg/engine/operation/models" - "kusionstack.io/kusion/pkg/engine/runtime/terraform" - "kusionstack.io/kusion/pkg/engine/state" - "kusionstack.io/kusion/pkg/log" - "kusionstack.io/kusion/pkg/project" - "kusionstack.io/kusion/pkg/util/pretty" - "kusionstack.io/kusion/pkg/util/signals" - "kusionstack.io/kusion/pkg/workspace" -) - -type Options struct { - build.Options - Operator string - Yes bool - Detail bool -} - -func NewDestroyOptions() *Options { - return &Options{ - Options: *build.NewBuildOptions(), - } -} - -func (o *Options) Complete(args []string) { - _ = o.Options.Complete(args) -} - -func (o *Options) Validate() error { - if err := o.Options.Validate(); err != nil { - return err - } - return nil -} - -func (o *Options) Run() error { - // listen for interrupts or the SIGTERM signal - signals.HandleInterrupt() - // parse project and stack of work directory - proj, stack, err := project.DetectProjectAndStackFrom(o.Options.WorkDir) - if err != nil { - return err - } - - // complete workspace name - bk, err := backend.NewBackend(o.Backend) - if err != nil { - return err - } - if o.Workspace == "" { - var wsStorage workspace.Storage - wsStorage, err = bk.WorkspaceStorage() - if err != nil { - return err - } - o.Workspace, err = wsStorage.GetCurrent() - if err != nil { - return err - } - } - - // only destroy resources we managed - storage := bk.StateStorage(proj.Name, stack.Name, o.Workspace) - priorState, err := storage.Get() - if err != nil || priorState == nil { - log.Infof("can't find state with project: %s, stack: %s, workspace: %s", proj.Name, stack.Name, o.Workspace) - return fmt.Errorf("can not find DeprecatedState in this stack") - } - destroyResources := priorState.Resources - - if destroyResources == nil || len(priorState.Resources) == 0 { - pterm.Println(pterm.Green("No managed resources to destroy")) - return nil - } - - // compute changes for preview - i := &apiv1.Spec{Resources: destroyResources} - changes, err := o.preview(i, proj, stack, storage) - if err != nil { - return err - } - - // preview - changes.Summary(os.Stdout) - - // detail detection - if o.Detail { - changes.OutputDiff("all") - return nil - } - // prompt - if !o.Yes { - for { - var input string - input, err = prompt() - if err != nil { - return err - } - - if input == "yes" { - break - } else if input == "details" { - var target string - target, err = changes.PromptDetails() - if err != nil { - return err - } - changes.OutputDiff(target) - } else { - fmt.Println("Operation destroy canceled") - return nil - } - } - } - - // destroy - fmt.Println("Start destroying resources......") - if err = o.destroy(i, changes, storage); err != nil { - return err - } - return nil -} - -func (o *Options) preview( - planResources *apiv1.Spec, - proj *apiv1.Project, - stack *apiv1.Stack, - stateStorage state.Storage, -) (*models.Changes, error) { - log.Info("Start compute preview changes ...") - - // check and install terraform executable binary for - // resources with the type of Terraform. - tfInstaller := terraform.CLIInstaller{ - Intent: planResources, - } - if err := tfInstaller.CheckAndInstall(); err != nil { - return nil, err - } - - pc := &operation.PreviewOperation{ - Operation: models.Operation{ - OperationType: models.DestroyPreview, - Stack: stack, - StateStorage: stateStorage, - ChangeOrder: &models.ChangeOrder{StepKeys: []string{}, ChangeSteps: map[string]*models.ChangeStep{}}, - }, - } - - log.Info("Start call pc.Preview() ...") - - rsp, s := pc.Preview(&operation.PreviewRequest{ - Request: models.Request{ - Project: proj, - Stack: stack, - Operator: o.Operator, - Intent: planResources, - }, - }) - if v1.IsErr(s) { - return nil, fmt.Errorf("preview failed, status: %v", s) - } - - return models.NewChanges(proj, stack, rsp.Order), nil -} - -func (o *Options) destroy(planResources *apiv1.Spec, changes *models.Changes, stateStorage state.Storage) error { - do := &operation.DestroyOperation{ - Operation: models.Operation{ - Stack: changes.Stack(), - StateStorage: stateStorage, - MsgCh: make(chan models.Message), - }, - } - - // line summary - var deleted int - - // progress bar, print dag walk detail - progressbar, err := pterm.DefaultProgressbar.WithMaxWidth(0).WithTotal(len(changes.StepKeys)).Start() - if err != nil { - return err - } - // wait msgCh close - var wg sync.WaitGroup - // receive msg and print detail - go func() { - defer func() { - if p := recover(); p != nil { - log.Errorf("failed to receive msg and print detail as %v", p) - } - }() - wg.Add(1) - - for { - select { - case msg, ok := <-do.MsgCh: - if !ok { - wg.Done() - return - } - changeStep := changes.Get(msg.ResourceID) - - switch msg.OpResult { - case models.Success, models.Skip: - var title string - if changeStep.Action == models.UnChanged { - title = fmt.Sprintf("%s %s, %s", - changeStep.Action.String(), - pterm.Bold.Sprint(changeStep.ID), - strings.ToLower(string(models.Skip)), - ) - } else { - title = fmt.Sprintf("%s %s %s", - changeStep.Action.String(), - pterm.Bold.Sprint(changeStep.ID), - strings.ToLower(string(msg.OpResult)), - ) - } - pretty.SuccessT.Println(title) - progressbar.UpdateTitle(title) - progressbar.Increment() - deleted++ - case models.Failed: - title := fmt.Sprintf("%s %s %s", - changeStep.Action.String(), - pterm.Bold.Sprint(changeStep.ID), - strings.ToLower(string(msg.OpResult)), - ) - pretty.ErrorT.Printf("%s\n", title) - default: - title := fmt.Sprintf("%s %s %s", - changeStep.Action.Ing(), - pterm.Bold.Sprint(changeStep.ID), - strings.ToLower(string(msg.OpResult)), - ) - progressbar.UpdateTitle(title) - } - } - } - }() - - st := do.Destroy(&operation.DestroyRequest{ - Request: models.Request{ - Project: changes.Project(), - Stack: changes.Stack(), - Operator: o.Operator, - Intent: planResources, - }, - }) - if v1.IsErr(st) { - return fmt.Errorf("destroy failed, status: %v", st) - } - - // wait for msgCh closed - wg.Wait() - // print summary - pterm.Println() - pterm.Printf("Destroy complete! Resources: %d deleted.\n", deleted) - return nil -} - -func prompt() (string, error) { - p := &survey.Select{ - Message: `Do you want to destroy these diffs?`, - Options: []string{"yes", "details", "no"}, - Default: "details", - } - - var input string - err := survey.AskOne(p, &input) - if err != nil { - fmt.Printf("Prompt failed %v\n", err) - return "", err - } - return input, nil -} diff --git a/pkg/cmd/destroy/options_test.go b/pkg/cmd/destroy/options_test.go deleted file mode 100644 index 3ae4eefc..00000000 --- a/pkg/cmd/destroy/options_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package destroy - -import ( - "context" - "errors" - "path/filepath" - "reflect" - "testing" - - "github.com/AlecAivazis/survey/v2" - "github.com/bytedance/mockey" - "github.com/stretchr/testify/assert" - - apiv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" - v1 "kusionstack.io/kusion/pkg/apis/status/v1" - "kusionstack.io/kusion/pkg/backend" - "kusionstack.io/kusion/pkg/backend/storages" - "kusionstack.io/kusion/pkg/engine" - "kusionstack.io/kusion/pkg/engine/operation" - "kusionstack.io/kusion/pkg/engine/operation/models" - "kusionstack.io/kusion/pkg/engine/runtime" - "kusionstack.io/kusion/pkg/engine/runtime/kubernetes" - statestorages "kusionstack.io/kusion/pkg/engine/state/storages" - "kusionstack.io/kusion/pkg/project" - workspacestorages "kusionstack.io/kusion/pkg/workspace/storages" -) - -func TestDestroyOptions_Run(t *testing.T) { - mockey.PatchConvey("Detail is true", t, func() { - mockDetectProjectAndStack() - mockGetState() - mockBackend() - mockWorkspaceStorage() - mockNewKubernetesRuntime() - mockOperationPreview() - - o := NewDestroyOptions() - o.Detail = true - err := o.Run() - assert.Nil(t, err) - }) - - mockey.PatchConvey("prompt no", t, func() { - mockDetectProjectAndStack() - mockGetState() - mockBackend() - mockWorkspaceStorage() - mockNewKubernetesRuntime() - mockOperationPreview() - - o := NewDestroyOptions() - mockPromptOutput("no") - err := o.Run() - assert.Nil(t, err) - }) - - mockey.PatchConvey("prompt yes", t, func() { - mockDetectProjectAndStack() - mockGetState() - mockBackend() - mockWorkspaceStorage() - mockNewKubernetesRuntime() - mockOperationPreview() - mockOperationDestroy(models.Success) - - o := NewDestroyOptions() - mockPromptOutput("yes") - err := o.Run() - assert.Nil(t, err) - }) -} - -var ( - proj = &apiv1.Project{ - Name: "testdata", - } - stack = &apiv1.Stack{ - Name: "dev", - } -) - -func mockDetectProjectAndStack() { - mockey.Mock(project.DetectProjectAndStackFrom).To(func(stackDir string) (*apiv1.Project, *apiv1.Stack, error) { - proj.Path = stackDir - stack.Path = stackDir - return proj, stack, nil - }).Build() -} - -func mockGetState() { - mockey.Mock((*statestorages.LocalStorage).Get).Return(&apiv1.DeprecatedState{Resources: []apiv1.Resource{sa1}}, nil).Build() -} - -func Test_preview(t *testing.T) { - mockey.PatchConvey("preview success", t, func() { - mockNewKubernetesRuntime() - mockOperationPreview() - - o := NewDestroyOptions() - stateStorage := statestorages.NewLocalStorage(filepath.Join(o.WorkDir, "state.yaml")) - _, err := o.preview(&apiv1.Spec{Resources: []apiv1.Resource{sa1}}, proj, stack, stateStorage) - assert.Nil(t, err) - }) -} - -func mockNewKubernetesRuntime() { - mockey.Mock(kubernetes.NewKubernetesRuntime).To(func() (runtime.Runtime, error) { - return &fakerRuntime{}, nil - }).Build() -} - -var _ runtime.Runtime = (*fakerRuntime)(nil) - -type fakerRuntime struct{} - -func (f *fakerRuntime) Import(_ context.Context, request *runtime.ImportRequest) *runtime.ImportResponse { - return &runtime.ImportResponse{Resource: request.PlanResource} -} - -func (f *fakerRuntime) Apply(_ context.Context, request *runtime.ApplyRequest) *runtime.ApplyResponse { - return &runtime.ApplyResponse{ - Resource: request.PlanResource, - Status: nil, - } -} - -func (f *fakerRuntime) Read(_ context.Context, request *runtime.ReadRequest) *runtime.ReadResponse { - if request.PlanResource.ResourceKey() == "fake-id" { - return &runtime.ReadResponse{ - Resource: nil, - Status: nil, - } - } - return &runtime.ReadResponse{ - Resource: request.PlanResource, - Status: nil, - } -} - -func (f *fakerRuntime) Delete(_ context.Context, _ *runtime.DeleteRequest) *runtime.DeleteResponse { - return nil -} - -func (f *fakerRuntime) Watch(_ context.Context, _ *runtime.WatchRequest) *runtime.WatchResponse { - return nil -} - -func mockOperationPreview() { - mockey.Mock((*operation.PreviewOperation).Preview).To( - func(*operation.PreviewOperation, *operation.PreviewRequest) (rsp *operation.PreviewResponse, s v1.Status) { - return &operation.PreviewResponse{ - Order: &models.ChangeOrder{ - StepKeys: []string{sa1.ID}, - ChangeSteps: map[string]*models.ChangeStep{ - sa1.ID: { - ID: sa1.ID, - Action: models.Delete, - From: nil, - }, - }, - }, - }, nil - }, - ).Build() -} - -const ( - apiVersion = "v1" - kind = "ServiceAccount" - namespace = "test-ns" -) - -var ( - sa1 = newSA("sa1") - sa2 = newSA("sa2") -) - -func newSA(name string) apiv1.Resource { - return apiv1.Resource{ - ID: engine.BuildID(apiVersion, kind, namespace, name), - Type: "Kubernetes", - Attributes: map[string]interface{}{ - "apiVersion": apiVersion, - "kind": kind, - "metadata": map[string]interface{}{ - "name": name, - "namespace": namespace, - }, - }, - } -} - -func Test_destroy(t *testing.T) { - mockey.PatchConvey("destroy success", t, func() { - mockNewKubernetesRuntime() - mockOperationDestroy(models.Success) - - o := NewDestroyOptions() - planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa2}} - order := &models.ChangeOrder{ - StepKeys: []string{sa1.ID, sa2.ID}, - ChangeSteps: map[string]*models.ChangeStep{ - sa1.ID: { - ID: sa1.ID, - Action: models.Delete, - From: nil, - }, - sa2.ID: { - ID: sa2.ID, - Action: models.UnChanged, - From: &sa2, - }, - }, - } - changes := models.NewChanges(proj, stack, order) - - stateStorage := statestorages.NewLocalStorage(filepath.Join(o.WorkDir, "state.yaml")) - - err := o.destroy(planResources, changes, stateStorage) - assert.Nil(t, err) - }) - mockey.PatchConvey("destroy failed", t, func() { - mockNewKubernetesRuntime() - mockOperationDestroy(models.Failed) - - o := NewDestroyOptions() - planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa1}} - order := &models.ChangeOrder{ - StepKeys: []string{sa1.ID}, - ChangeSteps: map[string]*models.ChangeStep{ - sa1.ID: { - ID: sa1.ID, - Action: models.Delete, - From: nil, - }, - }, - } - changes := models.NewChanges(proj, stack, order) - stateStorage := statestorages.NewLocalStorage(filepath.Join(o.WorkDir, "state.yaml")) - - err := o.destroy(planResources, changes, stateStorage) - assert.NotNil(t, err) - }) -} - -func mockOperationDestroy(res models.OpResult) { - mockey.Mock((*operation.DestroyOperation).Destroy).To( - func(o *operation.DestroyOperation, request *operation.DestroyRequest) v1.Status { - var err error - if res == models.Failed { - err = errors.New("mock error") - } - for _, r := range request.Intent.Resources { - // ing -> $res - o.MsgCh <- models.Message{ - ResourceID: r.ResourceKey(), - OpResult: "", - OpErr: nil, - } - o.MsgCh <- models.Message{ - ResourceID: r.ResourceKey(), - OpResult: res, - OpErr: err, - } - } - close(o.MsgCh) - if res == models.Failed { - return v1.NewErrorStatus(err) - } - return nil - }).Build() -} - -func mockBackend() { - mockey.Mock(backend.NewBackend).Return(&storages.LocalStorage{}, nil).Build() -} - -func mockWorkspaceStorage() { - mockey.Mock((*storages.LocalStorage).WorkspaceStorage).Return(&workspacestorages.LocalStorage{}, nil).Build() - mockey.Mock((*workspacestorages.LocalStorage).GetCurrent).Return("default", nil).Build() -} - -func Test_prompt(t *testing.T) { - mockey.PatchConvey("prompt error", t, func() { - mockey.Mock( - survey.AskOne).To( - func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - return errors.New("mock error") - }, - ).Build() - _, err := prompt() - assert.NotNil(t, err) - }) - - mockey.PatchConvey("prompt yes", t, func() { - mockPromptOutput("yes") - _, err := prompt() - assert.Nil(t, err) - }) -} - -func mockPromptOutput(res string) { - mockey.Mock( - survey.AskOne).To( - func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { - reflect.ValueOf(response).Elem().Set(reflect.ValueOf(res)) - return nil - }, - ).Build() -} diff --git a/pkg/cmd/build/builders/appconfig_builder.go b/pkg/cmd/generate/builders/appconfig_builder.go similarity index 65% rename from pkg/cmd/build/builders/appconfig_builder.go rename to pkg/cmd/generate/builders/appconfig_builder.go index e00ad392..33d74c33 100644 --- a/pkg/cmd/build/builders/appconfig_builder.go +++ b/pkg/cmd/generate/builders/appconfig_builder.go @@ -1,3 +1,17 @@ +// Copyright 2024 KusionStack Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package builders import ( diff --git a/pkg/cmd/build/builders/appconfig_builder_test.go b/pkg/cmd/generate/builders/appconfig_builder_test.go similarity index 65% rename from pkg/cmd/build/builders/appconfig_builder_test.go rename to pkg/cmd/generate/builders/appconfig_builder_test.go index 97734030..c86d3cf8 100644 --- a/pkg/cmd/build/builders/appconfig_builder_test.go +++ b/pkg/cmd/generate/builders/appconfig_builder_test.go @@ -1,19 +1,34 @@ +// Copyright 2024 KusionStack Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package builders import ( + "os" + "path/filepath" "testing" "github.com/bytedance/mockey" "github.com/stretchr/testify/assert" "kcl-lang.io/kpm/pkg/api" - pkg "kcl-lang.io/kpm/pkg/package" v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" internalv1 "kusionstack.io/kusion/pkg/apis/internal.kusion.io/v1" "kusionstack.io/kusion/pkg/modules" ) -func TestAppsConfigBuilder_Build(t *testing.T) { +func TestBuild(t *testing.T) { p, s := buildMockProjectAndStack() appName, app := buildMockApp() acg := &AppsConfigBuilder{ @@ -23,15 +38,16 @@ func TestAppsConfigBuilder_Build(t *testing.T) { Workspace: buildMockWorkspace(), } - kpmMock := mockey.Mock((*api.KclPackage).GetDependenciesInModFile).Return(&pkg.Dependencies{Deps: make(map[string]pkg.Dependency)}). - Build() callMock := mockey.Mock(modules.CallGenerators).Return(nil).Build() defer func() { - kpmMock.UnPatch() callMock.UnPatch() }() - kclPkg := &api.KclPackage{} + cwd, _ := os.Getwd() + pkgPath := filepath.Join(cwd, "testdata") + kclPkg, err := api.GetKclPackage(pkgPath) + assert.NoError(t, err) + intent, err := acg.Build(kclPkg, p, s) assert.NoError(t, err) assert.NotNil(t, intent) diff --git a/pkg/cmd/generate/builders/testdata/kcl.mod b/pkg/cmd/generate/builders/testdata/kcl.mod new file mode 100644 index 00000000..d6c82453 --- /dev/null +++ b/pkg/cmd/generate/builders/testdata/kcl.mod @@ -0,0 +1,10 @@ +[package] +name = "testdata" +version = "0.1.0" + +[dependencies] +kam = { git = "https://github.com/KusionStack/kam.git", tag = "0.1.0" } + +[profile] +entries = ["../base/base.k", "main.k"] + diff --git a/pkg/cmd/generate/generator/generator.go b/pkg/cmd/generate/generator/generator.go index 8e24f30d..5ed5f5a7 100644 --- a/pkg/cmd/generate/generator/generator.go +++ b/pkg/cmd/generate/generator/generator.go @@ -28,7 +28,7 @@ import ( v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" internalv1 "kusionstack.io/kusion/pkg/apis/internal.kusion.io/v1" - "kusionstack.io/kusion/pkg/cmd/build/builders" + "kusionstack.io/kusion/pkg/cmd/generate/builders" "kusionstack.io/kusion/pkg/cmd/generate/run" "kusionstack.io/kusion/pkg/util/io" "kusionstack.io/kusion/pkg/util/kfile"