From ba208dd103a638795b03b23e3f1c3364db39fbfa Mon Sep 17 00:00:00 2001 From: adohe Date: Sat, 13 Apr 2024 22:46:57 +0800 Subject: [PATCH] refactor: preview&apply use the same cmd pattern --- pkg/cmd/apply/apply.go | 470 ++++++++++++++++++++++++++++++-- pkg/cmd/apply/apply_test.go | 329 +++++++++++++++++++++- pkg/cmd/apply/options.go | 396 --------------------------- pkg/cmd/apply/options_test.go | 313 --------------------- pkg/cmd/cmd.go | 4 +- pkg/cmd/meta/meta.go | 20 +- pkg/cmd/preview/options.go | 266 ------------------ pkg/cmd/preview/options_test.go | 393 -------------------------- pkg/cmd/preview/preview.go | 296 +++++++++++++++++--- pkg/cmd/preview/preview_test.go | 232 +++++++++++++++- 10 files changed, 1271 insertions(+), 1448 deletions(-) delete mode 100644 pkg/cmd/apply/options.go delete mode 100644 pkg/cmd/apply/options_test.go delete mode 100644 pkg/cmd/preview/options.go delete mode 100644 pkg/cmd/preview/options_test.go diff --git a/pkg/cmd/apply/apply.go b/pkg/cmd/apply/apply.go index 23bd6556..1fe080ab 100644 --- a/pkg/cmd/apply/apply.go +++ b/pkg/cmd/apply/apply.go @@ -1,68 +1,478 @@ +// 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 apply import ( + "fmt" + "io" + "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/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/generate" + "kusionstack.io/kusion/pkg/cmd/preview" + 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/state" + "kusionstack.io/kusion/pkg/log" "kusionstack.io/kusion/pkg/util/i18n" + "kusionstack.io/kusion/pkg/util/pretty" ) -func NewCmdApply() *cobra.Command { - var ( - applyShort = i18n.T(`Apply the operational intent of various resources to multiple runtimes`) - - applyLong = i18n.T(` +var ( + applyLong = i18n.T(` Apply a series of resource changes within the stack. Create, update or delete resources according to the operational intent within a stack. By default, Kusion will generate an execution preview and prompt for your approval before performing any actions. You can review the preview details and make a decision to proceed with the actions or abort them.`) - applyExample = i18n.T(` + applyExample = i18n.T(` # Apply with specified work directory kusion apply -w /path/to/workdir # Apply with specified arguments kusion apply -D name=test -D age=18 - - # Apply with specified intent file - kusion apply --intent-file intent.yaml - - # Apply with specifying intent file - kusion apply --intent-file intent.yaml # Skip interactive approval of preview details before applying kusion apply --yes # Apply without output style and color kusion apply --no-style=true`) - ) +) + +// ApplyFlags directly reflect the information that CLI is gathering via flags. They will be converted to +// ApplyOptions, 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 ApplyFlags struct { + *preview.PreviewFlags + + Yes bool + DryRun bool + Watch bool + + genericiooptions.IOStreams +} + +// ApplyOptions defines flags and other configuration parameters for the `apply` command. +type ApplyOptions struct { + *preview.PreviewOptions + + Yes bool + DryRun bool + Watch bool + + genericiooptions.IOStreams +} + +// NewApplyFlags returns a default ApplyFlags +func NewApplyFlags(streams genericiooptions.IOStreams) *ApplyFlags { + return &ApplyFlags{ + PreviewFlags: preview.NewPreviewFlags(streams), + IOStreams: streams, + } +} + +// NewCmdApply creates the `apply` command. +func NewCmdApply(ioStreams genericiooptions.IOStreams) *cobra.Command { + flags := NewApplyFlags(ioStreams) - o := NewApplyOptions() cmd := &cobra.Command{ Use: "apply", - Short: applyShort, + Short: "Apply the operational intent of various resources to multiple runtimes", Long: templates.LongDesc(applyLong), Example: templates.Examples(applyExample), - 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) - o.AddPreviewFlags(cmd) - - cmd.Flags().BoolVarP(&o.Yes, "yes", "y", false, - i18n.T("Automatically approve and perform the update after previewing it")) - cmd.Flags().BoolVarP(&o.DryRun, "dry-run", "", false, - i18n.T("Preview the execution effect (always successful) without actually applying the changes")) - cmd.Flags().BoolVarP(&o.Watch, "watch", "", false, - i18n.T("After creating/updating/deleting the requested object, watch for changes")) + flags.AddFlags(cmd) return cmd } + +// AddFlags registers flags for a cli. +func (f *ApplyFlags) AddFlags(cmd *cobra.Command) { + // bind flag structs + f.PreviewFlags.AddFlags(cmd) + + cmd.Flags().BoolVarP(&f.Yes, "yes", "y", false, i18n.T("Automatically approve and perform the update after previewing it")) + cmd.Flags().BoolVarP(&f.DryRun, "dry-run", "", false, i18n.T("Preview the execution effect (always successful) without actually applying the changes")) + cmd.Flags().BoolVarP(&f.Watch, "watch", "", false, i18n.T("After creating/updating/deleting the requested object, watch for changes")) +} + +// ToOptions converts from CLI inputs to runtime inputs. +func (f *ApplyFlags) ToOptions() (*ApplyOptions, error) { + // Convert preview options + previewOptions, err := f.PreviewFlags.ToOptions() + if err != nil { + return nil, err + } + + o := &ApplyOptions{ + PreviewOptions: previewOptions, + Yes: f.Yes, + DryRun: f.DryRun, + Watch: f.Watch, + IOStreams: f.IOStreams, + } + + return o, nil +} + +// Validate verifies if ApplyOptions are valid and without conflicts. +func (o *ApplyOptions) Validate(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return cmdutil.UsageErrorf(cmd, "Unexpected args: %v", args) + } + + return nil +} + +// Run executes the `apply` command. +func (o *ApplyOptions) Run() error { + // set no style + if o.NoStyle { + pterm.DisableStyling() + pterm.DisableColor() + } + + // Generate Spec + spec, err := generate.GenerateSpecWithSpinner(o.RefProject, o.RefStack, o.RefWorkspace, o.NoStyle) + if err != nil { + return err + } + + // return immediately if no resource found in stack + if spec == nil || len(spec.Resources) == 0 { + fmt.Println(pretty.GreenBold("\nNo resource found in this stack.")) + return nil + } + + // compute changes for preview + storage := o.StorageBackend.StateStorage(o.RefProject.Name, o.RefStack.Name, o.RefWorkspace.Name) + changes, err := preview.Preview(o.PreviewOptions, storage, spec, o.RefProject, o.RefStack) + if err != nil { + return err + } + + if allUnChange(changes) { + fmt.Println("All resources are reconciled. No diff found") + return nil + } + + // summary preview table + changes.Summary(o.IOStreams.Out) + + // detail detection + if o.Detail && o.All { + changes.OutputDiff("all") + if !o.Yes { + return nil + } + } + + // prompt + if !o.Yes { + for { + input, err := prompt() + if err != nil { + return err + } + if input == "yes" { + break + } else if input == "details" { + target, err := changes.PromptDetails() + if err != nil { + return err + } + changes.OutputDiff(target) + } else { + fmt.Println("Operation apply canceled") + return nil + } + } + } + + fmt.Println("Start applying diffs ...") + if err = Apply(o, storage, spec, changes, o.IOStreams.Out); err != nil { + return err + } + + // if dry run, print the hint + if o.DryRun { + fmt.Printf("\nNOTE: Currently running in the --dry-run mode, the above configuration does not really take effect\n") + return nil + } + + if o.Watch { + fmt.Println("\nStart watching changes ...") + if err = Watch(o, spec, changes); err != nil { + return err + } + } + + return nil +} + +// The Apply function will apply the resources changes +// through the execution Kusion Engine, and will save +// the state to specified storage. +// +// You can customize the runtime of engine and the state +// storage through `runtime` and `storage` parameters. +// +// Example: +// +// o := NewApplyOptions() +// stateStorage := &states.FileSystemState{ +// Path: filepath.Join(o.WorkDir, states.KusionState) +// } +// kubernetesRuntime, err := runtime.NewKubernetesRuntime() +// if err != nil { +// return err +// } +// +// err = Apply(o, kubernetesRuntime, stateStorage, planResources, changes, os.Stdout) +// if err != nil { +// return err +// } +func Apply( + o *ApplyOptions, + storage state.Storage, + planResources *apiv1.Spec, + changes *models.Changes, + out io.Writer, +) error { + // construct the apply operation + ac := &operation.ApplyOperation{ + Operation: models.Operation{ + Stack: changes.Stack(), + StateStorage: storage, + MsgCh: make(chan models.Message), + IgnoreFields: o.IgnoreFields, + }, + } + + // line summary + var ls lineSummary + + // progress bar, print dag walk detail + progressbar, err := pterm.DefaultProgressbar. + WithMaxWidth(0). // Set to 0, the terminal width will be used + WithTotal(len(changes.StepKeys)). + WithWriter(out). + 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 := <-ac.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.WithWriter(out).Printfln(title) + progressbar.UpdateTitle(title) + progressbar.Increment() + ls.Count(changeStep.Action) + case models.Failed: + title := fmt.Sprintf("%s %s %s", + changeStep.Action.String(), + pterm.Bold.Sprint(changeStep.ID), + strings.ToLower(string(msg.OpResult)), + ) + pretty.ErrorT.WithWriter(out).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) + } + } + } + }() + + if o.DryRun { + for _, r := range planResources.Resources { + ac.MsgCh <- models.Message{ + ResourceID: r.ResourceKey(), + OpResult: models.Success, + OpErr: nil, + } + } + close(ac.MsgCh) + } else { + // parse cluster in arguments + _, st := ac.Apply(&operation.ApplyRequest{ + Request: models.Request{ + Project: changes.Project(), + Stack: changes.Stack(), + Operator: o.Operator, + Intent: planResources, + }, + }) + if v1.IsErr(st) { + return fmt.Errorf("apply failed, status:\n%v", st) + } + } + + // wait for msgCh closed + wg.Wait() + // print summary + pterm.Println() + pterm.Fprintln(out, fmt.Sprintf("Apply complete! Resources: %d created, %d updated, %d deleted.", ls.created, ls.updated, ls.deleted)) + return nil +} + +// Watch function will observe the changes of each resource +// by the execution engine. +// +// Example: +// +// o := NewApplyOptions() +// kubernetesRuntime, err := runtime.NewKubernetesRuntime() +// if err != nil { +// return err +// } +// +// Watch(o, kubernetesRuntime, planResources, changes, os.Stdout) +// if err != nil { +// return err +// } +func Watch( + o *ApplyOptions, + planResources *apiv1.Spec, + changes *models.Changes, +) error { + if o.DryRun { + fmt.Println("NOTE: Watch doesn't work in DryRun mode") + return nil + } + + // filter out unchanged resources + toBeWatched := apiv1.Resources{} + for _, res := range planResources.Resources { + if changes.ChangeOrder.ChangeSteps[res.ResourceKey()].Action != models.UnChanged { + toBeWatched = append(toBeWatched, res) + } + } + + // watch operation + wo := &operation.WatchOperation{} + if err := wo.Watch(&operation.WatchRequest{ + Request: models.Request{ + Project: changes.Project(), + Stack: changes.Stack(), + Intent: &apiv1.Spec{Resources: toBeWatched}, + }, + }); err != nil { + return err + } + + fmt.Println("Watch Finish! All resources have been reconciled.") + return nil +} + +type lineSummary struct { + created, updated, deleted int +} + +func (ls *lineSummary) Count(op models.ActionType) { + switch op { + case models.Create: + ls.created++ + case models.Update: + ls.updated++ + case models.Delete: + ls.deleted++ + } +} + +func allUnChange(changes *models.Changes) bool { + for _, v := range changes.ChangeSteps { + if v.Action != models.UnChanged { + return false + } + } + + return true +} + +func prompt() (string, error) { + // don`t display yes item when only preview + options := []string{"yes", "details", "no"} + + p := &survey.Select{ + Message: `Do you want to apply these diffs?`, + Options: options, + 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/apply/apply_test.go b/pkg/cmd/apply/apply_test.go index c1d6894a..7cdd084c 100644 --- a/pkg/cmd/apply/apply_test.go +++ b/pkg/cmd/apply/apply_test.go @@ -1,15 +1,336 @@ +// 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 apply 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/storages" + "kusionstack.io/kusion/pkg/cmd/generate" + "kusionstack.io/kusion/pkg/cmd/meta" + "kusionstack.io/kusion/pkg/cmd/preview" + "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" + workspacestorages "kusionstack.io/kusion/pkg/workspace/storages" +) + +var ( + proj = &apiv1.Project{ + Name: "testdata", + } + stack = &apiv1.Stack{ + Name: "dev", + } + workspace = &apiv1.Workspace{ + Name: "default", + } +) + +func NewApplyOptions() *ApplyOptions { + storageBackend := storages.NewLocalStorage(&internalv1.BackendLocalConfig{ + Path: filepath.Join("", "state.yaml"), + }) + return &ApplyOptions{ + PreviewOptions: &preview.PreviewOptions{ + MetaOptions: &meta.MetaOptions{ + RefProject: proj, + RefStack: stack, + RefWorkspace: workspace, + StorageBackend: storageBackend, + }, + Operator: "", + Detail: false, + All: false, + NoStyle: false, + Output: "", + IgnoreFields: nil, + }, + } +} + +func TestApplyOptionsRun(t *testing.T) { + /*mockey.PatchConvey("Detail is true", t, func() { + mockGenerateSpecWithSpinner() + mockPatchNewKubernetesRuntime() + mockPatchOperationPreview() + mockStateStorage() + + o := NewApplyOptions() + o.Detail = true + o.All = true + o.NoStyle = true + err := o.Run() + assert.Nil(t, err) + })*/ + + mockey.PatchConvey("DryRun is true", t, func() { + mockGenerateSpecWithSpinner() + mockPatchNewKubernetesRuntime() + mockPatchOperationPreview() + mockStateStorage() + mockOperationApply(models.Success) + + o := NewApplyOptions() + o.DryRun = true + mockPromptOutput("yes") + err := o.Run() + assert.Nil(t, err) + }) +} + +func mockGenerateSpecWithSpinner() { + mockey.Mock(generate.GenerateSpecWithSpinner).To(func( + project *apiv1.Project, + stack *apiv1.Stack, + workspace *apiv1.Workspace, + noStyle bool, + ) (*apiv1.Spec, error) { + return &apiv1.Spec{Resources: []apiv1.Resource{sa1, sa2, sa3}}, nil + }).Build() +} + +func mockPatchNewKubernetesRuntime() *mockey.Mocker { + return 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 mockPatchOperationPreview() *mockey.Mocker { + return 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, sa2.ID, sa3.ID}, + ChangeSteps: map[string]*models.ChangeStep{ + sa1.ID: { + ID: sa1.ID, + Action: models.Create, + From: &sa1, + }, + sa2.ID: { + ID: sa2.ID, + Action: models.UnChanged, + From: &sa2, + }, + sa3.ID: { + ID: sa3.ID, + Action: models.Undefined, + From: &sa1, + }, + }, + }, + }, nil + }).Build() +} + +func mockStateStorage() { + mockey.Mock((*storages.LocalStorage).WorkspaceStorage).Return(&workspacestorages.LocalStorage{}, nil).Build() +} + +const ( + apiVersion = "v1" + kind = "ServiceAccount" + namespace = "test-ns" +) + +var ( + sa1 = newSA("sa1") + sa2 = newSA("sa2") + sa3 = newSA("sa3") ) -func TestApplyCommandRun(t *testing.T) { - t.Run("validate error", func(t *testing.T) { - cmd := NewCmdApply() - 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 TestApply(t *testing.T) { + stateStorage := statestorages.NewLocalStorage(filepath.Join("", "state.yaml")) + mockey.PatchConvey("dry run", t, func() { + 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.Create, + From: sa1, + }, + }, + } + changes := models.NewChanges(proj, stack, order) + o := NewApplyOptions() + o.DryRun = true + err := Apply(o, stateStorage, planResources, changes, os.Stdout) + assert.Nil(t, err) + }) + mockey.PatchConvey("apply success", t, func() { + mockOperationApply(models.Success) + o := NewApplyOptions() + planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa1, sa2}} + order := &models.ChangeOrder{ + StepKeys: []string{sa1.ID, sa2.ID}, + ChangeSteps: map[string]*models.ChangeStep{ + sa1.ID: { + ID: sa1.ID, + Action: models.Create, + From: &sa1, + }, + sa2.ID: { + ID: sa2.ID, + Action: models.UnChanged, + From: &sa2, + }, + }, + } + changes := models.NewChanges(proj, stack, order) + + err := Apply(o, stateStorage, planResources, changes, os.Stdout) + assert.Nil(t, err) + }) + mockey.PatchConvey("apply failed", t, func() { + mockOperationApply(models.Failed) + + o := NewApplyOptions() + 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.Create, + From: &sa1, + }, + }, + } + changes := models.NewChanges(proj, stack, order) + + err := Apply(o, stateStorage, planResources, changes, os.Stdout) assert.NotNil(t, err) }) } + +func mockOperationApply(res models.OpResult) { + mockey.Mock((*operation.ApplyOperation).Apply).To( + func(o *operation.ApplyOperation, request *operation.ApplyRequest) (*operation.ApplyResponse, 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 nil, v1.NewErrorStatus(err) + } + return &operation.ApplyResponse{}, nil + }).Build() +} + +func TestPrompt(t *testing.T) { + mockey.PatchConvey("prompt error", t, func() { + mockey.Mock(survey.AskOne).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/apply/options.go b/pkg/cmd/apply/options.go deleted file mode 100644 index b56d2bfa..00000000 --- a/pkg/cmd/apply/options.go +++ /dev/null @@ -1,396 +0,0 @@ -package apply - -import ( - "fmt" - "io" - "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/generate" - "kusionstack.io/kusion/pkg/cmd/preview" - "kusionstack.io/kusion/pkg/engine/operation" - "kusionstack.io/kusion/pkg/engine/operation/models" - "kusionstack.io/kusion/pkg/engine/state" - "kusionstack.io/kusion/pkg/log" - "kusionstack.io/kusion/pkg/project" - "kusionstack.io/kusion/pkg/util/pretty" -) - -// Options defines flags for the `apply` command -type Options struct { - preview.Options - Flag -} - -type Flag struct { - Yes bool - DryRun bool - Watch bool -} - -// NewApplyOptions returns a new ApplyOptions instance -func NewApplyOptions() *Options { - return &Options{ - Options: *preview.NewPreviewOptions(), - } -} - -func (o *Options) Complete(args []string) { - o.Options.Complete(args) -} - -func (o *Options) Validate() error { - return o.Options.Validate() -} - -func (o *Options) Run() error { - // set no style - if o.NoStyle { - pterm.DisableStyling() - pterm.DisableColor() - } - - // parse project and stack of work directory - currentProject, currentStack, err := project.DetectProjectAndStackFrom(o.Options.WorkDir) - if err != nil { - return err - } - - // get workspace configurations - bk, err := backend.NewBackend(o.Backend) - if err != nil { - return err - } - wsStorage, err := bk.WorkspaceStorage() - if err != nil { - return err - } - currentWorkspace, err := wsStorage.Get(o.Workspace) - if err != nil { - return err - } - - // Generate Spec - var spec *apiv1.Spec - if len(o.IntentFile) != 0 { - spec, err = generate.SpecFromFile(o.IntentFile) - } else { - spec, err = generate.GenerateSpecWithSpinner(currentProject, currentStack, currentWorkspace, true) - } - if err != nil { - return err - } - - // return immediately if no resource found in stack - if spec == nil || len(spec.Resources) == 0 { - fmt.Println(pretty.GreenBold("\nNo resource found in this stack.")) - return nil - } - - // compute changes for preview - storage := bk.StateStorage(currentProject.Name, currentStack.Name, currentWorkspace.Name) - changes, err := preview.Preview(&o.Options, storage, spec, currentProject, currentStack) - if err != nil { - return err - } - - if allUnChange(changes) { - fmt.Println("All resources are reconciled. No diff found") - return nil - } - - // summary preview table - changes.Summary(os.Stdout) - - // detail detection - if o.Detail && o.All { - changes.OutputDiff("all") - if !o.Yes { - return nil - } - } - - // prompt - if !o.Yes { - for { - input, err := prompt() - if err != nil { - return err - } - if input == "yes" { - break - } else if input == "details" { - target, err := changes.PromptDetails() - if err != nil { - return err - } - changes.OutputDiff(target) - } else { - fmt.Println("Operation apply canceled") - return nil - } - } - } - - fmt.Println("Start applying diffs ...") - if err = Apply(o, storage, spec, changes, os.Stdout); err != nil { - return err - } - - // if dry run, print the hint - if o.DryRun { - fmt.Printf("\nNOTE: Currently running in the --dry-run mode, the above configuration does not really take effect\n") - return nil - } - - if o.Watch { - fmt.Println("\nStart watching changes ...") - if err = Watch(o, spec, changes); err != nil { - return err - } - } - - return nil -} - -// The Apply function will apply the resources changes -// through the execution Kusion Engine, and will save -// the state to specified storage. -// -// You can customize the runtime of engine and the state -// storage through `runtime` and `storage` parameters. -// -// Example: -// -// o := NewApplyOptions() -// stateStorage := &states.FileSystemState{ -// Path: filepath.Join(o.WorkDir, states.KusionState) -// } -// kubernetesRuntime, err := runtime.NewKubernetesRuntime() -// if err != nil { -// return err -// } -// -// err = Apply(o, kubernetesRuntime, stateStorage, planResources, changes, os.Stdout) -// if err != nil { -// return err -// } -func Apply( - o *Options, - storage state.Storage, - planResources *apiv1.Spec, - changes *models.Changes, - out io.Writer, -) error { - // construct the apply operation - ac := &operation.ApplyOperation{ - Operation: models.Operation{ - Stack: changes.Stack(), - StateStorage: storage, - MsgCh: make(chan models.Message), - IgnoreFields: o.IgnoreFields, - }, - } - - // line summary - var ls lineSummary - - // progress bar, print dag walk detail - progressbar, err := pterm.DefaultProgressbar. - WithMaxWidth(0). // Set to 0, the terminal width will be used - WithTotal(len(changes.StepKeys)). - WithWriter(out). - 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 := <-ac.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.WithWriter(out).Printfln(title) - progressbar.UpdateTitle(title) - progressbar.Increment() - ls.Count(changeStep.Action) - case models.Failed: - title := fmt.Sprintf("%s %s %s", - changeStep.Action.String(), - pterm.Bold.Sprint(changeStep.ID), - strings.ToLower(string(msg.OpResult)), - ) - pretty.ErrorT.WithWriter(out).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) - } - } - } - }() - - if o.DryRun { - for _, r := range planResources.Resources { - ac.MsgCh <- models.Message{ - ResourceID: r.ResourceKey(), - OpResult: models.Success, - OpErr: nil, - } - } - close(ac.MsgCh) - } else { - // parse cluster in arguments - _, st := ac.Apply(&operation.ApplyRequest{ - Request: models.Request{ - Project: changes.Project(), - Stack: changes.Stack(), - Operator: o.Operator, - Intent: planResources, - }, - }) - if v1.IsErr(st) { - return fmt.Errorf("apply failed, status:\n%v", st) - } - } - - // wait for msgCh closed - wg.Wait() - // print summary - pterm.Println() - pterm.Fprintln(out, fmt.Sprintf("Apply complete! Resources: %d created, %d updated, %d deleted.", ls.created, ls.updated, ls.deleted)) - return nil -} - -// Watch function will observe the changes of each resource -// by the execution engine. -// -// Example: -// -// o := NewApplyOptions() -// kubernetesRuntime, err := runtime.NewKubernetesRuntime() -// if err != nil { -// return err -// } -// -// Watch(o, kubernetesRuntime, planResources, changes, os.Stdout) -// if err != nil { -// return err -// } -func Watch( - o *Options, - planResources *apiv1.Spec, - changes *models.Changes, -) error { - if o.DryRun { - fmt.Println("NOTE: Watch doesn't work in DryRun mode") - return nil - } - - // filter out unchanged resources - toBeWatched := apiv1.Resources{} - for _, res := range planResources.Resources { - if changes.ChangeOrder.ChangeSteps[res.ResourceKey()].Action != models.UnChanged { - toBeWatched = append(toBeWatched, res) - } - } - - // watch operation - wo := &operation.WatchOperation{} - if err := wo.Watch(&operation.WatchRequest{ - Request: models.Request{ - Project: changes.Project(), - Stack: changes.Stack(), - Intent: &apiv1.Spec{Resources: toBeWatched}, - }, - }); err != nil { - return err - } - - fmt.Println("Watch Finish! All resources have been reconciled.") - return nil -} - -type lineSummary struct { - created, updated, deleted int -} - -func (ls *lineSummary) Count(op models.ActionType) { - switch op { - case models.Create: - ls.created++ - case models.Update: - ls.updated++ - case models.Delete: - ls.deleted++ - } -} - -func allUnChange(changes *models.Changes) bool { - for _, v := range changes.ChangeSteps { - if v.Action != models.UnChanged { - return false - } - } - - return true -} - -func prompt() (string, error) { - // don`t display yes item when only preview - options := []string{"yes", "details", "no"} - - p := &survey.Select{ - Message: `Do you want to apply these diffs?`, - Options: options, - 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/apply/options_test.go b/pkg/cmd/apply/options_test.go deleted file mode 100644 index 86cdc82d..00000000 --- a/pkg/cmd/apply/options_test.go +++ /dev/null @@ -1,313 +0,0 @@ -package apply - -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" - v1 "kusionstack.io/kusion/pkg/apis/status/v1" - "kusionstack.io/kusion/pkg/backend" - "kusionstack.io/kusion/pkg/backend/storages" - "kusionstack.io/kusion/pkg/cmd/generate" - "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 TestApplyOptions_Run(t *testing.T) { - mockey.PatchConvey("Detail is true", t, func() { - mockPatchDetectProjectAndStack() - mockGenerateSpecWithSpinner() - mockPatchNewKubernetesRuntime() - mockNewBackend() - mockWorkspaceStorage() - mockPatchOperationPreview() - - o := NewApplyOptions() - o.Detail = true - o.All = true - o.NoStyle = true - err := o.Run() - assert.Nil(t, err) - }) - - mockey.PatchConvey("DryRun is true", t, func() { - mockPatchDetectProjectAndStack() - mockGenerateSpecWithSpinner() - mockPatchNewKubernetesRuntime() - mockNewBackend() - mockWorkspaceStorage() - mockPatchOperationPreview() - mockOperationApply(models.Success) - - o := NewApplyOptions() - o.DryRun = true - mockPromptOutput("yes") - err := o.Run() - assert.Nil(t, err) - }) -} - -var ( - proj = &apiv1.Project{ - Name: "testdata", - } - stack = &apiv1.Stack{ - Name: "dev", - } -) - -func mockPatchDetectProjectAndStack() *mockey.Mocker { - return 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 mockGenerateSpecWithSpinner() { - mockey.Mock(generate.GenerateSpecWithSpinner).To(func( - project *apiv1.Project, - stack *apiv1.Stack, - workspace *apiv1.Workspace, - noStyle bool, - ) (*apiv1.Spec, error) { - return &apiv1.Spec{Resources: []apiv1.Resource{sa1, sa2, sa3}}, nil - }).Build() -} - -func mockPatchNewKubernetesRuntime() *mockey.Mocker { - return mockey.Mock(kubernetes.NewKubernetesRuntime).To(func() (runtime.Runtime, error) { - return &fakerRuntime{}, nil - }).Build() -} - -func mockNewBackend() *mockey.Mocker { - return 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).Get).Return(&apiv1.Workspace{}, 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 mockPatchOperationPreview() *mockey.Mocker { - return 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, sa2.ID, sa3.ID}, - ChangeSteps: map[string]*models.ChangeStep{ - sa1.ID: { - ID: sa1.ID, - Action: models.Create, - From: &sa1, - }, - sa2.ID: { - ID: sa2.ID, - Action: models.UnChanged, - From: &sa2, - }, - sa3.ID: { - ID: sa3.ID, - Action: models.Undefined, - From: &sa1, - }, - }, - }, - }, nil - }).Build() -} - -const ( - apiVersion = "v1" - kind = "ServiceAccount" - namespace = "test-ns" -) - -var ( - sa1 = newSA("sa1") - sa2 = newSA("sa2") - sa3 = newSA("sa3") -) - -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_apply(t *testing.T) { - stateStorage := statestorages.NewLocalStorage(filepath.Join("", "state.yaml")) - mockey.PatchConvey("dry run", t, func() { - 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.Create, - From: sa1, - }, - }, - } - changes := models.NewChanges(proj, stack, order) - o := NewApplyOptions() - o.DryRun = true - err := Apply(o, stateStorage, planResources, changes, os.Stdout) - assert.Nil(t, err) - }) - mockey.PatchConvey("apply success", t, func() { - mockOperationApply(models.Success) - o := NewApplyOptions() - planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa1, sa2}} - order := &models.ChangeOrder{ - StepKeys: []string{sa1.ID, sa2.ID}, - ChangeSteps: map[string]*models.ChangeStep{ - sa1.ID: { - ID: sa1.ID, - Action: models.Create, - From: &sa1, - }, - sa2.ID: { - ID: sa2.ID, - Action: models.UnChanged, - From: &sa2, - }, - }, - } - changes := models.NewChanges(proj, stack, order) - - err := Apply(o, stateStorage, planResources, changes, os.Stdout) - assert.Nil(t, err) - }) - mockey.PatchConvey("apply failed", t, func() { - mockOperationApply(models.Failed) - - o := NewApplyOptions() - 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.Create, - From: &sa1, - }, - }, - } - changes := models.NewChanges(proj, stack, order) - - err := Apply(o, stateStorage, planResources, changes, os.Stdout) - assert.NotNil(t, err) - }) -} - -func mockOperationApply(res models.OpResult) { - mockey.Mock((*operation.ApplyOperation).Apply).To( - func(o *operation.ApplyOperation, request *operation.ApplyRequest) (*operation.ApplyResponse, 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 nil, v1.NewErrorStatus(err) - } - return &operation.ApplyResponse{}, nil - }).Build() -} - -func Test_prompt(t *testing.T) { - mockey.PatchConvey("prompt error", t, func() { - mockey.Mock(survey.AskOne).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/cmd.go b/pkg/cmd/cmd.go index b487c917..ab775cb3 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -100,8 +100,8 @@ func NewKusionctlCmd(o KusionctlOptions) *cobra.Command { { Message: "Runtime Commands:", Commands: []*cobra.Command{ - preview.NewCmdPreview(), - apply.NewCmdApply(), + preview.NewCmdPreview(o.IOStreams), + apply.NewCmdApply(o.IOStreams), destroy.NewCmdDestroy(), }, }, diff --git a/pkg/cmd/meta/meta.go b/pkg/cmd/meta/meta.go index fc6002b9..dc1113c6 100644 --- a/pkg/cmd/meta/meta.go +++ b/pkg/cmd/meta/meta.go @@ -39,11 +39,14 @@ type MetaOptions struct { // RefProject references the project for this CLI invocation. RefProject *v1.Project - // RefStack referenced the stack for this CLI invocation + // RefStack referenced the stack for this CLI invocation. RefStack *v1.Stack - // RefWorkspace referenced the workspace for this CLI invocation + // RefWorkspace referenced the workspace for this CLI invocation. RefWorkspace *v1.Workspace + + // StorageBackend referenced the target storage backend for this CLI invocation. + StorageBackend backend.Backend } // NewMetaFlags provides default flags and values for use in other commands. @@ -82,9 +85,18 @@ func (f *MetaFlags) ToOptions() (*MetaOptions, error) { opts.RefProject = refProject opts.RefStack = refStack + var storageBackend backend.Backend + if f.Backend != nil { + storageBackend, err = backend.NewBackend(*f.Backend) + opts.StorageBackend = storageBackend + } + if err != nil { + return nil, err + } + // Get current workspace from backend - if f.Backend != nil && f.Workspace != nil { - workspaceStorage, err := backend.NewWorkspaceStorage(*f.Backend) + if f.Workspace != nil && storageBackend != nil { + workspaceStorage, err := storageBackend.WorkspaceStorage() if err != nil { return nil, err } diff --git a/pkg/cmd/preview/options.go b/pkg/cmd/preview/options.go deleted file mode 100644 index 85321c20..00000000 --- a/pkg/cmd/preview/options.go +++ /dev/null @@ -1,266 +0,0 @@ -package preview - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/pkg/errors" - "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/cmd/generate" - "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" -) - -const jsonOutput = "json" - -type Options struct { - build.Options - Flags -} - -type Flags struct { - Operator string - Detail bool - All bool - NoStyle bool - Output string - IntentFile string - IgnoreFields []string -} - -func NewPreviewOptions() *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 - } - if o.Output != "" && o.Output != jsonOutput { - return errors.New("invalid output type, supported types: json") - } - if err := o.ValidateIntentFile(); err != nil { - return err - } - return nil -} - -func (o *Options) ValidateIntentFile() error { - if o.IntentFile == "" { - return nil - } - - // calculate the absolute path of the intentFile - var absSF string - if o.WorkDir == "" { - absSF, _ = filepath.Abs(o.IntentFile) - } else if filepath.IsAbs(o.IntentFile) { - absSF = o.IntentFile - } else { - absSF = filepath.Join(o.WorkDir, o.IntentFile) - } - - fi, err := os.Stat(absSF) - if err != nil { - if os.IsNotExist(err) { - err = fmt.Errorf("intent file not exist") - } - return err - } - - if fi.IsDir() || !fi.Mode().IsRegular() { - return fmt.Errorf("intent file must be a regular file") - } - - // calculate the relative path between absWD and absSF, - // if absSF is not located in the directory or subdirectory specified by absWD, - // an error will be returned - absWD, _ := filepath.Abs(o.WorkDir) - rel, err := filepath.Rel(absWD, absSF) - if err != nil { - return err - } - if rel[:3] == ".."+string(filepath.Separator) { - return fmt.Errorf("the intent file must be located in the working directory or its subdirectories") - } - - // set the intent file to the absolute path for further processing - o.IntentFile = absSF - return nil -} - -func (o *Options) Run() error { - // set no style - if o.NoStyle || o.Output == jsonOutput { - pterm.DisableStyling() - pterm.DisableColor() - } - - // Parse project and currentStack of work directory - currentProject, currentStack, err := project.DetectProjectAndStackFrom(o.WorkDir) - if err != nil { - return err - } - - // Get current workspace from backend - bk, err := backend.NewBackend(o.Backend) - if err != nil { - return err - } - wsStorage, err := bk.WorkspaceStorage() - if err != nil { - return err - } - currentWorkspace, err := wsStorage.Get(o.Workspace) - if err != nil { - return err - } - - // Generate Spec - var spec *apiv1.Spec - if len(o.IntentFile) != 0 { - spec, err = generate.SpecFromFile(o.IntentFile) - } else { - spec, err = generate.GenerateSpecWithSpinner(currentProject, currentStack, currentWorkspace, true) - } - if err != nil { - return err - } - - // return immediately if no resource found in stack - // todo: if there is no resource, should still do diff job; for now, if output is json format, there is no hint - if spec == nil || len(spec.Resources) == 0 { - if o.Output != jsonOutput { - fmt.Println(pretty.GreenBold("\nNo resource found in this stack.")) - } - return nil - } - - // compute changes for preview - storage := bk.StateStorage(currentProject.Name, currentStack.Name, currentWorkspace.Name) - changes, err := Preview(o, storage, spec, currentProject, currentStack) - if err != nil { - return err - } - - if o.Output == jsonOutput { - var previewChanges []byte - previewChanges, err = json.Marshal(changes) - if err != nil { - return fmt.Errorf("json marshal preview changes failed as %w", err) - } - fmt.Println(string(previewChanges)) - return nil - } - - if changes.AllUnChange() { - fmt.Println("All resources are reconciled. No diff found") - return nil - } - - // summary preview table - changes.Summary(os.Stdout) - - // detail detection - if o.Detail { - for { - var target string - target, err = changes.PromptDetails() - if err != nil { - return err - } - if target == "" { // Cancel option - break - } - changes.OutputDiff(target) - } - } - - return nil -} - -// The Preview function calculates the upcoming actions of each resource -// through the execution Kusion Engine, and you can customize the -// runtime of engine and the state storage through `runtime` and -// `storage` parameters. -// -// Example: -// -// o := NewPreviewOptions() -// stateStorage := &states.FileSystemState{ -// Path: filepath.Join(o.WorkDir, states.KusionState) -// } -// kubernetesRuntime, err := runtime.NewKubernetesRuntime() -// if err != nil { -// return err -// } -// -// changes, err := Preview(o, kubernetesRuntime, stateStorage, -// planResources, project, stack, os.Stdout) -// if err != nil { -// return err -// } -func Preview( - o *Options, - storage state.Storage, - planResources *apiv1.Spec, - proj *apiv1.Project, - stack *apiv1.Stack, -) (*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 - } - - // construct the preview operation - pc := &operation.PreviewOperation{ - Operation: models.Operation{ - OperationType: models.ApplyPreview, - Stack: stack, - StateStorage: storage, - IgnoreFields: o.IgnoreFields, - ChangeOrder: &models.ChangeOrder{StepKeys: []string{}, ChangeSteps: map[string]*models.ChangeStep{}}, - }, - } - - log.Info("Start call pc.Preview() ...") - - // parse cluster in arguments - 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.\n%s", s.String()) - } - - return models.NewChanges(proj, stack, rsp.Order), nil -} diff --git a/pkg/cmd/preview/options_test.go b/pkg/cmd/preview/options_test.go deleted file mode 100644 index a5cbb697..00000000 --- a/pkg/cmd/preview/options_test.go +++ /dev/null @@ -1,393 +0,0 @@ -package preview - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/bytedance/mockey" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - 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/cmd/build" - "kusionstack.io/kusion/pkg/cmd/generate" - "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 ( - apiVersion = "v1" - kind = "ServiceAccount" - namespace = "test-ns" - - proj = &apiv1.Project{ - Name: "testdata", - } - stack = &apiv1.Stack{ - Name: "dev", - } - - sa1 = newSA("sa1") - sa2 = newSA("sa2") - sa3 = newSA("sa3") -) - -func Test_preview(t *testing.T) { - stateStorage := statestorages.NewLocalStorage(filepath.Join("", "state.yaml")) - t.Run("preview success", func(t *testing.T) { - m := mockOperationPreview() - defer m.UnPatch() - - o := NewPreviewOptions() - _, err := Preview(o, stateStorage, &apiv1.Spec{Resources: []apiv1.Resource{sa1, sa2, sa3}}, proj, stack) - assert.Nil(t, err) - }) -} - -func TestPreviewOptions_Run(t *testing.T) { - t.Run("no project or stack", func(t *testing.T) { - o := NewPreviewOptions() - o.Detail = true - err := o.Run() - assert.NotNil(t, err) - }) - - t.Run("compile failed", func(t *testing.T) { - mockey.PatchConvey("mock engine operation", t, func() { - mockDetectProjectAndStack() - - o := NewPreviewOptions() - o.Detail = true - err := o.Run() - assert.NotNil(t, err) - }) - }) - - t.Run("no changes", func(t *testing.T) { - mockey.PatchConvey("mock engine operation", t, func() { - mockDetectProjectAndStack() - mockGenerateIntentWithSpinner() - mockNewKubernetesRuntime() - mockNewBackend() - mockWorkspaceStorage() - o := NewPreviewOptions() - o.Detail = true - err := o.Run() - assert.Nil(t, err) - }) - }) - - t.Run("detail is true", func(t *testing.T) { - mockey.PatchConvey("mock engine operation", t, func() { - mockDetectProjectAndStack() - mockGenerateIntentWithSpinner() - mockNewKubernetesRuntime() - mockOperationPreview() - mockPromptDetail("") - mockNewBackend() - mockWorkspaceStorage() - - o := NewPreviewOptions() - o.Detail = true - err := o.Run() - assert.Nil(t, err) - }) - }) - - t.Run("json output is true", func(t *testing.T) { - mockey.PatchConvey("mock engine operation", t, func() { - mockDetectProjectAndStack() - mockGenerateIntentWithSpinner() - mockNewKubernetesRuntime() - mockOperationPreview() - mockPromptDetail("") - mockNewBackend() - mockWorkspaceStorage() - - o := NewPreviewOptions() - o.Output = jsonOutput - err := o.Run() - assert.Nil(t, err) - }) - }) - - t.Run("no style is true", func(t *testing.T) { - mockey.PatchConvey("mock engine operation", t, func() { - mockDetectProjectAndStack() - mockGenerateIntentWithSpinner() - mockNewKubernetesRuntime() - mockOperationPreview() - mockPromptDetail("") - mockNewBackend() - mockWorkspaceStorage() - - o := NewPreviewOptions() - o.NoStyle = true - err := o.Run() - assert.Nil(t, err) - }) - }) -} - -type fooRuntime struct{} - -func (f *fooRuntime) Import(_ context.Context, request *runtime.ImportRequest) *runtime.ImportResponse { - return &runtime.ImportResponse{Resource: request.PlanResource} -} - -func (f *fooRuntime) Apply(_ context.Context, request *runtime.ApplyRequest) *runtime.ApplyResponse { - return &runtime.ApplyResponse{ - Resource: request.PlanResource, - Status: nil, - } -} - -func (f *fooRuntime) 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 *fooRuntime) Delete(_ context.Context, _ *runtime.DeleteRequest) *runtime.DeleteResponse { - return nil -} - -func (f *fooRuntime) Watch(_ context.Context, _ *runtime.WatchRequest) *runtime.WatchResponse { - return nil -} - -func mockOperationPreview() *mockey.Mocker { - return 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, sa2.ID, sa3.ID}, - ChangeSteps: map[string]*models.ChangeStep{ - sa1.ID: { - ID: sa1.ID, - Action: models.Create, - From: &sa1, - }, - sa2.ID: { - ID: sa2.ID, - Action: models.UnChanged, - From: &sa2, - }, - sa3.ID: { - ID: sa3.ID, - Action: models.Undefined, - From: &sa1, - }, - }, - }, - }, nil - }).Build() -} - -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 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 mockGenerateIntentWithSpinner() { - mockey.Mock(generate.GenerateSpecWithSpinner).To(func( - project *apiv1.Project, - stack *apiv1.Stack, - workspace *apiv1.Workspace, - noStyle bool, - ) (*apiv1.Spec, error) { - return &apiv1.Spec{Resources: []apiv1.Resource{sa1, sa2, sa3}}, nil - }).Build() -} - -func mockNewKubernetesRuntime() { - mockey.Mock(kubernetes.NewKubernetesRuntime).To(func() (runtime.Runtime, error) { - return &fooRuntime{}, nil - }).Build() -} - -func mockPromptDetail(input string) { - mockey.Mock((*models.ChangeOrder).PromptDetails).To(func(co *models.ChangeOrder) (string, error) { - return input, nil - }).Build() -} - -func mockNewBackend() { - 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).Get).Return(&apiv1.Workspace{}, nil).Build() -} - -func TestPreviewOptions_ValidateIntentFile(t *testing.T) { - currDir, _ := os.Getwd() - tests := []struct { - name string - intentFile string - workDir string - createIntentFile bool - wantErr bool - }{ - { - name: "test1", - intentFile: "kusion_intent.yaml", - workDir: "", - createIntentFile: true, - }, - { - name: "test2", - intentFile: filepath.Join(currDir, "kusion_intent.yaml"), - workDir: "", - createIntentFile: true, - }, - { - name: "test3", - intentFile: "kusion_intent.yaml", - workDir: "", - createIntentFile: false, - wantErr: true, - }, - { - name: "test4", - intentFile: "ci-test/stdout.golden.yaml", - workDir: "", - createIntentFile: true, - }, - { - name: "test5", - intentFile: "../kusion_intent.yaml", - workDir: "", - createIntentFile: true, - wantErr: true, - }, - { - name: "test6", - intentFile: filepath.Join(currDir, "../kusion_intent.yaml"), - workDir: "", - createIntentFile: true, - wantErr: true, - }, - { - name: "test7", - intentFile: "", - workDir: "", - wantErr: false, - }, - { - name: "test8", - intentFile: currDir, - workDir: "", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - o := Options{} - o.IntentFile = tt.intentFile - o.WorkDir = tt.workDir - if tt.createIntentFile { - dir := filepath.Dir(tt.intentFile) - if _, err := os.Stat(dir); os.IsNotExist(err) { - _ = os.MkdirAll(dir, 0o755) - defer func() { - _ = os.RemoveAll(dir) - }() - } - _, _ = os.Create(tt.intentFile) - defer func() { - _ = os.Remove(tt.intentFile) - }() - } - err := o.ValidateIntentFile() - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - }) - } -} - -func TestPreviewOptions_Validate(t *testing.T) { - m := mockey.Mock((*build.Options).Validate).Return(nil).Build() - defer m.UnPatch() - tests := []struct { - name string - output string - wantErr bool - }{ - { - name: "test1", - output: "json", - wantErr: false, - }, - { - name: "test2", - output: "yaml", - wantErr: true, - }, - { - name: "test3", - output: "", - wantErr: false, - }, - { - name: "test4", - output: "", - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - o := &Options{} - o.Output = tt.output - err := o.Validate() - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} diff --git a/pkg/cmd/preview/preview.go b/pkg/cmd/preview/preview.go index 2fab7a4d..79eb339e 100644 --- a/pkg/cmd/preview/preview.go +++ b/pkg/cmd/preview/preview.go @@ -1,33 +1,56 @@ +// 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 preview import ( + "encoding/json" + "fmt" + + "github.com/pterm/pterm" "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericiooptions" "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/generate" + "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/i18n" + "kusionstack.io/kusion/pkg/util/pretty" ) -func NewCmdPreview() *cobra.Command { - var ( - previewShort = i18n.T(`Preview a series of resource changes within the stack`) - - previewLong = i18n.T(` +var ( + previewLong = i18n.T(` Preview a series of resource changes within the stack. Create, update or delete resources according to the intent described in the stack. By default, Kusion will generate an execution preview and present it for your approval before taking any action.`) - previewExample = i18n.T(` + previewExample = i18n.T(` # Preview with specified work directory kusion preview -w /path/to/workdir # Preview with specified arguments kusion preview -D name=test -D age=18 - # Preview with specified intent file - kusion preview --intent-file intent.yaml - # Preview with ignored fields kusion preview --ignore-fields="metadata.generation,metadata.managedFields @@ -36,42 +59,245 @@ func NewCmdPreview() *cobra.Command { # Preview without output style and color kusion preview --no-style=true`) - ) +) + +const jsonOutput = "json" + +// PreviewFlags directly reflect the information that CLI is gathering via flags. They will be converted to +// PreviewOptions, 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 PreviewFlags struct { + MetaFlags *meta.MetaFlags + + Operator string + Detail bool + All bool + NoStyle bool + Output string + IgnoreFields []string + + genericiooptions.IOStreams +} + +// PreviewOptions defines flags and other configuration parameters for the `preview` command. +type PreviewOptions struct { + *meta.MetaOptions + + Operator string + Detail bool + All bool + NoStyle bool + Output string + IgnoreFields []string + + genericiooptions.IOStreams +} + +// NewPreviewFlags returns a default PreviewFlags +func NewPreviewFlags(streams genericiooptions.IOStreams) *PreviewFlags { + return &PreviewFlags{ + MetaFlags: meta.NewMetaFlags(), + IOStreams: streams, + } +} + +// NewCmdPreview creates the `preview` command. +func NewCmdPreview(ioStreams genericiooptions.IOStreams) *cobra.Command { + flags := NewPreviewFlags(ioStreams) - o := NewPreviewOptions() cmd := &cobra.Command{ Use: "preview", - Short: previewShort, + Short: "Preview a series of resource changes within the stack", Long: templates.LongDesc(previewLong), Example: templates.Examples(previewExample), - 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) - o.AddPreviewFlags(cmd) + flags.AddFlags(cmd) return cmd } -func (o *Options) AddPreviewFlags(cmd *cobra.Command) { - cmd.Flags().StringVarP(&o.Operator, "operator", "", "", - i18n.T("Specify the operator")) - cmd.Flags().BoolVarP(&o.Detail, "detail", "d", true, - i18n.T("Automatically show preview details with interactive options")) - cmd.Flags().BoolVarP(&o.All, "all", "a", false, - i18n.T("Automatically show all preview details, combined use with flag `--detail`")) - cmd.Flags().BoolVarP(&o.NoStyle, "no-style", "", false, - i18n.T("no-style sets to RawOutput mode and disables all of styling")) - cmd.Flags().StringSliceVarP(&o.IgnoreFields, "ignore-fields", "", nil, - i18n.T("Ignore differences of target fields")) - cmd.Flags().StringVarP(&o.Output, "output", "o", "", - i18n.T("Specify the output format")) - cmd.Flags().StringVarP(&o.IntentFile, "intent-file", "", "", - i18n.T("Specify the intent file path as input, and the intent file must be located in the working directory or its subdirectories")) +// AddFlags registers flags for a cli. +func (f *PreviewFlags) AddFlags(cmd *cobra.Command) { + // bind flag structs + f.MetaFlags.AddFlags(cmd) + + cmd.Flags().StringVarP(&f.Operator, "operator", "", f.Operator, i18n.T("Specify the operator")) + cmd.Flags().BoolVarP(&f.Detail, "detail", "d", true, i18n.T("Automatically show preview details with interactive options")) + cmd.Flags().BoolVarP(&f.All, "all", "a", false, i18n.T("Automatically show all preview details, combined use with flag `--detail`")) + cmd.Flags().BoolVarP(&f.NoStyle, "no-style", "", false, i18n.T("no-style sets to RawOutput mode and disables all of styling")) + cmd.Flags().StringSliceVarP(&f.IgnoreFields, "ignore-fields", "", f.IgnoreFields, i18n.T("Ignore differences of target fields")) + cmd.Flags().StringVarP(&f.Output, "output", "o", f.Output, i18n.T("Specify the output format")) +} + +// ToOptions converts from CLI inputs to runtime inputs. +func (f *PreviewFlags) ToOptions() (*PreviewOptions, error) { + // Convert meta options + metaOptions, err := f.MetaFlags.ToOptions() + if err != nil { + return nil, err + } + + o := &PreviewOptions{ + MetaOptions: metaOptions, + Operator: f.Operator, + Detail: f.Detail, + All: f.All, + NoStyle: f.NoStyle, + Output: f.Output, + IgnoreFields: f.IgnoreFields, + IOStreams: f.IOStreams, + } + + return o, nil +} + +// Validate verifies if PreviewOptions are valid and without conflicts. +func (o *PreviewOptions) Validate(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return cmdutil.UsageErrorf(cmd, "Unexpected args: %v", args) + } + + return nil +} + +// Run executes the `preview` command. +func (o *PreviewOptions) Run() error { + // set no style + if o.NoStyle || o.Output == jsonOutput { + pterm.DisableStyling() + pterm.DisableColor() + } + + // Generate spec + spec, err := generate.GenerateSpecWithSpinner(o.RefProject, o.RefStack, o.RefWorkspace, o.NoStyle) + if err != nil { + return err + } + + // return immediately if no resource found in stack + if spec == nil || len(spec.Resources) == 0 { + if o.Output != jsonOutput { + fmt.Println(pretty.GreenBold("\nNo resource found in this stack.")) + } + return nil + } + + // compute changes for preview + storage := o.StorageBackend.StateStorage(o.RefProject.Name, o.RefStack.Name, o.RefWorkspace.Name) + changes, err := Preview(o, storage, spec, o.RefProject, o.RefStack) + if err != nil { + return err + } + + if o.Output == jsonOutput { + var previewChanges []byte + previewChanges, err = json.Marshal(changes) + if err != nil { + return fmt.Errorf("json marshal preview changes failed as %w", err) + } + fmt.Println(string(previewChanges)) + return nil + } + + if changes.AllUnChange() { + fmt.Println("All resources are reconciled. No diff found") + return nil + } + + // summary preview table + changes.Summary(o.IOStreams.Out) + + // detail detection + if o.Detail { + for { + var target string + target, err = changes.PromptDetails() + if err != nil { + return err + } + if target == "" { // Cancel option + break + } + changes.OutputDiff(target) + } + } + return nil +} + +// The Preview function calculates the upcoming actions of each resource +// through the execution Kusion Engine, and you can customize the +// runtime of engine and the state storage through `runtime` and +// `storage` parameters. +// +// Example: +// +// o := NewPreviewOptions() +// stateStorage := &states.FileSystemState{ +// Path: filepath.Join(o.WorkDir, states.KusionState) +// } +// kubernetesRuntime, err := runtime.NewKubernetesRuntime() +// if err != nil { +// return err +// } +// +// changes, err := Preview(o, kubernetesRuntime, stateStorage, +// planResources, project, stack, os.Stdout) +// if err != nil { +// return err +// } +func Preview( + opts *PreviewOptions, + storage state.Storage, + planResources *apiv1.Spec, + project *apiv1.Project, + stack *apiv1.Stack, +) (*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 + } + + // construct the preview operation + pc := &operation.PreviewOperation{ + Operation: models.Operation{ + OperationType: models.ApplyPreview, + Stack: stack, + StateStorage: storage, + IgnoreFields: opts.IgnoreFields, + ChangeOrder: &models.ChangeOrder{StepKeys: []string{}, ChangeSteps: map[string]*models.ChangeStep{}}, + }, + } + + log.Info("Start call pc.Preview() ...") + + // parse cluster in arguments + rsp, s := pc.Preview(&operation.PreviewRequest{ + Request: models.Request{ + Project: project, + Stack: stack, + Operator: opts.Operator, + Intent: planResources, + }, + }) + if v1.IsErr(s) { + return nil, fmt.Errorf("preview failed.\n%s", s.String()) + } + + return models.NewChanges(project, stack, rsp.Order), nil } diff --git a/pkg/cmd/preview/preview_test.go b/pkg/cmd/preview/preview_test.go index 345dac4a..a3791e60 100644 --- a/pkg/cmd/preview/preview_test.go +++ b/pkg/cmd/preview/preview_test.go @@ -1,15 +1,237 @@ +// 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 preview import ( + "context" + "path/filepath" "testing" + "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/storages" + "kusionstack.io/kusion/pkg/cmd/generate" + "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" + workspacestorages "kusionstack.io/kusion/pkg/workspace/storages" +) + +var ( + apiVersion = "v1" + kind = "ServiceAccount" + namespace = "test-ns" + + proj = &apiv1.Project{ + Name: "testdata", + } + stack = &apiv1.Stack{ + Name: "dev", + } + workspace = &apiv1.Workspace{ + Name: "default", + } + + sa1 = newSA("sa1") + sa2 = newSA("sa2") + sa3 = newSA("sa3") ) -func TestNewCmdPreview(t *testing.T) { - t.Run("validate error", func(t *testing.T) { - cmd := NewCmdPreview() - err := cmd.Execute() - assert.NotNil(t, err) +func NewPreviewOptions() *PreviewOptions { + storageBackend := storages.NewLocalStorage(&internalv1.BackendLocalConfig{ + Path: filepath.Join("", "state.yaml"), + }) + return &PreviewOptions{ + MetaOptions: &meta.MetaOptions{ + RefProject: proj, + RefStack: stack, + RefWorkspace: workspace, + StorageBackend: storageBackend, + }, + } +} + +func TestPreview(t *testing.T) { + stateStorage := statestorages.NewLocalStorage(filepath.Join("", "state.yaml")) + t.Run("preview success", func(t *testing.T) { + m := mockOperationPreview() + defer m.UnPatch() + + o := &PreviewOptions{} + _, err := Preview(o, stateStorage, &apiv1.Spec{Resources: []apiv1.Resource{sa1, sa2, sa3}}, proj, stack) + assert.Nil(t, err) + }) +} + +func TestPreviewOptionsRun(t *testing.T) { + t.Run("detail is true", func(t *testing.T) { + mockey.PatchConvey("mock engine operation", t, func() { + mockGenerateSpecWithSpinner() + mockNewKubernetesRuntime() + mockOperationPreview() + mockPromptDetail("") + mockStateStorage() + + o := NewPreviewOptions() + o.Detail = true + err := o.Run() + assert.Nil(t, err) + }) + }) + + t.Run("json output is true", func(t *testing.T) { + mockey.PatchConvey("mock engine operation", t, func() { + mockGenerateSpecWithSpinner() + mockNewKubernetesRuntime() + mockOperationPreview() + mockPromptDetail("") + mockStateStorage() + + o := NewPreviewOptions() + o.Output = jsonOutput + err := o.Run() + assert.Nil(t, err) + }) }) + + t.Run("no style is true", func(t *testing.T) { + mockey.PatchConvey("mock engine operation", t, func() { + mockGenerateSpecWithSpinner() + mockNewKubernetesRuntime() + mockOperationPreview() + mockPromptDetail("") + mockStateStorage() + + o := NewPreviewOptions() + o.NoStyle = true + err := o.Run() + assert.Nil(t, err) + }) + }) +} + +type fooRuntime struct{} + +func (f *fooRuntime) Import(_ context.Context, request *runtime.ImportRequest) *runtime.ImportResponse { + return &runtime.ImportResponse{Resource: request.PlanResource} +} + +func (f *fooRuntime) Apply(_ context.Context, request *runtime.ApplyRequest) *runtime.ApplyResponse { + return &runtime.ApplyResponse{ + Resource: request.PlanResource, + Status: nil, + } +} + +func (f *fooRuntime) 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 *fooRuntime) Delete(_ context.Context, _ *runtime.DeleteRequest) *runtime.DeleteResponse { + return nil +} + +func (f *fooRuntime) Watch(_ context.Context, _ *runtime.WatchRequest) *runtime.WatchResponse { + return nil +} + +func mockOperationPreview() *mockey.Mocker { + return 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, sa2.ID, sa3.ID}, + ChangeSteps: map[string]*models.ChangeStep{ + sa1.ID: { + ID: sa1.ID, + Action: models.Create, + From: &sa1, + }, + sa2.ID: { + ID: sa2.ID, + Action: models.UnChanged, + From: &sa2, + }, + sa3.ID: { + ID: sa3.ID, + Action: models.Undefined, + From: &sa1, + }, + }, + }, + }, nil + }).Build() +} + +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 mockGenerateSpecWithSpinner() { + mockey.Mock(generate.GenerateSpecWithSpinner).To(func( + project *apiv1.Project, + stack *apiv1.Stack, + workspace *apiv1.Workspace, + noStyle bool, + ) (*apiv1.Spec, error) { + return &apiv1.Spec{Resources: []apiv1.Resource{sa1, sa2, sa3}}, nil + }).Build() +} + +func mockNewKubernetesRuntime() { + mockey.Mock(kubernetes.NewKubernetesRuntime).To(func() (runtime.Runtime, error) { + return &fooRuntime{}, nil + }).Build() +} + +func mockPromptDetail(input string) { + mockey.Mock((*models.ChangeOrder).PromptDetails).To(func(co *models.ChangeOrder) (string, error) { + return input, nil + }).Build() +} + +func mockStateStorage() { + mockey.Mock((*storages.LocalStorage).WorkspaceStorage).Return(&workspacestorages.LocalStorage{}, nil).Build() }