From 7765bc19f2500a0c0646a006dbb6e3e986d8e3eb Mon Sep 17 00:00:00 2001 From: KK <68334452+healthjyk@users.noreply.github.com> Date: Wed, 22 May 2024 18:00:03 +0800 Subject: [PATCH] feat: use release to update state usage in preview/apply/destroy (#1130) --- go.mod | 2 + go.sum | 2 + pkg/apis/api.kusion.io/v1/types.go | 12 +- pkg/cmd/apply/apply.go | 163 ++++----- pkg/cmd/apply/apply_test.go | 223 +++++++------ pkg/cmd/destroy/destroy.go | 133 +++++--- pkg/cmd/destroy/destroy_test.go | 309 +++++++++--------- pkg/cmd/meta/meta.go | 6 +- pkg/cmd/preview/preview.go | 45 ++- pkg/cmd/preview/preview_test.go | 252 +++++++------- pkg/engine/api/apply.go | 37 ++- pkg/engine/api/apply_test.go | 80 +++-- pkg/engine/api/destroy.go | 51 ++- pkg/engine/api/destroy_test.go | 63 ++-- pkg/engine/api/preview.go | 23 +- pkg/engine/api/preview_test.go | 6 +- pkg/engine/operation/apply.go | 129 ++++---- pkg/engine/operation/apply_test.go | 234 ++++++------- pkg/engine/operation/destory.go | 77 +++-- pkg/engine/operation/destory_test.go | 182 +++++++---- pkg/engine/operation/diff.go | 66 ---- pkg/engine/operation/doc.go | 2 - pkg/engine/operation/graph/resource_node.go | 6 +- .../operation/graph/resource_node_test.go | 13 +- pkg/engine/operation/models/doc.go | 3 - .../operation/models/operation_context.go | 82 ++--- pkg/engine/operation/parser/spec_parser.go | 14 +- .../operation/parser/spec_parser_test.go | 4 +- pkg/engine/operation/port_forward.go | 28 +- pkg/engine/operation/port_forward_test.go | 84 +++-- pkg/engine/operation/preview.go | 42 ++- pkg/engine/operation/preview_test.go | 237 +++++++------- pkg/engine/operation/testdata/state.yaml | 0 pkg/engine/operation/watch.go | 21 +- pkg/engine/operation/watch_test.go | 50 ++- pkg/engine/release/util.go | 135 ++++++++ pkg/engine/release/util_test.go | 49 +++ pkg/engine/release/validation.go | 7 +- pkg/engine/release/validation_test.go | 28 +- pkg/server/manager/stack/stack_manager.go | 199 +++++++---- pkg/server/manager/stack/util.go | 44 +-- 41 files changed, 1771 insertions(+), 1372 deletions(-) delete mode 100644 pkg/engine/operation/diff.go delete mode 100644 pkg/engine/operation/doc.go delete mode 100644 pkg/engine/operation/models/doc.go delete mode 100755 pkg/engine/operation/testdata/state.yaml create mode 100644 pkg/engine/release/util.go create mode 100644 pkg/engine/release/util_test.go diff --git a/go.mod b/go.mod index 87c91ddf..36a9b24a 100644 --- a/go.mod +++ b/go.mod @@ -85,6 +85,8 @@ require ( sigs.k8s.io/controller-runtime v0.15.1 ) +require github.com/kisielk/godepgraph v0.0.0-20240411160502-0f324ca7e282 // indirect + require ( atomicgo.dev/cursor v0.2.0 // indirect atomicgo.dev/keyboard v0.2.9 // indirect diff --git a/go.sum b/go.sum index a945457d..43e5d265 100644 --- a/go.sum +++ b/go.sum @@ -789,6 +789,8 @@ github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/godepgraph v0.0.0-20240411160502-0f324ca7e282 h1:GJiyKwxPNw3KuY/etrF81fqHKNpWSiGsqGbwKxooWRc= +github.com/kisielk/godepgraph v0.0.0-20240411160502-0f324ca7e282/go.mod h1:Gb5YEgxqiSSVrXKWQxDcKoCM94NO5QAwOwTaVmIUAMI= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.15.11/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= diff --git a/pkg/apis/api.kusion.io/v1/types.go b/pkg/apis/api.kusion.io/v1/types.go index 3d86c2e4..a08d32b8 100644 --- a/pkg/apis/api.kusion.io/v1/types.go +++ b/pkg/apis/api.kusion.io/v1/types.go @@ -858,22 +858,22 @@ type ReleasePhase string const ( // ReleasePhaseGenerating indicates the stage of generating Spec. - ReleasePhaseGenerating = "generating" + ReleasePhaseGenerating ReleasePhase = "generating" // ReleasePhasePreviewing indicated the stage of previewing. - ReleasePhasePreviewing = "previewing" + ReleasePhasePreviewing ReleasePhase = "previewing" // ReleasePhaseApplying indicates the stage of applying. - ReleasePhaseApplying = "applying" + ReleasePhaseApplying ReleasePhase = "applying" // ReleasePhaseDestroying indicates the stage of destroying. - ReleasePhaseDestroying = "destroying" + ReleasePhaseDestroying ReleasePhase = "destroying" // ReleasePhaseSucceeded is a final phase, indicates the Release is successful. - ReleasePhaseSucceeded = "succeeded" + ReleasePhaseSucceeded ReleasePhase = "succeeded" // ReleasePhaseFailed is a final phase, indicates the Release is failed. - ReleasePhaseFailed = "failed" + ReleasePhaseFailed ReleasePhase = "failed" ) // Release describes the generation, preview and deployment of a specified Stack. When the operation diff --git a/pkg/cmd/apply/apply.go b/pkg/cmd/apply/apply.go index 36f7ad20..223917c2 100644 --- a/pkg/cmd/apply/apply.go +++ b/pkg/cmd/apply/apply.go @@ -32,7 +32,7 @@ import ( 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/engine/release" "kusionstack.io/kusion/pkg/log" "kusionstack.io/kusion/pkg/util/i18n" "kusionstack.io/kusion/pkg/util/pretty" @@ -168,12 +168,45 @@ func (o *ApplyOptions) Validate(cmd *cobra.Command, args []string) error { } // Run executes the `apply` command. -func (o *ApplyOptions) Run() error { +func (o *ApplyOptions) Run() (err error) { + // update release to succeeded or failed + var storage release.Storage + var rel *apiv1.Release + releaseCreated := false + defer func() { + if !releaseCreated { + return + } + if err != nil { + rel.Phase = apiv1.ReleasePhaseFailed + _ = release.UpdateApplyRelease(storage, rel, o.DryRun) + } else { + rel.Phase = apiv1.ReleasePhaseSucceeded + err = release.UpdateApplyRelease(storage, rel, o.DryRun) + } + }() + // set no style if o.NoStyle { pterm.DisableStyling() } + // create release + storage, err = o.Backend.ReleaseStorage(o.RefProject.Name, o.RefWorkspace.Name) + if err != nil { + return + } + rel, err = release.NewApplyRelease(storage, o.RefProject.Name, o.RefStack.Name, o.RefWorkspace.Name) + if err != nil { + return + } + if !o.DryRun { + if err = storage.Create(rel); err != nil { + return + } + releaseCreated = true + } + // build parameters parameters := make(map[string]string) for _, value := range o.PreviewOptions.Values { @@ -181,10 +214,10 @@ func (o *ApplyOptions) Run() error { parameters[parts[0]] = parts[1] } - // Generate Spec + // generate Spec spec, err := generate.GenerateSpecWithSpinner(o.RefProject, o.RefStack, o.RefWorkspace, nil, o.UI, o.NoStyle) if err != nil { - return err + return } // return immediately if no resource found in stack @@ -193,11 +226,16 @@ func (o *ApplyOptions) Run() error { return nil } + // update release phase to previewing + rel.Spec = spec + rel.Phase = apiv1.ReleasePhasePreviewing + if err = release.UpdateApplyRelease(storage, rel, o.DryRun); err != nil { + return + } // compute changes for preview - storage := o.StorageBackend.StateStorage(o.RefProject.Name, o.RefWorkspace.Name) - changes, err := preview.Preview(o.PreviewOptions, storage, spec, o.RefProject, o.RefStack) + changes, err := preview.Preview(o.PreviewOptions, storage, rel.Spec, rel.State, o.RefProject, o.RefStack) if err != nil { - return err + return } if allUnChange(changes) { @@ -219,14 +257,16 @@ func (o *ApplyOptions) Run() error { // prompt if !o.Yes { for { - input, err := prompt(o.UI) + var input string + input, err = prompt(o.UI) if err != nil { return err } if input == "yes" { break } else if input == "details" { - target, err := changes.PromptDetails(o.UI) + var target string + target, err = changes.PromptDetails(o.UI) if err != nil { return err } @@ -238,70 +278,58 @@ func (o *ApplyOptions) Run() error { } } + // update release phase to applying + rel.Phase = apiv1.ReleasePhaseApplying + if err = release.UpdateApplyRelease(storage, rel, o.DryRun); err != nil { + return + } + // start applying fmt.Println("Start applying diffs ...") - if err = Apply(o, storage, spec, changes, o.IOStreams.Out); err != nil { - return err + var updatedRel *apiv1.Release + updatedRel, err = Apply(o, storage, rel, changes, o.IOStreams.Out) + if err != nil { + return } - // 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 } + rel = updatedRel if o.Watch { fmt.Println("\nStart watching changes ...") - if err = Watch(o, spec, changes); err != nil { - return err + if err = Watch(o, rel.Spec, changes); err != nil { + return } } if o.PortForward > 0 { fmt.Printf("\nStart port-forwarding ...\n") - if err = PortForward(o, spec); err != nil { - return err + if err = PortForward(o, rel.Spec); err != nil { + return } } 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 -// } +// The Apply function will apply the resources changes through the execution kusion engine. +// You can customize the runtime of engine and the release storage through `runtime` and `storage` parameters. func Apply( o *ApplyOptions, - storage state.Storage, - planResources *apiv1.Spec, + storage release.Storage, + rel *apiv1.Release, changes *models.Changes, out io.Writer, -) error { +) (*apiv1.Release, error) { // construct the apply operation ac := &operation.ApplyOperation{ Operation: models.Operation{ - Stack: changes.Stack(), - StateStorage: storage, - MsgCh: make(chan models.Message), - IgnoreFields: o.IgnoreFields, + Stack: changes.Stack(), + ReleaseStorage: storage, + MsgCh: make(chan models.Message), + IgnoreFields: o.IgnoreFields, }, } @@ -316,7 +344,7 @@ func Apply( WithRemoveWhenDone(). Start() if err != nil { - return err + return nil, err } // wait msgCh close var wg sync.WaitGroup @@ -377,8 +405,9 @@ func Apply( } }() + var updatedRel *apiv1.Release if o.DryRun { - for _, r := range planResources.Resources { + for _, r := range rel.Spec.Resources { ac.MsgCh <- models.Message{ ResourceID: r.ResourceKey(), OpResult: models.Success, @@ -388,17 +417,17 @@ func Apply( close(ac.MsgCh) } else { // parse cluster in arguments - _, st := ac.Apply(&operation.ApplyRequest{ + rsp, st := ac.Apply(&operation.ApplyRequest{ Request: models.Request{ - Project: changes.Project(), - Stack: changes.Stack(), - Operator: o.Operator, - Intent: planResources, + Project: changes.Project(), + Stack: changes.Stack(), }, + Release: rel, }) if v1.IsErr(st) { - return fmt.Errorf("apply failed, status:\n%v", st) + return nil, fmt.Errorf("apply failed, status:\n%v", st) } + updatedRel = rsp.Release } // wait for msgCh closed @@ -406,24 +435,10 @@ func Apply( // 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 + return updatedRel, 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 -// } +// Watch function will observe the changes of each resource by the execution engine. func Watch( o *ApplyOptions, planResources *apiv1.Spec, @@ -442,14 +457,14 @@ func Watch( } } - // watch operation + // 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}, }, + Spec: &apiv1.Spec{Resources: toBeWatched}, }); err != nil { return err } @@ -462,7 +477,7 @@ func Watch( // // Example: // -// o := NewApplyOptions() +// o := newApplyOptions() // spec, err := generate.GenerateSpecWithSpinner(o.RefProject, o.RefStack, o.RefWorkspace, nil, o.NoStyle) // // if err != nil { @@ -486,9 +501,7 @@ func PortForward( // portforward operation wo := &operation.PortForwardOperation{} if err := wo.PortForward(&operation.PortForwardRequest{ - Request: models.Request{ - Intent: spec, - }, + Spec: spec, Port: o.PortForward, }); err != nil { return err diff --git a/pkg/cmd/apply/apply_test.go b/pkg/cmd/apply/apply_test.go index b3902439..ea9ea711 100644 --- a/pkg/cmd/apply/apply_test.go +++ b/pkg/cmd/apply/apply_test.go @@ -18,8 +18,8 @@ import ( "context" "errors" "os" - "path/filepath" "testing" + "time" "github.com/bytedance/mockey" "github.com/pterm/pterm" @@ -34,38 +34,70 @@ import ( "kusionstack.io/kusion/pkg/engine" "kusionstack.io/kusion/pkg/engine/operation" "kusionstack.io/kusion/pkg/engine/operation/models" + releasestorages "kusionstack.io/kusion/pkg/engine/release/storages" "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/util/terminal" workspacestorages "kusionstack.io/kusion/pkg/workspace/storages" ) +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 +} + var ( proj = &apiv1.Project{ - Name: "testdata", + Name: "fake-proj", } stack = &apiv1.Stack{ - Name: "dev", + Name: "fake-stack", } workspace = &apiv1.Workspace{ - Name: "default", + Name: "fake-workspace", } ) -func NewApplyOptions() *ApplyOptions { - storageBackend := storages.NewLocalStorage(&apiv1.BackendLocalConfig{ - Path: filepath.Join("", "state.yaml"), - }) +func newApplyOptions() *ApplyOptions { return &ApplyOptions{ PreviewOptions: &preview.PreviewOptions{ MetaOptions: &meta.MetaOptions{ - RefProject: proj, - RefStack: stack, - RefWorkspace: workspace, - StorageBackend: storageBackend, + RefProject: proj, + RefStack: stack, + RefWorkspace: workspace, + Backend: &storages.LocalStorage{}, }, - Operator: "", Detail: false, All: false, NoStyle: false, @@ -76,36 +108,6 @@ func NewApplyOptions() *ApplyOptions { } } -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, @@ -125,42 +127,6 @@ func mockPatchNewKubernetesRuntime() *mockey.Mocker { }).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, @@ -191,10 +157,35 @@ func mockPatchOperationPreview() *mockey.Mocker { }).Build() } -func mockStateStorage() { +func mockWorkspaceStorage() { mockey.Mock((*storages.LocalStorage).WorkspaceStorage).Return(&workspacestorages.LocalStorage{}, nil).Build() } +func mockReleaseStorage() { + mockey.Mock((*storages.LocalStorage).ReleaseStorage).Return(&releasestorages.LocalStorage{}, nil).Build() + mockey.Mock((*releasestorages.LocalStorage).Create).Return(nil).Build() + mockey.Mock((*releasestorages.LocalStorage).Update).Return(nil).Build() + mockey.Mock((*releasestorages.LocalStorage).GetLatestRevision).Return(0).Build() + mockey.Mock((*releasestorages.LocalStorage).Get).Return(&apiv1.Release{State: &apiv1.State{}, Phase: apiv1.ReleasePhaseSucceeded}, nil).Build() +} + +func TestApplyOptions_Run(t *testing.T) { + mockey.PatchConvey("DryRun is true", t, func() { + mockGenerateSpecWithSpinner() + mockPatchNewKubernetesRuntime() + mockPatchOperationPreview() + mockWorkspaceStorage() + mockReleaseStorage() + mockOperationApply(models.Success) + + o := newApplyOptions() + o.DryRun = true + mockPromptOutput("yes") + err := o.Run() + assert.Nil(t, err) + }) +} + const ( apiVersion = "v1" kind = "ServiceAccount" @@ -223,9 +214,21 @@ func newSA(name string) apiv1.Resource { } func TestApply(t *testing.T) { - stateStorage := statestorages.NewLocalStorage(filepath.Join("", "state.yaml")) + loc, _ := time.LoadLocation("Asia/Shanghai") mockey.PatchConvey("dry run", t, func() { - planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa1}} + mockey.Mock((*releasestorages.LocalStorage).Update).Return(nil).Build() + + rel := &apiv1.Release{ + Project: "fake-project", + Workspace: "fake-workspace", + Revision: 1, + Stack: "fake-stack", + Spec: &apiv1.Spec{Resources: []apiv1.Resource{sa1}}, + State: &apiv1.State{}, + Phase: apiv1.ReleasePhaseApplying, + CreateTime: time.Date(2024, 5, 20, 19, 39, 0, 0, loc), + ModifiedTime: time.Date(2024, 5, 20, 19, 39, 0, 0, loc), + } order := &models.ChangeOrder{ StepKeys: []string{sa1.ID}, ChangeSteps: map[string]*models.ChangeStep{ @@ -236,16 +239,29 @@ func TestApply(t *testing.T) { }, }, } + changes := models.NewChanges(proj, stack, order) - o := NewApplyOptions() + o := newApplyOptions() o.DryRun = true - err := Apply(o, stateStorage, planResources, changes, os.Stdout) + _, err := Apply(o, &releasestorages.LocalStorage{}, rel, 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}} + mockey.Mock((*releasestorages.LocalStorage).Update).Return(nil).Build() + + o := newApplyOptions() + rel := &apiv1.Release{ + Project: "fake-project", + Workspace: "fake-workspace", + Revision: 1, + Stack: "fake-stack", + Spec: &apiv1.Spec{Resources: []apiv1.Resource{sa1, sa2}}, + State: &apiv1.State{}, + Phase: apiv1.ReleasePhaseApplying, + CreateTime: time.Date(2024, 5, 20, 19, 39, 0, 0, loc), + ModifiedTime: time.Date(2024, 5, 20, 19, 39, 0, 0, loc), + } order := &models.ChangeOrder{ StepKeys: []string{sa1.ID, sa2.ID}, ChangeSteps: map[string]*models.ChangeStep{ @@ -261,16 +277,27 @@ func TestApply(t *testing.T) { }, }, } - changes := models.NewChanges(proj, stack, order) - err := Apply(o, stateStorage, planResources, changes, os.Stdout) + changes := models.NewChanges(proj, stack, order) + _, err := Apply(o, &releasestorages.LocalStorage{}, rel, 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}} + mockey.Mock((*releasestorages.LocalStorage).Update).Return(nil).Build() + + o := newApplyOptions() + rel := &apiv1.Release{ + Project: "fake-project", + Workspace: "fake-workspace", + Revision: 1, + Stack: "fake-stack", + Spec: &apiv1.Spec{Resources: []apiv1.Resource{sa1}}, + State: &apiv1.State{}, + Phase: apiv1.ReleasePhaseApplying, + CreateTime: time.Date(2024, 5, 20, 19, 39, 0, 0, loc), + ModifiedTime: time.Date(2024, 5, 20, 19, 39, 0, 0, loc), + } order := &models.ChangeOrder{ StepKeys: []string{sa1.ID}, ChangeSteps: map[string]*models.ChangeStep{ @@ -283,7 +310,7 @@ func TestApply(t *testing.T) { } changes := models.NewChanges(proj, stack, order) - err := Apply(o, stateStorage, planResources, changes, os.Stdout) + _, err := Apply(o, &releasestorages.LocalStorage{}, rel, changes, os.Stdout) assert.NotNil(t, err) }) } @@ -295,7 +322,7 @@ func mockOperationApply(res models.OpResult) { if res == models.Failed { err = errors.New("mock error") } - for _, r := range request.Intent.Resources { + for _, r := range request.Release.Spec.Resources { // ing -> $res o.MsgCh <- models.Message{ ResourceID: r.ResourceKey(), @@ -316,6 +343,10 @@ func mockOperationApply(res models.OpResult) { }).Build() } +func mockPromptOutput(res string) { + mockey.Mock((*pterm.InteractiveSelectPrinter).Show).Return(res, nil).Build() +} + func TestPrompt(t *testing.T) { mockey.PatchConvey("prompt error", t, func() { mockey.Mock((*pterm.InteractiveSelectPrinter).Show).Return("", errors.New("mock error")).Build() @@ -329,7 +360,3 @@ func TestPrompt(t *testing.T) { assert.Nil(t, err) }) } - -func mockPromptOutput(res string) { - mockey.Mock((*pterm.InteractiveSelectPrinter).Show).Return(res, nil).Build() -} diff --git a/pkg/cmd/destroy/destroy.go b/pkg/cmd/destroy/destroy.go index 5effe738..d8705c39 100644 --- a/pkg/cmd/destroy/destroy.go +++ b/pkg/cmd/destroy/destroy.go @@ -32,8 +32,8 @@ import ( 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/release" "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/terminal" @@ -53,7 +53,7 @@ var ( ) // 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. +// DestroyOptions, 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 { @@ -69,14 +69,13 @@ type DeleteFlags struct { genericiooptions.IOStreams } -// DeleteOptions defines flags and other configuration parameters for the `delete` command. -type DeleteOptions struct { +// DestroyOptions defines flags and other configuration parameters for the `delete` command. +type DestroyOptions struct { *meta.MetaOptions - Operator string - Yes bool - Detail bool - NoStyle bool + Yes bool + Detail bool + NoStyle bool UI *terminal.UI @@ -121,23 +120,21 @@ 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")) cmd.Flags().BoolVarP(&flags.NoStyle, "no-style", "", false, i18n.T("no-style sets to RawOutput mode and disables all of styling")) } // ToOptions converts from CLI inputs to runtime inputs. -func (flags *DeleteFlags) ToOptions() (*DeleteOptions, error) { +func (flags *DeleteFlags) ToOptions() (*DestroyOptions, error) { // Convert meta options metaOptions, err := flags.MetaFlags.ToOptions() if err != nil { return nil, err } - o := &DeleteOptions{ + o := &DestroyOptions{ MetaOptions: metaOptions, - Operator: flags.Operator, Detail: flags.Detail, Yes: flags.Yes, NoStyle: flags.NoStyle, @@ -148,8 +145,8 @@ func (flags *DeleteFlags) ToOptions() (*DeleteOptions, error) { return o, nil } -// Validate verifies if DeleteOptions are valid and without conflicts. -func (o *DeleteOptions) Validate(cmd *cobra.Command, args []string) error { +// Validate verifies if DestroyOptions are valid and without conflicts. +func (o *DestroyOptions) Validate(cmd *cobra.Command, args []string) error { if len(args) != 0 { return cmdutil.UsageErrorf(cmd, "Unexpected args: %v", args) } @@ -158,25 +155,43 @@ func (o *DeleteOptions) Validate(cmd *cobra.Command, args []string) error { } // Run executes the `delete` command. -func (o *DeleteOptions) Run() error { +func (o *DestroyOptions) Run() (err error) { + // update release to succeeded or failed + var storage release.Storage + var rel *apiv1.Release + releaseCreated := false + defer func() { + if !releaseCreated { + return + } + if err != nil { + rel.Phase = apiv1.ReleasePhaseFailed + _ = release.UpdateDestroyRelease(storage, rel) + } else { + rel.Phase = apiv1.ReleasePhaseSucceeded + err = release.UpdateDestroyRelease(storage, rel) + } + }() + // only destroy resources we managed - storage := o.StorageBackend.StateStorage(o.RefProject.Name, o.RefWorkspace.Name) - priorState, err := storage.Get() - if err != nil || priorState == nil { - return fmt.Errorf("can not find DeprecatedState in this stack") + storage, err = o.Backend.ReleaseStorage(o.RefProject.Name, o.RefWorkspace.Name) + if err != nil { + return } - destroyResources := priorState.Resources - - if destroyResources == nil || len(priorState.Resources) == 0 { + rel, err = release.CreateDestroyRelease(storage, o.RefProject.Name, o.RefStack.Name, o.RefWorkspace.Name) + if err != nil { + return + } + if len(rel.Spec.Resources) == 0 { pterm.Println(pterm.Green("No managed resources to destroy")) - return nil + return } + releaseCreated = true // compute changes for preview - i := &apiv1.Spec{Resources: destroyResources} - changes, err := o.preview(i, o.RefProject, o.RefStack, storage) + changes, err := o.preview(rel.Spec, rel.State, o.RefProject, o.RefStack, storage) if err != nil { - return err + return } // preview @@ -199,16 +214,15 @@ func (o *DeleteOptions) Run() error { var input string input, err = prompt(o.UI) if err != nil { - return err + return } - if input == "yes" { break } else if input == "details" { var target string target, err = changes.PromptDetails(o.UI) if err != nil { - return err + return } changes.OutputDiff(target) } else { @@ -218,19 +232,29 @@ func (o *DeleteOptions) Run() error { } } + // update release phase to destroying + rel.Phase = apiv1.ReleasePhaseDestroying + if err = release.UpdateDestroyRelease(storage, rel); err != nil { + return + } // destroy fmt.Println("Start destroying resources......") - if err = o.destroy(i, changes, storage); err != nil { + var updatedRel *apiv1.Release + updatedRel, err = o.destroy(rel, changes, storage) + if err != nil { return err } + rel = updatedRel + return nil } -func (o *DeleteOptions) preview( +func (o *DestroyOptions) preview( planResources *apiv1.Spec, + priorResources *apiv1.State, proj *apiv1.Project, stack *apiv1.Stack, - stateStorage state.Storage, + storage release.Storage, ) (*models.Changes, error) { log.Info("Start compute preview changes ...") @@ -245,10 +269,10 @@ func (o *DeleteOptions) preview( pc := &operation.PreviewOperation{ Operation: models.Operation{ - OperationType: models.DestroyPreview, - Stack: stack, - StateStorage: stateStorage, - ChangeOrder: &models.ChangeOrder{StepKeys: []string{}, ChangeSteps: map[string]*models.ChangeStep{}}, + OperationType: models.DestroyPreview, + Stack: stack, + ReleaseStorage: storage, + ChangeOrder: &models.ChangeOrder{StepKeys: []string{}, ChangeSteps: map[string]*models.ChangeStep{}}, }, } @@ -256,11 +280,11 @@ func (o *DeleteOptions) preview( rsp, s := pc.Preview(&operation.PreviewRequest{ Request: models.Request{ - Project: proj, - Stack: stack, - Operator: o.Operator, - Intent: planResources, + Project: proj, + Stack: stack, }, + Spec: planResources, + State: priorResources, }) if v1.IsErr(s) { return nil, fmt.Errorf("preview failed, status: %v", s) @@ -269,12 +293,12 @@ func (o *DeleteOptions) preview( return models.NewChanges(proj, stack, rsp.Order), nil } -func (o *DeleteOptions) destroy(planResources *apiv1.Spec, changes *models.Changes, stateStorage state.Storage) error { +func (o *DestroyOptions) destroy(rel *apiv1.Release, changes *models.Changes, storage release.Storage) (*apiv1.Release, error) { destroyOpt := &operation.DestroyOperation{ Operation: models.Operation{ - Stack: changes.Stack(), - StateStorage: stateStorage, - MsgCh: make(chan models.Message), + Stack: changes.Stack(), + ReleaseStorage: storage, + MsgCh: make(chan models.Message), }, } @@ -289,7 +313,7 @@ func (o *DeleteOptions) destroy(planResources *apiv1.Spec, changes *models.Chang WithRemoveWhenDone(). Start() if err != nil { - return err + return nil, err } // wait msgCh close var wg sync.WaitGroup @@ -350,24 +374,25 @@ func (o *DeleteOptions) destroy(planResources *apiv1.Spec, changes *models.Chang } }() - st := destroyOpt.Destroy(&operation.DestroyRequest{ + req := &operation.DestroyRequest{ Request: models.Request{ - Project: changes.Project(), - Stack: changes.Stack(), - Operator: o.Operator, - Intent: planResources, + Project: changes.Project(), + Stack: changes.Stack(), }, - }) - if v1.IsErr(st) { - return fmt.Errorf("destroy failed, status: %v", st) + Release: rel, + } + rsp, status := destroyOpt.Destroy(req) + if v1.IsErr(status) { + return nil, fmt.Errorf("destroy failed, status: %v", status) } + updatedRel := rsp.Release // wait for msgCh closed wg.Wait() // print summary pterm.Println() pterm.Fprintln(o.IOStreams.Out, fmt.Sprintf("Destroy complete! Resources: %d deleted.", deleted)) - return nil + return updatedRel, nil } func prompt(ui *terminal.UI) (string, error) { diff --git a/pkg/cmd/destroy/destroy_test.go b/pkg/cmd/destroy/destroy_test.go index feb79a16..8c9205cd 100644 --- a/pkg/cmd/destroy/destroy_test.go +++ b/pkg/cmd/destroy/destroy_test.go @@ -17,9 +17,8 @@ package destroy import ( "context" "errors" - "os" - "path/filepath" "testing" + "time" "github.com/bytedance/mockey" "github.com/pterm/pterm" @@ -27,123 +26,19 @@ import ( 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/meta" "kusionstack.io/kusion/pkg/engine" "kusionstack.io/kusion/pkg/engine/operation" "kusionstack.io/kusion/pkg/engine/operation/models" + releasestorages "kusionstack.io/kusion/pkg/engine/release/storages" "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" "kusionstack.io/kusion/pkg/util/terminal" 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(&apiv1.BackendLocalConfig{ - Path: filepath.Join(cwd, "state.yaml"), - }) - return &DeleteOptions{ - MetaOptions: &meta.MetaOptions{ - RefProject: proj, - RefStack: stack, - RefWorkspace: workspace, - StorageBackend: storageBackend, - }, - Operator: "", - Detail: false, - UI: terminal.DefaultUI(), - } -} - -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.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{} @@ -180,24 +75,17 @@ func (f *fakerRuntime) Watch(_ context.Context, _ *runtime.WatchRequest) *runtim 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() -} +var ( + proj = &apiv1.Project{ + Name: "fake-proj", + } + stack = &apiv1.Stack{ + Name: "fake-stack", + } + workspace = &apiv1.Workspace{ + Name: "fake-workspace", + } +) const ( apiVersion = "v1" @@ -206,11 +94,11 @@ const ( ) var ( - sa1 = newSA("sa1") - sa2 = newSA("sa2") + sa1 = mockSA("sa1") + sa2 = mockSA("sa2") ) -func newSA(name string) apiv1.Resource { +func mockSA(name string) apiv1.Resource { return apiv1.Resource{ ID: engine.BuildID(apiVersion, kind, namespace, name), Type: "Kubernetes", @@ -225,13 +113,143 @@ func newSA(name string) apiv1.Resource { } } +func mockDeleteOptions() *DestroyOptions { + return &DestroyOptions{ + MetaOptions: &meta.MetaOptions{ + RefProject: proj, + RefStack: stack, + RefWorkspace: workspace, + Backend: &storages.LocalStorage{}, + }, + Detail: false, + UI: terminal.DefaultUI(), + } +} + +func mockRelease(resources apiv1.Resources) *apiv1.Release { + loc, _ := time.LoadLocation("Asia/Shanghai") + return &apiv1.Release{ + Project: "fake-proj", + Workspace: "fake-workspace", + Revision: 2, + Stack: "fake-stack", + Spec: &apiv1.Spec{Resources: resources}, + State: &apiv1.State{Resources: resources}, + Phase: apiv1.ReleasePhaseDestroying, + CreateTime: time.Date(2024, 5, 21, 14, 48, 0, 0, loc), + ModifiedTime: time.Date(2024, 5, 21, 14, 48, 0, 0, loc), + } +} + +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 mockNewKubernetesRuntime() { + mockey.Mock(kubernetes.NewKubernetesRuntime).To(func() (runtime.Runtime, error) { + return &fakerRuntime{}, nil + }).Build() +} + +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() +} + +func mockWorkspaceStorage() { + mockey.Mock((*storages.LocalStorage).WorkspaceStorage).Return(&workspacestorages.LocalStorage{}, nil).Build() + mockey.Mock((*workspacestorages.LocalStorage).GetCurrent).Return("default", nil).Build() +} + +func mockReleaseStorage() { + mockey.Mock((*storages.LocalStorage).ReleaseStorage).Return(&releasestorages.LocalStorage{}, nil).Build() + mockey.Mock((*releasestorages.LocalStorage).Create).Return(nil).Build() + mockey.Mock((*releasestorages.LocalStorage).Update).Return(nil).Build() + mockey.Mock((*releasestorages.LocalStorage).GetLatestRevision).Return(1).Build() + mockey.Mock((*releasestorages.LocalStorage).Get).Return(&apiv1.Release{State: &apiv1.State{}, Phase: apiv1.ReleasePhaseSucceeded}, nil).Build() +} + +func mockPromptOutput(res string) { + mockey.Mock((*pterm.InteractiveSelectPrinter).Show).Return(res, nil).Build() +} + +func TestDestroyOptions_Run(t *testing.T) { + mockey.PatchConvey("Detail is true", t, func() { + mockWorkspaceStorage() + mockNewKubernetesRuntime() + mockOperationPreview() + mockReleaseStorage() + + o := mockDeleteOptions() + o.Detail = true + err := o.Run() + assert.Nil(t, err) + }) + + mockey.PatchConvey("prompt no", t, func() { + mockDetectProjectAndStack() + mockWorkspaceStorage() + mockNewKubernetesRuntime() + mockOperationPreview() + mockReleaseStorage() + + o := mockDeleteOptions() + mockPromptOutput("no") + err := o.Run() + assert.Nil(t, err) + }) + + mockey.PatchConvey("prompt yes", t, func() { + mockDetectProjectAndStack() + mockWorkspaceStorage() + mockNewKubernetesRuntime() + mockOperationPreview() + mockOperationDestroy(models.Success) + mockReleaseStorage() + + o := mockDeleteOptions() + mockPromptOutput("yes") + err := o.Run() + assert.Nil(t, err) + }) +} + +func TestPreview(t *testing.T) { + mockey.PatchConvey("preview success", t, func() { + mockNewKubernetesRuntime() + mockOperationPreview() + + o := mockDeleteOptions() + _, err := o.preview(&apiv1.Spec{Resources: []apiv1.Resource{sa1}}, &apiv1.State{Resources: []apiv1.Resource{sa1}}, proj, stack, &releasestorages.LocalStorage{}) + assert.Nil(t, err) + }) +} + func TestDestroy(t *testing.T) { mockey.PatchConvey("destroy success", t, func() { mockNewKubernetesRuntime() mockOperationDestroy(models.Success) - o := NewDeleteOptions() - planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa2}} + o := mockDeleteOptions() + rel := mockRelease([]apiv1.Resource{sa2}) order := &models.ChangeOrder{ StepKeys: []string{sa1.ID, sa2.ID}, ChangeSteps: map[string]*models.ChangeStep{ @@ -249,16 +267,15 @@ func TestDestroy(t *testing.T) { } changes := models.NewChanges(proj, stack, order) - stateStorage := o.StorageBackend.StateStorage(o.RefProject.Name, o.RefWorkspace.Name) - err := o.destroy(planResources, changes, stateStorage) + _, err := o.destroy(rel, changes, &releasestorages.LocalStorage{}) assert.Nil(t, err) }) mockey.PatchConvey("destroy failed", t, func() { mockNewKubernetesRuntime() mockOperationDestroy(models.Failed) - o := NewDeleteOptions() - planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa1}} + o := mockDeleteOptions() + rel := mockRelease([]apiv1.Resource{sa1}) order := &models.ChangeOrder{ StepKeys: []string{sa1.ID}, ChangeSteps: map[string]*models.ChangeStep{ @@ -271,20 +288,19 @@ func TestDestroy(t *testing.T) { } changes := models.NewChanges(proj, stack, order) - stateStorage := o.StorageBackend.StateStorage(o.RefProject.Name, o.RefWorkspace.Name) - err := o.destroy(planResources, changes, stateStorage) + _, err := o.destroy(rel, changes, &releasestorages.LocalStorage{}) assert.NotNil(t, err) }) } func mockOperationDestroy(res models.OpResult) { mockey.Mock((*operation.DestroyOperation).Destroy).To( - func(o *operation.DestroyOperation, request *operation.DestroyRequest) v1.Status { + func(o *operation.DestroyOperation, request *operation.DestroyRequest) (*operation.DestroyResponse, v1.Status) { var err error if res == models.Failed { err = errors.New("mock error") } - for _, r := range request.Intent.Resources { + for _, r := range request.Release.State.Resources { // ing -> $res o.MsgCh <- models.Message{ ResourceID: r.ResourceKey(), @@ -299,21 +315,12 @@ func mockOperationDestroy(res models.OpResult) { } close(o.MsgCh) if res == models.Failed { - return v1.NewErrorStatus(err) + return nil, v1.NewErrorStatus(err) } - return nil + return &operation.DestroyResponse{}, 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((*pterm.InteractiveSelectPrinter).Show).Return("", errors.New("mock error")).Build() @@ -327,7 +334,3 @@ func TestPrompt(t *testing.T) { assert.Nil(t, err) }) } - -func mockPromptOutput(res string) { - mockey.Mock((*pterm.InteractiveSelectPrinter).Show).Return(res, nil).Build() -} diff --git a/pkg/cmd/meta/meta.go b/pkg/cmd/meta/meta.go index 253a22c6..9cc33576 100644 --- a/pkg/cmd/meta/meta.go +++ b/pkg/cmd/meta/meta.go @@ -46,8 +46,8 @@ type MetaOptions struct { // RefWorkspace referenced the workspace for this CLI invocation. RefWorkspace *v1.Workspace - // StorageBackend referenced the target storage backend for this CLI invocation. - StorageBackend backend.Backend + // Backend referenced the target storage backend for this CLI invocation. + Backend backend.Backend } // NewMetaFlags provides default flags and values for use in other commands. @@ -101,7 +101,7 @@ func (f *MetaFlags) ToOptions() (*MetaOptions, error) { if err != nil { return nil, err } - opts.StorageBackend = storageBackend + opts.Backend = storageBackend // Get current workspace from backend workspace, err := f.ParseWorkspace(storageBackend) diff --git a/pkg/cmd/preview/preview.go b/pkg/cmd/preview/preview.go index 28976efb..a27bc2d0 100644 --- a/pkg/cmd/preview/preview.go +++ b/pkg/cmd/preview/preview.go @@ -31,8 +31,8 @@ import ( 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/release" "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" @@ -72,7 +72,6 @@ const jsonOutput = "json" type PreviewFlags struct { MetaFlags *meta.MetaFlags - Operator string Detail bool All bool NoStyle bool @@ -89,7 +88,6 @@ type PreviewFlags struct { type PreviewOptions struct { *meta.MetaOptions - Operator string Detail bool All bool NoStyle bool @@ -140,7 +138,6 @@ 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")) @@ -159,7 +156,6 @@ func (f *PreviewFlags) ToOptions() (*PreviewOptions, error) { o := &PreviewOptions{ MetaOptions: metaOptions, - Operator: f.Operator, Detail: f.Detail, All: f.All, NoStyle: f.NoStyle, @@ -210,9 +206,21 @@ func (o *PreviewOptions) Run() error { return nil } + // compute state + storage, err := o.Backend.ReleaseStorage(o.RefProject.Name, o.RefWorkspace.Name) + if err != nil { + return err + } + state, err := release.GetLatestState(storage) + if err != nil { + return err + } + if state == nil { + state = &apiv1.State{} + } + // compute changes for preview - storage := o.StorageBackend.StateStorage(o.RefProject.Name, o.RefWorkspace.Name) - changes, err := Preview(o, storage, spec, o.RefProject, o.RefStack) + changes, err := Preview(o, storage, spec, state, o.RefProject, o.RefStack) if err != nil { return err } @@ -259,7 +267,7 @@ func (o *PreviewOptions) Run() error { // // Example: // -// o := NewPreviewOptions() +// o := newPreviewOptions() // stateStorage := &states.FileSystemState{ // Path: filepath.Join(o.WorkDir, states.KusionState) // } @@ -275,8 +283,9 @@ func (o *PreviewOptions) Run() error { // } func Preview( opts *PreviewOptions, - storage state.Storage, + storage release.Storage, planResources *apiv1.Spec, + priorResources *apiv1.State, project *apiv1.Project, stack *apiv1.Stack, ) (*models.Changes, error) { @@ -294,11 +303,11 @@ func Preview( // 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{}}, + OperationType: models.ApplyPreview, + Stack: stack, + ReleaseStorage: storage, + IgnoreFields: opts.IgnoreFields, + ChangeOrder: &models.ChangeOrder{StepKeys: []string{}, ChangeSteps: map[string]*models.ChangeStep{}}, }, } @@ -307,11 +316,11 @@ func Preview( // parse cluster in arguments rsp, s := pc.Preview(&operation.PreviewRequest{ Request: models.Request{ - Project: project, - Stack: stack, - Operator: opts.Operator, - Intent: planResources, + Project: project, + Stack: stack, }, + Spec: planResources, + State: priorResources, }) if v1.IsErr(s) { return nil, fmt.Errorf("preview failed.\n%s", s.String()) diff --git a/pkg/cmd/preview/preview_test.go b/pkg/cmd/preview/preview_test.go index 7e70ba34..d503f953 100644 --- a/pkg/cmd/preview/preview_test.go +++ b/pkg/cmd/preview/preview_test.go @@ -30,106 +30,14 @@ import ( "kusionstack.io/kusion/pkg/engine" "kusionstack.io/kusion/pkg/engine/operation" "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/release" + releasestorages "kusionstack.io/kusion/pkg/engine/release/storages" "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/util/terminal" 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 NewPreviewOptions() *PreviewOptions { - storageBackend := storages.NewLocalStorage(&apiv1.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 { @@ -164,34 +72,38 @@ func (f *fooRuntime) Watch(_ context.Context, _ *runtime.WatchRequest) *runtime. 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() +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 newPreviewOptions() *PreviewOptions { + storageBackend := storages.NewLocalStorage(&apiv1.BackendLocalConfig{ + Path: filepath.Join("", "state.yaml"), + }) + return &PreviewOptions{ + MetaOptions: &meta.MetaOptions{ + RefProject: proj, + RefStack: stack, + RefWorkspace: workspace, + Backend: storageBackend, + }, + } } func newSA(name string) apiv1.Resource { @@ -234,6 +146,100 @@ func mockPromptDetail(input string) { }).Build() } -func mockStateStorage() { +func mockWorkspaceStorage() { mockey.Mock((*storages.LocalStorage).WorkspaceStorage).Return(&workspacestorages.LocalStorage{}, nil).Build() } + +func mockReleaseStorageOperation() { + mockey.Mock((*releasestorages.LocalStorage).Update).Return(nil).Build() + mockey.Mock(release.GetLatestState).Return(nil, nil).Build() +} + +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 TestPreview(t *testing.T) { + t.Run("preview success", func(t *testing.T) { + m := mockOperationPreview() + defer m.UnPatch() + mockReleaseStorageOperation() + + o := &PreviewOptions{} + _, err := Preview(o, &releasestorages.LocalStorage{}, &apiv1.Spec{Resources: []apiv1.Resource{sa1, sa2, sa3}}, &apiv1.State{}, proj, stack) + assert.Nil(t, err) + }) +} + +func TestPreviewOptions_Run(t *testing.T) { + t.Run("detail is true", func(t *testing.T) { + mockey.PatchConvey("mock engine operation", t, func() { + mockGenerateSpecWithSpinner() + mockNewKubernetesRuntime() + mockOperationPreview() + mockPromptDetail("") + 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() { + mockGenerateSpecWithSpinner() + mockNewKubernetesRuntime() + mockOperationPreview() + mockPromptDetail("") + 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() { + mockGenerateSpecWithSpinner() + mockNewKubernetesRuntime() + mockOperationPreview() + mockPromptDetail("") + mockWorkspaceStorage() + + o := newPreviewOptions() + o.NoStyle = true + err := o.Run() + assert.Nil(t, err) + }) + }) +} diff --git a/pkg/engine/api/apply.go b/pkg/engine/api/apply.go index a8579f92..dc3dcd16 100644 --- a/pkg/engine/api/apply.go +++ b/pkg/engine/api/apply.go @@ -12,7 +12,7 @@ import ( v1 "kusionstack.io/kusion/pkg/apis/status/v1" "kusionstack.io/kusion/pkg/engine/operation" "kusionstack.io/kusion/pkg/engine/operation/models" - "kusionstack.io/kusion/pkg/engine/state" + "kusionstack.io/kusion/pkg/engine/release" "kusionstack.io/kusion/pkg/log" ) @@ -40,18 +40,18 @@ import ( // } func Apply( o *APIOptions, - storage state.Storage, - planResources *apiv1.Spec, + storage release.Storage, + rel *apiv1.Release, changes *models.Changes, out io.Writer, -) error { +) (*apiv1.Release, error) { // construct the apply operation ac := &operation.ApplyOperation{ Operation: models.Operation{ - Stack: changes.Stack(), - StateStorage: storage, - MsgCh: make(chan models.Message), - IgnoreFields: o.IgnoreFields, + Stack: changes.Stack(), + ReleaseStorage: storage, + MsgCh: make(chan models.Message), + IgnoreFields: o.IgnoreFields, }, } @@ -66,7 +66,7 @@ func Apply( WithRemoveWhenDone(). Start() if err != nil { - return err + return nil, err } // wait msgCh close var wg sync.WaitGroup @@ -127,8 +127,9 @@ func Apply( } }() + var upRel *apiv1.Release if o.DryRun { - for _, r := range planResources.Resources { + for _, r := range rel.Spec.Resources { ac.MsgCh <- models.Message{ ResourceID: r.ResourceKey(), OpResult: models.Success, @@ -138,24 +139,24 @@ func Apply( close(ac.MsgCh) } else { // parse cluster in arguments - _, st := ac.Apply(&operation.ApplyRequest{ + rsp, st := ac.Apply(&operation.ApplyRequest{ Request: models.Request{ - Project: changes.Project(), - Stack: changes.Stack(), - Operator: o.Operator, - Intent: planResources, + Project: changes.Project(), + Stack: changes.Stack(), }, + Release: rel, }) if v1.IsErr(st) { - return fmt.Errorf("apply failed, status:\n%v", st) + return nil, fmt.Errorf("apply failed, status:\n%v", st) } + upRel = rsp.Release } // wait for msgCh closed wg.Wait() // print summary pterm.Fprintln(out, fmt.Sprintf("Apply complete! Resources: %d created, %d updated, %d deleted.", ls.created, ls.updated, ls.deleted)) - return nil + return upRel, nil } // Watch function will observe the changes of each resource @@ -197,8 +198,8 @@ func Watch( Request: models.Request{ Project: changes.Project(), Stack: changes.Stack(), - Intent: &apiv1.Spec{Resources: toBeWatched}, }, + Spec: &apiv1.Spec{Resources: toBeWatched}, }); err != nil { return err } diff --git a/pkg/engine/api/apply_test.go b/pkg/engine/api/apply_test.go index 2639e74f..da1880d5 100644 --- a/pkg/engine/api/apply_test.go +++ b/pkg/engine/api/apply_test.go @@ -17,8 +17,8 @@ package api import ( "errors" "os" - "path/filepath" "testing" + "time" "github.com/bytedance/mockey" "github.com/stretchr/testify/assert" @@ -27,13 +27,27 @@ import ( v1 "kusionstack.io/kusion/pkg/apis/status/v1" "kusionstack.io/kusion/pkg/engine/operation" "kusionstack.io/kusion/pkg/engine/operation/models" - statestorages "kusionstack.io/kusion/pkg/engine/state/storages" + releasestorages "kusionstack.io/kusion/pkg/engine/release/storages" ) +func mockApplyRelease(resources apiv1.Resources) *apiv1.Release { + loc, _ := time.LoadLocation("Asia/Shanghai") + return &apiv1.Release{ + Project: "fake-proj", + Workspace: "fake-workspace", + Revision: 2, + Stack: "fake-stack", + Spec: &apiv1.Spec{Resources: resources}, + State: &apiv1.State{}, + Phase: apiv1.ReleasePhaseApplying, + CreateTime: time.Date(2024, 5, 21, 15, 29, 0, 0, loc), + ModifiedTime: time.Date(2024, 5, 21, 15, 29, 0, 0, loc), + } +} + 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}} + rel := mockApplyRelease([]apiv1.Resource{sa1}) order := &models.ChangeOrder{ StepKeys: []string{sa1.ID}, ChangeSteps: map[string]*models.ChangeStep{ @@ -47,13 +61,13 @@ func TestApply(t *testing.T) { changes := models.NewChanges(proj, stack, order) o := &APIOptions{} o.DryRun = true - err := Apply(o, stateStorage, planResources, changes, os.Stdout) + _, err := Apply(o, &releasestorages.LocalStorage{}, rel, changes, os.Stdout) assert.Nil(t, err) }) mockey.PatchConvey("apply success", t, func() { mockOperationApply(models.Success) o := &APIOptions{} - planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa1, sa2}} + rel := mockApplyRelease([]apiv1.Resource{sa1, sa2}) order := &models.ChangeOrder{ StepKeys: []string{sa1.ID, sa2.ID}, ChangeSteps: map[string]*models.ChangeStep{ @@ -71,14 +85,14 @@ func TestApply(t *testing.T) { } changes := models.NewChanges(proj, stack, order) - err := Apply(o, stateStorage, planResources, changes, os.Stdout) + _, err := Apply(o, &releasestorages.LocalStorage{}, rel, changes, os.Stdout) assert.Nil(t, err) }) mockey.PatchConvey("apply failed", t, func() { mockOperationApply(models.Failed) o := &APIOptions{} - planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa1}} + rel := mockApplyRelease([]apiv1.Resource{sa1}) order := &models.ChangeOrder{ StepKeys: []string{sa1.ID}, ChangeSteps: map[string]*models.ChangeStep{ @@ -91,7 +105,7 @@ func TestApply(t *testing.T) { } changes := models.NewChanges(proj, stack, order) - err := Apply(o, stateStorage, planResources, changes, os.Stdout) + _, err := Apply(o, &releasestorages.LocalStorage{}, rel, changes, os.Stdout) assert.NotNil(t, err) }) } @@ -99,27 +113,35 @@ func TestApply(t *testing.T) { 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) + st := mockOperation(res, o.MsgCh, request.Release) + if st != nil { + return nil, st } return &operation.ApplyResponse{}, nil }).Build() } + +func mockOperation(res models.OpResult, msgCh chan models.Message, rel *apiv1.Release) v1.Status { + var err error + if res == models.Failed { + err = errors.New("mock error") + } + for _, r := range rel.State.Resources { + // ing -> $res + msgCh <- models.Message{ + ResourceID: r.ResourceKey(), + OpResult: "", + OpErr: nil, + } + msgCh <- models.Message{ + ResourceID: r.ResourceKey(), + OpResult: res, + OpErr: err, + } + } + close(msgCh) + if res == models.Failed { + return v1.NewErrorStatus(err) + } + return nil +} diff --git a/pkg/engine/api/destroy.go b/pkg/engine/api/destroy.go index 3b4803de..99ce910b 100644 --- a/pkg/engine/api/destroy.go +++ b/pkg/engine/api/destroy.go @@ -11,17 +11,17 @@ import ( v1 "kusionstack.io/kusion/pkg/apis/status/v1" "kusionstack.io/kusion/pkg/engine/operation" "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/release" "kusionstack.io/kusion/pkg/engine/runtime/terraform" - "kusionstack.io/kusion/pkg/engine/state" "kusionstack.io/kusion/pkg/log" ) func DestroyPreview( - o *APIOptions, planResources *apiv1.Spec, + priorResources *apiv1.State, proj *apiv1.Project, stack *apiv1.Stack, - stateStorage state.Storage, + storage release.Storage, ) (*models.Changes, error) { log.Info("Start compute preview changes ...") @@ -36,10 +36,10 @@ func DestroyPreview( pc := &operation.PreviewOperation{ Operation: models.Operation{ - OperationType: models.DestroyPreview, - Stack: stack, - StateStorage: stateStorage, - ChangeOrder: &models.ChangeOrder{StepKeys: []string{}, ChangeSteps: map[string]*models.ChangeStep{}}, + OperationType: models.DestroyPreview, + Stack: stack, + ReleaseStorage: storage, + ChangeOrder: &models.ChangeOrder{StepKeys: []string{}, ChangeSteps: map[string]*models.ChangeStep{}}, }, } @@ -47,11 +47,11 @@ func DestroyPreview( rsp, s := pc.Preview(&operation.PreviewRequest{ Request: models.Request{ - Project: proj, - Stack: stack, - Operator: o.Operator, - Intent: planResources, + Project: proj, + Stack: stack, }, + Spec: planResources, + State: priorResources, }) if v1.IsErr(s) { return nil, fmt.Errorf("preview failed, status: %v", s) @@ -61,16 +61,15 @@ func DestroyPreview( } func Destroy( - o *APIOptions, - planResources *apiv1.Spec, + rel *apiv1.Release, changes *models.Changes, - stateStorage state.Storage, -) error { + storage release.Storage, +) (*apiv1.Release, error) { do := &operation.DestroyOperation{ Operation: models.Operation{ - Stack: changes.Stack(), - StateStorage: stateStorage, - MsgCh: make(chan models.Message), + Stack: changes.Stack(), + ReleaseStorage: storage, + MsgCh: make(chan models.Message), }, } @@ -83,7 +82,7 @@ func Destroy( WithRemoveWhenDone(). Start() if err != nil { - return err + return nil, err } // wait msgCh close var wg sync.WaitGroup @@ -144,22 +143,22 @@ func Destroy( } }() - st := do.Destroy(&operation.DestroyRequest{ + rsp, st := do.Destroy(&operation.DestroyRequest{ Request: models.Request{ - Project: changes.Project(), - Stack: changes.Stack(), - Operator: o.Operator, - Intent: planResources, + Project: changes.Project(), + Stack: changes.Stack(), }, + Release: rel, }) if v1.IsErr(st) { - return fmt.Errorf("destroy failed, status: %v", st) + return nil, fmt.Errorf("destroy failed, status: %v", st) } + upRel := rsp.Release // wait for msgCh closed wg.Wait() // print summary pterm.Println() pterm.Printf("Destroy complete! Resources: %d deleted.\n", deleted) - return nil + return upRel, nil } diff --git a/pkg/engine/api/destroy_test.go b/pkg/engine/api/destroy_test.go index b610bde4..0fdab80c 100644 --- a/pkg/engine/api/destroy_test.go +++ b/pkg/engine/api/destroy_test.go @@ -16,9 +16,8 @@ package api import ( "context" - "errors" - "path/filepath" "testing" + "time" "github.com/bytedance/mockey" "github.com/stretchr/testify/assert" @@ -27,19 +26,32 @@ import ( v1 "kusionstack.io/kusion/pkg/apis/status/v1" "kusionstack.io/kusion/pkg/engine/operation" "kusionstack.io/kusion/pkg/engine/operation/models" + releasestorages "kusionstack.io/kusion/pkg/engine/release/storages" "kusionstack.io/kusion/pkg/engine/runtime" "kusionstack.io/kusion/pkg/engine/runtime/kubernetes" - statestorages "kusionstack.io/kusion/pkg/engine/state/storages" ) +func mockDestroyRelease(resources apiv1.Resources) *apiv1.Release { + loc, _ := time.LoadLocation("Asia/Shanghai") + return &apiv1.Release{ + Project: "fake-proj", + Workspace: "fake-workspace", + Revision: 2, + Stack: "fake-stack", + Spec: &apiv1.Spec{Resources: resources}, + State: &apiv1.State{Resources: resources}, + Phase: apiv1.ReleasePhaseDestroying, + CreateTime: time.Date(2024, 5, 21, 15, 43, 0, 0, loc), + ModifiedTime: time.Date(2024, 5, 21, 15, 43, 0, 0, loc), + } +} + func TestDestroyPreview(t *testing.T) { - stateStorage := statestorages.NewLocalStorage(filepath.Join("", "state.yaml")) mockey.PatchConvey("preview success", t, func() { mockNewKubernetesRuntime() mockOperationPreview() - o := &APIOptions{} - _, err := DestroyPreview(o, &apiv1.Spec{Resources: []apiv1.Resource{sa1}}, proj, stack, stateStorage) + _, err := DestroyPreview(&apiv1.Spec{Resources: []apiv1.Resource{sa1}}, &apiv1.State{Resources: []apiv1.Resource{sa1}}, proj, stack, &releasestorages.LocalStorage{}) assert.Nil(t, err) }) } @@ -87,13 +99,11 @@ func (f *fakerRuntime) Watch(_ context.Context, _ *runtime.WatchRequest) *runtim } func TestDestroy(t *testing.T) { - stateStorage := statestorages.NewLocalStorage(filepath.Join("", "state.yaml")) mockey.PatchConvey("destroy success", t, func() { mockNewKubernetesRuntime() mockOperationDestroy(models.Success) - o := &APIOptions{} - planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa2}} + rel := mockDestroyRelease([]apiv1.Resource{sa2}) order := &models.ChangeOrder{ StepKeys: []string{sa1.ID, sa2.ID}, ChangeSteps: map[string]*models.ChangeStep{ @@ -111,15 +121,15 @@ func TestDestroy(t *testing.T) { } changes := models.NewChanges(proj, stack, order) - err := Destroy(o, planResources, changes, stateStorage) + _, err := Destroy(rel, changes, &releasestorages.LocalStorage{}) assert.Nil(t, err) }) + mockey.PatchConvey("destroy failed", t, func() { mockNewKubernetesRuntime() mockOperationDestroy(models.Failed) - o := &APIOptions{} - planResources := &apiv1.Spec{Resources: []apiv1.Resource{sa1}} + rel := mockDestroyRelease([]apiv1.Resource{sa1}) order := &models.ChangeOrder{ StepKeys: []string{sa1.ID}, ChangeSteps: map[string]*models.ChangeStep{ @@ -132,35 +142,18 @@ func TestDestroy(t *testing.T) { } changes := models.NewChanges(proj, stack, order) - err := Destroy(o, planResources, changes, stateStorage) + _, err := Destroy(rel, changes, &releasestorages.LocalStorage{}) 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) + func(o *operation.DestroyOperation, request *operation.DestroyRequest) (*operation.DestroyResponse, v1.Status) { + st := mockOperation(res, o.MsgCh, request.Release) + if st != nil { + return nil, st } - return nil + return &operation.DestroyResponse{}, nil }).Build() } diff --git a/pkg/engine/api/preview.go b/pkg/engine/api/preview.go index 5b56743e..4652f562 100644 --- a/pkg/engine/api/preview.go +++ b/pkg/engine/api/preview.go @@ -7,8 +7,8 @@ import ( v1 "kusionstack.io/kusion/pkg/apis/status/v1" "kusionstack.io/kusion/pkg/engine/operation" opsmodels "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/release" "kusionstack.io/kusion/pkg/engine/runtime/terraform" - "kusionstack.io/kusion/pkg/engine/state" "kusionstack.io/kusion/pkg/log" ) @@ -52,8 +52,9 @@ func NewAPIOptions() APIOptions { // } func Preview( o *APIOptions, - storage state.Storage, + storage release.Storage, planResources *apiv1.Spec, + priorResources *apiv1.State, proj *apiv1.Project, stack *apiv1.Stack, ) (*opsmodels.Changes, error) { @@ -71,11 +72,11 @@ func Preview( // construct the preview operation pc := &operation.PreviewOperation{ Operation: opsmodels.Operation{ - OperationType: opsmodels.ApplyPreview, - Stack: stack, - StateStorage: storage, - IgnoreFields: o.IgnoreFields, - ChangeOrder: &opsmodels.ChangeOrder{StepKeys: []string{}, ChangeSteps: map[string]*opsmodels.ChangeStep{}}, + OperationType: opsmodels.ApplyPreview, + Stack: stack, + ReleaseStorage: storage, + IgnoreFields: o.IgnoreFields, + ChangeOrder: &opsmodels.ChangeOrder{StepKeys: []string{}, ChangeSteps: map[string]*opsmodels.ChangeStep{}}, }, } @@ -84,11 +85,11 @@ func Preview( // parse cluster in arguments rsp, s := pc.Preview(&operation.PreviewRequest{ Request: opsmodels.Request{ - Project: proj, - Stack: stack, - Operator: o.Operator, - Intent: planResources, + Project: proj, + Stack: stack, }, + Spec: planResources, + State: priorResources, }) if v1.IsErr(s) { return nil, fmt.Errorf("preview failed.\n%s", s.String()) diff --git a/pkg/engine/api/preview_test.go b/pkg/engine/api/preview_test.go index dd2ce974..30c784ad 100644 --- a/pkg/engine/api/preview_test.go +++ b/pkg/engine/api/preview_test.go @@ -15,7 +15,6 @@ package api import ( - "path/filepath" "testing" "github.com/bytedance/mockey" @@ -26,7 +25,7 @@ import ( "kusionstack.io/kusion/pkg/engine" "kusionstack.io/kusion/pkg/engine/operation" "kusionstack.io/kusion/pkg/engine/operation/models" - statestorages "kusionstack.io/kusion/pkg/engine/state/storages" + releasestorages "kusionstack.io/kusion/pkg/engine/release/storages" ) var ( @@ -47,13 +46,12 @@ var ( ) 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 := &APIOptions{} - _, err := Preview(o, stateStorage, &apiv1.Spec{Resources: []apiv1.Resource{sa1, sa2, sa3}}, proj, stack) + _, err := Preview(o, &releasestorages.LocalStorage{}, &apiv1.Spec{Resources: []apiv1.Resource{sa1, sa2, sa3}}, &apiv1.State{}, proj, stack) assert.Nil(t, err) }) } diff --git a/pkg/engine/operation/apply.go b/pkg/engine/operation/apply.go index 8b1459fa..621ab745 100644 --- a/pkg/engine/operation/apply.go +++ b/pkg/engine/operation/apply.go @@ -5,11 +5,14 @@ import ( "fmt" "sync" + "github.com/jinzhu/copier" + apiv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" v1 "kusionstack.io/kusion/pkg/apis/status/v1" "kusionstack.io/kusion/pkg/engine/operation/graph" models "kusionstack.io/kusion/pkg/engine/operation/models" "kusionstack.io/kusion/pkg/engine/operation/parser" + "kusionstack.io/kusion/pkg/engine/release" runtimeinit "kusionstack.io/kusion/pkg/engine/runtime/init" "kusionstack.io/kusion/pkg/log" "kusionstack.io/kusion/third_party/terraform/dag" @@ -21,29 +24,12 @@ type ApplyOperation struct { } type ApplyRequest struct { - models.Request `json:",inline" yaml:",inline"` + models.Request + Release *apiv1.Release } type ApplyResponse struct { - State *apiv1.DeprecatedState -} - -func NewApplyGraph(m *apiv1.Spec, priorState *apiv1.DeprecatedState) (*dag.AcyclicGraph, v1.Status) { - intentParser := parser.NewIntentParser(m) - g := &dag.AcyclicGraph{} - g.Add(&graph.RootNode{}) - - s := intentParser.Parse(g) - if v1.IsErr(s) { - return nil, s - } - deleteResourceParser := parser.NewDeleteResourceParser(priorState.Resources) - s = deleteResourceParser.Parse(g) - if v1.IsErr(s) { - return nil, s - } - - return g, s + Release *apiv1.Release } // Apply means turn all actual infra resources into the desired state described in the request by invoking a specified Runtime. @@ -51,7 +37,7 @@ func NewApplyGraph(m *apiv1.Spec, priorState *apiv1.DeprecatedState) (*dag.Acycl // 1. parse resources and their relationship to build a DAG and should take care of those resources that will be deleted // 2. walk this DAG and execute all graph nodes concurrently, besides the entire process should follow dependencies in this DAG // 3. during the execution of each node, it will invoke different runtime according to the resource type -func (ao *ApplyOperation) Apply(request *ApplyRequest) (rsp *ApplyResponse, st v1.Status) { +func (ao *ApplyOperation) Apply(req *ApplyRequest) (rsp *ApplyResponse, s v1.Status) { log.Infof("engine: Apply start!") o := ao.Operation @@ -63,21 +49,21 @@ func (ao *ApplyOperation) Apply(request *ApplyRequest) (rsp *ApplyResponse, st v switch x := e.(type) { case string: - st = v1.NewErrorStatus(fmt.Errorf("apply panic:%s", e)) + s = v1.NewErrorStatus(fmt.Errorf("apply panic:%s", e)) case error: - st = v1.NewErrorStatus(x) + s = v1.NewErrorStatus(x) default: - st = v1.NewErrorStatusWithCode(v1.Unknown, errors.New("unknown panic")) + s = v1.NewErrorStatusWithCode(v1.Unknown, errors.New("unknown panic")) } } }() - if st = validateRequest(&request.Request); v1.IsErr(st) { - return nil, st + if s = validateApplyRequest(req); v1.IsErr(s) { + return nil, s } // 1. init & build Indexes - priorState, resultState := o.InitStates(&request.Request) + priorState := req.Release.State priorStateResourceIndex := priorState.Resources.Index() // copy priorStateResourceIndex into a new map stateResourceIndex := map[string]*apiv1.Resource{} @@ -85,7 +71,7 @@ func (ao *ApplyOperation) Apply(request *ApplyRequest) (rsp *ApplyResponse, st v stateResourceIndex[k] = v } - resources := request.Intent.Resources + resources := req.Release.Spec.Resources resources = append(resources, priorState.Resources...) runtimesMap, s := runtimeinit.Runtimes(resources) if v1.IsErr(s) { @@ -94,16 +80,20 @@ func (ao *ApplyOperation) Apply(request *ApplyRequest) (rsp *ApplyResponse, st v o.RuntimeMap = runtimesMap // 2. build & walk DAG - applyGraph, s := NewApplyGraph(request.Intent, priorState) + applyGraph, s := newApplyGraph(req.Release.Spec, priorState) if v1.IsErr(s) { return nil, s } log.Infof("Apply Graph:\n%s", applyGraph.String()) + rel, s := copyRelease(req.Release) + if v1.IsErr(s) { + return nil, s + } applyOperation := &ApplyOperation{ Operation: models.Operation{ OperationType: models.Apply, - StateStorage: o.StateStorage, + ReleaseStorage: o.ReleaseStorage, CtxResourceIndex: map[string]*apiv1.Resource{}, PriorStateResourceIndex: priorStateResourceIndex, StateResourceIndex: stateResourceIndex, @@ -111,28 +101,70 @@ func (ao *ApplyOperation) Apply(request *ApplyRequest) (rsp *ApplyResponse, st v Stack: o.Stack, IgnoreFields: o.IgnoreFields, MsgCh: o.MsgCh, - ResultState: resultState, Lock: &sync.Mutex{}, + Release: rel, }, } - w := &dag.Walker{Callback: applyOperation.applyWalkFun} + w := &dag.Walker{Callback: applyOperation.walkFun} w.Update(applyGraph) // Wait if diags := w.Wait(); diags.HasErrors() { - st = v1.NewErrorStatus(diags.Err()) - return nil, st + s = v1.NewErrorStatus(diags.Err()) + return nil, s + } + + return &ApplyResponse{Release: applyOperation.Release}, nil +} + +func (ao *ApplyOperation) walkFun(v dag.Vertex) (diags tfdiags.Diagnostics) { + return applyWalkFun(&ao.Operation, v) +} + +func validateApplyRequest(req *ApplyRequest) v1.Status { + if req == nil { + return v1.NewErrorStatusWithMsg(v1.InvalidArgument, "request is nil") + } + if err := release.ValidateRelease(req.Release); err != nil { + return v1.NewErrorStatusWithMsg(v1.InvalidArgument, err.Error()) } + if req.Release.Phase != apiv1.ReleasePhaseApplying { + return v1.NewErrorStatusWithMsg(v1.InvalidArgument, "release phase is not applying") + } + return nil +} + +func newApplyGraph(spec *apiv1.Spec, priorState *apiv1.State) (*dag.AcyclicGraph, v1.Status) { + specParser := parser.NewIntentParser(spec) + g := &dag.AcyclicGraph{} + g.Add(&graph.RootNode{}) - return &ApplyResponse{State: resultState}, nil + s := specParser.Parse(g) + if v1.IsErr(s) { + return nil, s + } + deleteResourceParser := parser.NewDeleteResourceParser(priorState.Resources) + s = deleteResourceParser.Parse(g) + if v1.IsErr(s) { + return nil, s + } + + return g, s } -func (ao *ApplyOperation) applyWalkFun(v dag.Vertex) (diags tfdiags.Diagnostics) { +func copyRelease(r *apiv1.Release) (*apiv1.Release, v1.Status) { + rel := &apiv1.Release{} + if err := copier.Copy(rel, r); err != nil { + return nil, v1.NewErrorStatusWithMsg(v1.Internal, fmt.Sprintf("copy release failed, %v", err)) + } + return rel, nil +} + +func applyWalkFun(o *models.Operation, v dag.Vertex) (diags tfdiags.Diagnostics) { var s v1.Status if v == nil { return nil } - o := &ao.Operation if node, ok := v.(graph.ExecutableNode); ok { if rn, ok2 := v.(*graph.ResourceNode); ok2 { @@ -156,26 +188,3 @@ func (ao *ApplyOperation) applyWalkFun(v dag.Vertex) (diags tfdiags.Diagnostics) } return diags } - -func validateRequest(request *models.Request) v1.Status { - var s v1.Status - - if request == nil { - return v1.NewErrorStatusWithMsg(v1.InvalidArgument, "request is nil") - } - if request.Intent == nil { - return v1.NewErrorStatusWithMsg(v1.InvalidArgument, - "request.Intent is empty. If you want to delete all resources, please use command 'destroy'") - } - resourceKeyMap := make(map[string]bool) - - for _, resource := range request.Intent.Resources { - key := resource.ResourceKey() - if _, ok := resourceKeyMap[key]; ok { - return v1.NewErrorStatusWithMsg(v1.InvalidArgument, fmt.Sprintf("Duplicate resource:%s in request.", key)) - } - resourceKeyMap[key] = true - } - - return s -} diff --git a/pkg/engine/operation/apply_test.go b/pkg/engine/operation/apply_test.go index 28aeba5c..a00cf66d 100644 --- a/pkg/engine/operation/apply_test.go +++ b/pkg/engine/operation/apply_test.go @@ -1,104 +1,58 @@ package operation import ( - "path/filepath" - "reflect" "sync" "testing" + "time" "github.com/bytedance/mockey" - _ "github.com/go-sql-driver/mysql" "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/engine/operation/graph" "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/release" + "kusionstack.io/kusion/pkg/engine/release/storages" "kusionstack.io/kusion/pkg/engine/runtime" runtimeinit "kusionstack.io/kusion/pkg/engine/runtime/init" "kusionstack.io/kusion/pkg/engine/runtime/kubernetes" - "kusionstack.io/kusion/pkg/engine/state" - "kusionstack.io/kusion/pkg/engine/state/storages" ) -func Test_ValidateRequest(t *testing.T) { - type args struct { - request *models.Request - } - tests := []struct { - name string - args args - want v1.Status - }{ - { - name: "t1", - args: args{ - request: &models.Request{}, - }, - want: v1.NewErrorStatusWithMsg(v1.InvalidArgument, - "request.Intent is empty. If you want to delete all resources, please use command 'destroy'"), - }, - { - name: "t2", - args: args{ - request: &models.Request{ - Intent: &apiv1.Spec{Resources: []apiv1.Resource{}}, - }, - }, - want: nil, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := validateRequest(tt.args.request); !reflect.DeepEqual(got, tt.want) { - t.Errorf("validateRequest() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestOperation_Apply(t *testing.T) { +func TestApplyOperation_Apply(t *testing.T) { type fields struct { - OperationType models.OperationType - StateStorage state.Storage - CtxResourceIndex map[string]*apiv1.Resource - PriorStateResourceIndex map[string]*apiv1.Resource - StateResourceIndex map[string]*apiv1.Resource - Order *models.ChangeOrder - RuntimeMap map[apiv1.Type]runtime.Runtime - Stack *apiv1.Stack - MsgCh chan models.Message - resultState *apiv1.DeprecatedState + operationType models.OperationType + releaseStorage release.Storage + ctxResourceIndex map[string]*apiv1.Resource + priorStateResourceIndex map[string]*apiv1.Resource + stateResourceIndex map[string]*apiv1.Resource + order *models.ChangeOrder + runtimeMap map[apiv1.Type]runtime.Runtime + stack *apiv1.Stack + msgCh chan models.Message + release *apiv1.Release lock *sync.Mutex } type args struct { applyRequest *ApplyRequest } - const Jack = "jack" - mf := &apiv1.Spec{Resources: []apiv1.Resource{ - { - ID: Jack, - Type: runtime.Kubernetes, - Attributes: map[string]interface{}{ - "a": "b", + fakeSpec := &apiv1.Spec{ + Resources: []apiv1.Resource{ + { + ID: "mock-id", + Type: runtime.Kubernetes, + Attributes: map[string]interface{}{ + "a": "b", + }, + DependsOn: nil, }, - DependsOn: nil, }, - }} - - rs := &apiv1.DeprecatedState{ - ID: 0, - Stack: "fake-stack", - Project: "fake-project", - Workspace: "fake-workspace", - Version: 0, - KusionVersion: "", - Serial: 1, - Operator: "fake-operator", + } + fakeState := &apiv1.State{ Resources: []apiv1.Resource{ { - ID: Jack, + ID: "mock-id", Type: runtime.Kubernetes, Attributes: map[string]interface{}{ "a": "b", @@ -108,63 +62,89 @@ func TestOperation_Apply(t *testing.T) { }, } - s := &apiv1.Stack{ + loc, _ := time.LoadLocation("Asia/Shanghai") + fakeTime := time.Date(2024, 5, 10, 16, 48, 0, 0, loc) + fakeRelease := &apiv1.Release{ + Project: "fake-project", + Workspace: "fake-workspace", + Revision: 1, + Stack: "fake-stack", + Spec: fakeSpec, + State: &apiv1.State{}, + Phase: apiv1.ReleasePhaseApplying, + CreateTime: fakeTime, + ModifiedTime: fakeTime, + } + fakeUpdateRelease := &apiv1.Release{ + Project: "fake-project", + Workspace: "fake-workspace", + Revision: 1, + Stack: "fake-stack", + Spec: fakeSpec, + State: fakeState, + Phase: apiv1.ReleasePhaseApplying, + CreateTime: fakeTime, + ModifiedTime: fakeTime, + } + + fakeStack := &apiv1.Stack{ Name: "fake-stack", Path: "fake-path", } - p := &apiv1.Project{ + fakeProject := &apiv1.Project{ Name: "fake-project", Path: "fake-path", - Stacks: []*apiv1.Stack{s}, + Stacks: []*apiv1.Stack{fakeStack}, } - tests := []struct { - name string - fields fields - args args - wantRsp *ApplyResponse - wantSt v1.Status + testcases := []struct { + name string + fields fields + args args + expectedResponse *ApplyResponse + expectedStatus v1.Status }{ { name: "apply test", fields: fields{ - OperationType: models.Apply, - StateStorage: storages.NewLocalStorage(filepath.Join("testdata", "state.yaml")), - RuntimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &kubernetes.KubernetesRuntime{}}, - MsgCh: make(chan models.Message, 5), + operationType: models.Apply, + releaseStorage: &storages.LocalStorage{}, + runtimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &kubernetes.KubernetesRuntime{}}, + msgCh: make(chan models.Message, 5), }, - args: args{applyRequest: &ApplyRequest{models.Request{ - Stack: s, - Project: p, - Operator: "fake-operator", - Intent: mf, - }}}, - wantRsp: &ApplyResponse{rs}, - wantSt: nil, + args: args{applyRequest: &ApplyRequest{ + Request: models.Request{ + Stack: fakeStack, + Project: fakeProject, + }, + Release: fakeRelease, + }}, + expectedResponse: &ApplyResponse{Release: fakeUpdateRelease}, + expectedStatus: nil, }, } - for _, tt := range tests { - mockey.PatchConvey(tt.name, t, func() { + for _, tc := range testcases { + mockey.PatchConvey(tc.name, t, func() { o := &models.Operation{ - OperationType: tt.fields.OperationType, - StateStorage: tt.fields.StateStorage, - CtxResourceIndex: tt.fields.CtxResourceIndex, - PriorStateResourceIndex: tt.fields.PriorStateResourceIndex, - StateResourceIndex: tt.fields.StateResourceIndex, - ChangeOrder: tt.fields.Order, - RuntimeMap: tt.fields.RuntimeMap, - Stack: tt.fields.Stack, - MsgCh: tt.fields.MsgCh, - ResultState: tt.fields.resultState, - Lock: tt.fields.lock, + OperationType: tc.fields.operationType, + ReleaseStorage: tc.fields.releaseStorage, + CtxResourceIndex: tc.fields.ctxResourceIndex, + PriorStateResourceIndex: tc.fields.priorStateResourceIndex, + StateResourceIndex: tc.fields.stateResourceIndex, + ChangeOrder: tc.fields.order, + RuntimeMap: tc.fields.runtimeMap, + Stack: tc.fields.stack, + MsgCh: tc.fields.msgCh, + Release: tc.fields.release, + Lock: tc.fields.lock, } ao := &ApplyOperation{ Operation: *o, } mockey.Mock((*graph.ResourceNode).Execute).To(func(operation *models.Operation) v1.Status { - o.ResultState = rs + operation.Release = fakeUpdateRelease return nil }).Build() mockey.Mock(runtimeinit.Runtimes).To(func( @@ -173,9 +153,43 @@ func TestOperation_Apply(t *testing.T) { return map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &kubernetes.KubernetesRuntime{}}, nil }).Build() - gotRsp, gotSt := ao.Apply(tt.args.applyRequest) - assert.Equalf(t, tt.wantRsp.State.Stack, gotRsp.State.Stack, "Apply(%v)", tt.args.applyRequest) - assert.Equalf(t, tt.wantSt, gotSt, "Apply(%v)", tt.args.applyRequest) + rsp, status := ao.Apply(tc.args.applyRequest) + assert.Equal(t, tc.expectedResponse, rsp) + assert.Equal(t, tc.expectedStatus, status) + }) + } +} + +func Test_ValidateApplyRequest(t *testing.T) { + testcases := []struct { + name string + success bool + req *ApplyRequest + }{ + { + name: "invalid request nil request", + success: false, + req: nil, + }, + { + name: "invalid request invalid release phase", + success: false, + req: &ApplyRequest{ + Release: &apiv1.Release{ + Phase: "invalid_phase", + }, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock valid release and spec", t, func() { + mockey.Mock(release.ValidateRelease).Return(nil).Build() + mockey.Mock(release.ValidateSpec).Return(nil).Build() + err := validateApplyRequest(tc.req) + assert.Equal(t, tc.success, err == nil) + }) }) } } diff --git a/pkg/engine/operation/destory.go b/pkg/engine/operation/destory.go index b948a226..2a036035 100644 --- a/pkg/engine/operation/destory.go +++ b/pkg/engine/operation/destory.go @@ -8,6 +8,7 @@ import ( "kusionstack.io/kusion/pkg/engine/operation/graph" "kusionstack.io/kusion/pkg/engine/operation/models" "kusionstack.io/kusion/pkg/engine/operation/parser" + "kusionstack.io/kusion/pkg/engine/release" runtimeinit "kusionstack.io/kusion/pkg/engine/runtime/init" "kusionstack.io/kusion/third_party/terraform/dag" "kusionstack.io/kusion/third_party/terraform/tfdiags" @@ -18,33 +19,26 @@ type DestroyOperation struct { } type DestroyRequest struct { - models.Request `json:",inline" yaml:",inline"` + models.Request + Release *apiv1.Release } -func NewDestroyGraph(resource apiv1.Resources) (*dag.AcyclicGraph, v1.Status) { - ag := &dag.AcyclicGraph{} - ag.Add(&graph.RootNode{}) - deleteResourceParser := parser.NewDeleteResourceParser(resource) - s := deleteResourceParser.Parse(ag) - if v1.IsErr(s) { - return nil, s - } - - return ag, s +type DestroyResponse struct { + Release *apiv1.Release } // Destroy will delete all resources in this request. The whole process is similar to the operation Apply, // but every node's execution is deleting the resource. -func (do *DestroyOperation) Destroy(request *DestroyRequest) (st v1.Status) { +func (do *DestroyOperation) Destroy(req *DestroyRequest) (rsp *DestroyResponse, s v1.Status) { o := do.Operation defer close(o.MsgCh) - if st = validateRequest(&request.Request); v1.IsErr(st) { - return st + if s = validateDestroyRequest(req); v1.IsErr(s) { + return nil, s } // 1. init & build Indexes - priorState, resultState := o.InitStates(&request.Request) + priorState := req.Release.State priorStateResourceIndex := priorState.Resources.Index() // copy priorStateResourceIndex into a new map stateResourceIndex := map[string]*apiv1.Resource{} @@ -56,44 +50,71 @@ func (do *DestroyOperation) Destroy(request *DestroyRequest) (st v1.Status) { resources := priorState.Resources runtimesMap, s := runtimeinit.Runtimes(resources) if v1.IsErr(s) { - return s + return nil, s } o.RuntimeMap = runtimesMap // 2. build & walk DAG - destroyGraph, s := NewDestroyGraph(resources) + destroyGraph, s := newDestroyGraph(resources) if v1.IsErr(s) { - return s + return nil, s } - newDo := &DestroyOperation{ + rel, s := copyRelease(req.Release) + if v1.IsErr(s) { + return nil, s + } + destroyOperation := &DestroyOperation{ Operation: models.Operation{ OperationType: models.Destroy, - StateStorage: o.StateStorage, + ReleaseStorage: o.ReleaseStorage, CtxResourceIndex: map[string]*apiv1.Resource{}, PriorStateResourceIndex: priorStateResourceIndex, StateResourceIndex: stateResourceIndex, RuntimeMap: o.RuntimeMap, Stack: o.Stack, MsgCh: o.MsgCh, - ResultState: resultState, Lock: &sync.Mutex{}, + Release: rel, }, } - w := &dag.Walker{Callback: newDo.destroyWalkFun} + w := &dag.Walker{Callback: destroyOperation.walkFun} w.Update(destroyGraph) // Wait if diags := w.Wait(); diags.HasErrors() { - st = v1.NewErrorStatus(diags.Err()) - return st + s = v1.NewErrorStatus(diags.Err()) + return nil, s + } + + return &DestroyResponse{Release: destroyOperation.Release}, nil +} + +func (do *DestroyOperation) walkFun(v dag.Vertex) (diags tfdiags.Diagnostics) { + return applyWalkFun(&do.Operation, v) +} + +func validateDestroyRequest(req *DestroyRequest) v1.Status { + if req == nil { + return v1.NewErrorStatusWithMsg(v1.InvalidArgument, "request is nil") + } + if err := release.ValidateRelease(req.Release); err != nil { + return v1.NewErrorStatusWithMsg(v1.InvalidArgument, err.Error()) + } + if req.Release.Phase != apiv1.ReleasePhaseDestroying { + return v1.NewErrorStatusWithMsg(v1.InvalidArgument, "release phase is not destroying") } return nil } -func (do *DestroyOperation) destroyWalkFun(v dag.Vertex) (diags tfdiags.Diagnostics) { - ao := &ApplyOperation{ - Operation: do.Operation, +func newDestroyGraph(resource apiv1.Resources) (*dag.AcyclicGraph, v1.Status) { + ag := &dag.AcyclicGraph{} + ag.Add(&graph.RootNode{}) + deleteResourceParser := parser.NewDeleteResourceParser(resource) + status := deleteResourceParser.Parse(ag) + if v1.IsErr(status) { + return nil, status } - return ao.applyWalkFun(v) + + return ag, status } diff --git a/pkg/engine/operation/destory_test.go b/pkg/engine/operation/destory_test.go index af987ee5..5d2a0f2d 100644 --- a/pkg/engine/operation/destory_test.go +++ b/pkg/engine/operation/destory_test.go @@ -4,8 +4,8 @@ import ( "context" "errors" "fmt" - "path/filepath" "testing" + "time" "github.com/bytedance/mockey" "github.com/stretchr/testify/assert" @@ -14,25 +14,60 @@ import ( v1 "kusionstack.io/kusion/pkg/apis/status/v1" "kusionstack.io/kusion/pkg/engine/operation/graph" "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/release" + "kusionstack.io/kusion/pkg/engine/release/storages" "kusionstack.io/kusion/pkg/engine/runtime" "kusionstack.io/kusion/pkg/engine/runtime/kubernetes" - "kusionstack.io/kusion/pkg/engine/state/storages" ) -func TestOperation_Destroy(t *testing.T) { - operator := "foo-user" +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} +} - s := &apiv1.Stack{ - Name: "fake-name", +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 TestDestroyOperation_Destroy(t *testing.T) { + fakeStack := &apiv1.Stack{ + Name: "fake-project", Path: "fake-path", } - p := &apiv1.Project{ - Name: "fake-name", + fakeProject := &apiv1.Project{ + Name: "fake-stack", Path: "fake-path", - Stacks: []*apiv1.Stack{s}, + Stacks: []*apiv1.Stack{fakeStack}, } - resourceState := apiv1.Resource{ + fakeResource := apiv1.Resource{ ID: "id1", Type: runtime.Kubernetes, Attributes: map[string]interface{}{ @@ -40,45 +75,78 @@ func TestOperation_Destroy(t *testing.T) { }, DependsOn: nil, } - mf := &apiv1.Spec{Resources: []apiv1.Resource{resourceState}} + fakeSpec := &apiv1.Spec{Resources: []apiv1.Resource{fakeResource}} + fakeState := &apiv1.State{Resources: []apiv1.Resource{fakeResource}} + + loc, _ := time.LoadLocation("Asia/Shanghai") + fakeTime := time.Date(2024, 5, 20, 14, 51, 0, 0, loc) + fakeRelease := &apiv1.Release{ + Project: "fake-project", + Workspace: "fake-workspace", + Revision: 2, + Stack: "fake-stack", + Spec: fakeSpec, + State: fakeState, + Phase: apiv1.ReleasePhaseDestroying, + CreateTime: fakeTime, + ModifiedTime: fakeTime, + } + fakeDestroyRelease := &apiv1.Release{ + Project: "fake-project", + Workspace: "fake-workspace", + Revision: 2, + Stack: "fake-stack", + Spec: nil, + State: &apiv1.State{ + Resources: apiv1.Resources{}, + }, + Phase: apiv1.ReleasePhaseDestroying, + CreateTime: fakeTime, + ModifiedTime: fakeTime, + } + o := &DestroyOperation{ models.Operation{ - OperationType: models.Destroy, - StateStorage: storages.NewLocalStorage(filepath.Join("testdata", "state.yaml")), - RuntimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &kubernetes.KubernetesRuntime{}}, + OperationType: models.Destroy, + ReleaseStorage: &storages.LocalStorage{}, + RuntimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &kubernetes.KubernetesRuntime{}}, }, } - r := &DestroyRequest{ - models.Request{ - Stack: s, - Project: p, - Operator: operator, - Intent: mf, + req := &DestroyRequest{ + Request: models.Request{ + Stack: fakeStack, + Project: fakeProject, }, + Release: fakeRelease, + } + expectedRsp := &DestroyResponse{ + Release: fakeDestroyRelease, } mockey.PatchConvey("destroy success", t, func() { - mockey.Mock((*graph.ResourceNode).Execute).Return(nil).Build() - mockey.Mock((*storages.LocalStorage).Get).Return(&apiv1.DeprecatedState{Resources: []apiv1.Resource{resourceState}}, nil).Build() + mockey.Mock((*graph.ResourceNode).Execute).To(func(operation *models.Operation) v1.Status { + operation.Release = fakeDestroyRelease + return nil + }).Build() mockey.Mock(kubernetes.NewKubernetesRuntime).To(func() (runtime.Runtime, error) { return &fakerRuntime{}, nil }).Build() o.MsgCh = make(chan models.Message, 1) go readMsgCh(o.MsgCh) - st := o.Destroy(r) - assert.Nil(t, st) + rsp, status := o.Destroy(req) + assert.Equal(t, rsp, expectedRsp) + assert.Nil(t, status) }) mockey.PatchConvey("destroy failed", t, func() { mockey.Mock((*graph.ResourceNode).Execute).Return(v1.NewErrorStatus(errors.New("mock error"))).Build() - mockey.Mock((*storages.LocalStorage).Get).Return(&apiv1.DeprecatedState{Resources: []apiv1.Resource{resourceState}}, nil).Build() mockey.Mock(kubernetes.NewKubernetesRuntime).Return(&fakerRuntime{}, nil).Build() o.MsgCh = make(chan models.Message, 1) go readMsgCh(o.MsgCh) - st := o.Destroy(r) - assert.True(t, v1.IsErr(st)) + _, status := o.Destroy(req) + assert.True(t, v1.IsErr(status)) }) } @@ -94,38 +162,36 @@ func readMsgCh(ch chan models.Message) { } } -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 Test_ValidateDestroyRequest(t *testing.T) { + testcases := []struct { + name string + success bool + req *DestroyRequest + }{ + { + name: "invalid request nil request", + success: false, + req: nil, + }, + { + name: "invalid request invalid release phase", + success: false, + req: &DestroyRequest{ + Release: &apiv1.Release{ + Phase: "invalid_phase", + }, + }, + }, } -} -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, + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock valid release and spec", t, func() { + mockey.Mock(release.ValidateRelease).Return(nil).Build() + mockey.Mock(release.ValidateSpec).Return(nil).Build() + err := validateDestroyRequest(tc.req) + assert.Equal(t, tc.success, err == 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 -} diff --git a/pkg/engine/operation/diff.go b/pkg/engine/operation/diff.go deleted file mode 100644 index fff3f625..00000000 --- a/pkg/engine/operation/diff.go +++ /dev/null @@ -1,66 +0,0 @@ -package operation - -import ( - "github.com/pkg/errors" - - v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" - "kusionstack.io/kusion/pkg/engine/operation/models" - "kusionstack.io/kusion/pkg/engine/state" - "kusionstack.io/kusion/pkg/log" - "kusionstack.io/kusion/pkg/util" - "kusionstack.io/kusion/pkg/util/diff" - "kusionstack.io/kusion/pkg/util/json" - "kusionstack.io/kusion/third_party/dyff" -) - -type Diff struct { - StateStorage state.Storage -} - -type DiffRequest struct { - models.Request -} - -func (d *Diff) Diff(request *DiffRequest) (string, error) { - log.Infof("invoke Diff") - - defer func() { - if err := recover(); err != nil { - log.Error("Diff panic:%v", err) - } - }() - - util.CheckNotNil(request, "request is nil") - util.CheckNotNil(request.Intent, "resource is nil") - - // Get plan state resources - plan := request.Intent - - // Get the state resources - priorState, err := d.StateStorage.Get() - if err != nil { - return "", errors.Wrap(err, "GetLatestState failed") - } - if priorState == nil { - log.Infof("can't find states by request: %v.", json.MustMarshal2String(request)) - } - // Get diff result - return DiffWithRequestResourceAndState(plan, priorState) -} - -func DiffWithRequestResourceAndState(plan *v1.Spec, priorState *v1.DeprecatedState) (string, error) { - planString := json.MustMarshal2String(plan.Resources) - var report *dyff.Report - var err error - if priorState == nil { - report, err = diff.ToReport("", planString) - } else { - latestResources := priorState.Resources - priorString := json.MustMarshal2String(latestResources) - report, err = diff.ToReport(priorString, priorString) - } - if err != nil { - return "", err - } - return diff.ToHumanString(diff.NewHumanReport(report)) -} diff --git a/pkg/engine/operation/doc.go b/pkg/engine/operation/doc.go deleted file mode 100644 index 5b3dd5c2..00000000 --- a/pkg/engine/operation/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package operation contains code for basic operations like Apply, Preview and Destroy -package operation diff --git a/pkg/engine/operation/graph/resource_node.go b/pkg/engine/operation/graph/resource_node.go index 8b3313ec..467b6f19 100644 --- a/pkg/engine/operation/graph/resource_node.go +++ b/pkg/engine/operation/graph/resource_node.go @@ -175,7 +175,7 @@ func (rn *ResourceNode) initThreeWayDiffData(operation *models.Operation) (*apiv planedResource = nil } - // 2. get prior resource which is stored in kusion_state.json + // 2. get prior resource from the latest release key := rn.resource.ResourceKey() priorResource := operation.PriorStateResourceIndex[key] @@ -237,7 +237,7 @@ func (rn *ResourceNode) applyResource(operation *models.Operation, prior, planed } case models.UnChanged: log.Infof("planed resource and live resource are equal") - // auto import resources exist in intent and live cluster but no recorded in kusion_state.json + // auto import resources exist in intent and live cluster but not recorded in release file if prior == nil { response := rt.Import(context.Background(), &runtime.ImportRequest{PlanResource: planed}) s = response.Status @@ -255,7 +255,7 @@ func (rn *ResourceNode) applyResource(operation *models.Operation, prior, planed if e := operation.RefreshResourceIndex(key, res, rn.Action); e != nil { return v1.NewErrorStatus(e) } - if e := operation.UpdateState(operation.StateResourceIndex); e != nil { + if e := operation.UpdateReleaseState(); e != nil { return v1.NewErrorStatus(e) } diff --git a/pkg/engine/operation/graph/resource_node_test.go b/pkg/engine/operation/graph/resource_node_test.go index 28f8a511..918e6008 100644 --- a/pkg/engine/operation/graph/resource_node_test.go +++ b/pkg/engine/operation/graph/resource_node_test.go @@ -13,7 +13,6 @@ import ( "kusionstack.io/kusion/pkg/engine/operation/models" "kusionstack.io/kusion/pkg/engine/runtime" "kusionstack.io/kusion/pkg/engine/runtime/kubernetes" - "kusionstack.io/kusion/pkg/engine/state/storages" "kusionstack.io/kusion/third_party/terraform/dag" ) @@ -100,15 +99,14 @@ func TestResourceNode_Execute(t *testing.T) { }, args: args{operation: models.Operation{ OperationType: models.Apply, - StateStorage: storages.NewLocalStorage("/state.yaml"), CtxResourceIndex: priorStateResourceIndex, PriorStateResourceIndex: priorStateResourceIndex, StateResourceIndex: priorStateResourceIndex, IgnoreFields: []string{"not_exist_field"}, MsgCh: make(chan models.Message), - ResultState: apiv1.NewState(), Lock: &sync.Mutex{}, RuntimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &kubernetes.KubernetesRuntime{}}, + Release: &apiv1.Release{}, }}, want: nil, }, @@ -121,12 +119,10 @@ func TestResourceNode_Execute(t *testing.T) { }, args: args{operation: models.Operation{ OperationType: models.Apply, - StateStorage: storages.NewLocalStorage("/state.yaml"), CtxResourceIndex: priorStateResourceIndex, PriorStateResourceIndex: priorStateResourceIndex, StateResourceIndex: priorStateResourceIndex, MsgCh: make(chan models.Message), - ResultState: apiv1.NewState(), Lock: &sync.Mutex{}, RuntimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &kubernetes.KubernetesRuntime{}}, }}, @@ -141,12 +137,10 @@ func TestResourceNode_Execute(t *testing.T) { }, args: args{operation: models.Operation{ OperationType: models.Apply, - StateStorage: storages.NewLocalStorage("/state.yaml"), CtxResourceIndex: priorStateResourceIndex, PriorStateResourceIndex: priorStateResourceIndex, StateResourceIndex: priorStateResourceIndex, MsgCh: make(chan models.Message), - ResultState: apiv1.NewState(), Lock: &sync.Mutex{}, RuntimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &kubernetes.KubernetesRuntime{}}, }}, @@ -176,10 +170,7 @@ func TestResourceNode_Execute(t *testing.T) { func(k *kubernetes.KubernetesRuntime, ctx context.Context, request *runtime.ReadRequest) *runtime.ReadResponse { return &runtime.ReadResponse{Resource: request.PriorResource} }).Build() - mockey.Mock(mockey.GetMethod(tt.args.operation.StateStorage, "Apply")).To( - func(f *storages.LocalStorage, state *apiv1.DeprecatedState) error { - return nil - }).Build() + mockey.Mock((*models.Operation).UpdateReleaseState).Return(nil).Build() assert.Equalf(t, tt.want, rn.Execute(&tt.args.operation), "Execute(%v)", tt.args.operation) }) diff --git a/pkg/engine/operation/models/doc.go b/pkg/engine/operation/models/doc.go deleted file mode 100644 index e48eef83..00000000 --- a/pkg/engine/operation/models/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package models contains internal structs of operations -// todo CLI imports this package directly. We need to make this pkg internal -package models diff --git a/pkg/engine/operation/models/operation_context.go b/pkg/engine/operation/models/operation_context.go index 3c199788..32c1886c 100644 --- a/pkg/engine/operation/models/operation_context.go +++ b/pkg/engine/operation/models/operation_context.go @@ -5,14 +5,10 @@ import ( "sync" "time" - "github.com/jinzhu/copier" - - v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + apiv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/engine/release" "kusionstack.io/kusion/pkg/engine/runtime" - "kusionstack.io/kusion/pkg/engine/state" "kusionstack.io/kusion/pkg/log" - "kusionstack.io/kusion/pkg/util" - "kusionstack.io/kusion/pkg/util/json" ) // Operation is the base model for all operations @@ -20,17 +16,17 @@ type Operation struct { // OperationType represents the OperationType of this operation OperationType OperationType - // StateStorage represents the storage where state will be saved during this operation - StateStorage state.Storage + // ReleaseStorage represents the storage where state will be saved during this operation + ReleaseStorage release.Storage // CtxResourceIndex represents resources updated by this operation - CtxResourceIndex map[string]*v1.Resource + CtxResourceIndex map[string]*apiv1.Resource // PriorStateResourceIndex represents resource state saved during the last operation - PriorStateResourceIndex map[string]*v1.Resource + PriorStateResourceIndex map[string]*apiv1.Resource // StateResourceIndex represents resources that will be saved in state.Storage - StateResourceIndex map[string]*v1.Resource + StateResourceIndex map[string]*apiv1.Resource // IgnoreFields will be ignored in preview stage IgnoreFields []string @@ -39,10 +35,10 @@ type Operation struct { ChangeOrder *ChangeOrder // RuntimeMap contains all infrastructure runtimes involved this operation. The key of this map is the Runtime type - RuntimeMap map[v1.Type]runtime.Runtime + RuntimeMap map[apiv1.Type]runtime.Runtime // Stack contains info about where this command is invoked - Stack *v1.Stack + Stack *apiv1.Stack // MsgCh is used to send operation status like Success, Failed or Skip to Kusion CTl, // and this message will be displayed in the terminal @@ -51,8 +47,8 @@ type Operation struct { // Lock is the operation-wide mutex Lock *sync.Mutex - // ResultState is the final DeprecatedState build by this operation, and this DeprecatedState will be saved in the StateStorage - ResultState *v1.DeprecatedState + // Release is the release updated in this operation, and saved in the ReleaseStorage + Release *apiv1.Release } type Message struct { @@ -62,10 +58,8 @@ type Message struct { } type Request struct { - Project *v1.Project `json:"project"` - Stack *v1.Stack `json:"stack"` - Operator string `json:"operator"` - Intent *v1.Spec `json:"intent"` + Project *apiv1.Project + Stack *apiv1.Stack } type OpResult string @@ -78,7 +72,7 @@ const ( ) // RefreshResourceIndex refresh resources in CtxResourceIndex & StateResourceIndex -func (o *Operation) RefreshResourceIndex(resourceKey string, resource *v1.Resource, actionType ActionType) error { +func (o *Operation) RefreshResourceIndex(resourceKey string, resource *apiv1.Resource, actionType ActionType) error { o.Lock.Lock() defer o.Lock.Unlock() @@ -95,52 +89,26 @@ func (o *Operation) RefreshResourceIndex(resourceKey string, resource *v1.Resour return nil } -func (o *Operation) InitStates(request *Request) (*v1.DeprecatedState, *v1.DeprecatedState) { - priorState, err := o.StateStorage.Get() - util.CheckNotError(err, fmt.Sprintf("get state failed with request: %v", json.Marshal2PrettyString(request))) - if priorState == nil { - log.Infof("can't find state with request: %v", json.Marshal2PrettyString(request)) - priorState = v1.NewState() - } - resultState := v1.NewState() - resultState.Serial = priorState.Serial - err = copier.Copy(resultState, request) - util.CheckNotError(err, fmt.Sprintf("copy request to result DeprecatedState failed, request:%v", json.Marshal2PrettyString(request))) - resultState.Stack = request.Stack.Name - resultState.Project = request.Project.Name - - resultState.Resources = nil - - return priorState, resultState -} - -func (o *Operation) UpdateState(resourceIndex map[string]*v1.Resource) error { +func (o *Operation) UpdateReleaseState() error { o.Lock.Lock() defer o.Lock.Unlock() - resultState := o.ResultState - resultState.Serial += 1 - resultState.Resources = nil - - res := make([]v1.Resource, 0, len(resourceIndex)) - for key := range resourceIndex { + res := make([]apiv1.Resource, 0, len(o.StateResourceIndex)) + for key := range o.StateResourceIndex { // {key -> nil} represents Deleted action - if resourceIndex[key] == nil { + if o.StateResourceIndex[key] == nil { continue } - res = append(res, *resourceIndex[key]) + res = append(res, *o.StateResourceIndex[key]) } - resultState.Resources = res - now := time.Now() - if resultState.CreateTime.IsZero() { - resultState.CreateTime = now - } - resultState.ModifiedTime = now - err := o.StateStorage.Apply(resultState) + o.Release.State.Resources = res + o.Release.ModifiedTime = time.Now() + + err := o.ReleaseStorage.Update(o.Release) if err != nil { - return fmt.Errorf("apply DeprecatedState failed. %w", err) + return fmt.Errorf("udpate release failed, %w", err) } - log.Infof("update DeprecatedState:%v success", resultState.ID) + log.Infof("update release succeeded, project %s, workspace %s, revision %d", o.Release.Project, o.Release.Workspace, o.Release.Revision) return nil } diff --git a/pkg/engine/operation/parser/spec_parser.go b/pkg/engine/operation/parser/spec_parser.go index 0c079390..1ea4fc7b 100644 --- a/pkg/engine/operation/parser/spec_parser.go +++ b/pkg/engine/operation/parser/spec_parser.go @@ -12,19 +12,19 @@ import ( "kusionstack.io/kusion/third_party/terraform/dag" ) -type IntentParser struct { - intent *apiv1.Spec +type SpecParser struct { + spec *apiv1.Spec } -func NewIntentParser(i *apiv1.Spec) *IntentParser { - return &IntentParser{intent: i} +func NewIntentParser(i *apiv1.Spec) *SpecParser { + return &SpecParser{spec: i} } -var _ Parser = (*IntentParser)(nil) +var _ Parser = (*SpecParser)(nil) -func (m *IntentParser) Parse(g *dag.AcyclicGraph) (s v1.Status) { +func (m *SpecParser) Parse(g *dag.AcyclicGraph) (s v1.Status) { util.CheckNotNil(g, "dag is nil") - i := m.intent + i := m.spec util.CheckNotNil(i, "models is nil") if i.Resources == nil { sprintf := fmt.Sprintf("no resources in models:%s", json.Marshal2String(i)) diff --git a/pkg/engine/operation/parser/spec_parser_test.go b/pkg/engine/operation/parser/spec_parser_test.go index c77d0524..5b013e03 100644 --- a/pkg/engine/operation/parser/spec_parser_test.go +++ b/pkg/engine/operation/parser/spec_parser_test.go @@ -43,8 +43,8 @@ func TestSpecParser_Parse(t *testing.T) { ag := &dag.AcyclicGraph{} ag.Add(&graph.RootNode{}) - i := &IntentParser{ - intent: mf, + i := &SpecParser{ + spec: mf, } _ = i.Parse(ag) diff --git a/pkg/engine/operation/port_forward.go b/pkg/engine/operation/port_forward.go index f71889a9..ba1da1c5 100644 --- a/pkg/engine/operation/port_forward.go +++ b/pkg/engine/operation/port_forward.go @@ -18,9 +18,11 @@ import ( "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/portforward" "k8s.io/client-go/transport/spdy" + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" "kusionstack.io/kusion/pkg/engine/operation/models" "kusionstack.io/kusion/pkg/engine/printers/convertor" + "kusionstack.io/kusion/pkg/engine/release" "kusionstack.io/kusion/pkg/engine/runtime/kubernetes/kubeops" ) @@ -35,21 +37,22 @@ type PortForwardOperation struct { } type PortForwardRequest struct { - models.Request `json:",inline" yaml:",inline"` - Port int + models.Request + Spec *v1.Spec + Port int } func (bpo *PortForwardOperation) PortForward(req *PortForwardRequest) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - if req.Intent == nil { - return ErrEmptySpec + if err := validatePortForwardRequest(req); err != nil { + return err } // Find Kubernetes Service in the resources of Spec. services := make(map[*v1.Resource]*corev1.Service) - for _, res := range req.Intent.Resources { + for _, res := range req.Spec.Resources { // Skip non-Kubernetes resources. if res.Type != v1.Kubernetes { continue @@ -127,7 +130,7 @@ func (bpo *PortForwardOperation) PortForward(req *PortForwardRequest) error { } go func() { - err := ForwardPort(ctx, cfg, clientset, namespace, serviceName, servicePort, servicePort) + err = ForwardPort(ctx, cfg, clientset, namespace, serviceName, servicePort, servicePort) failed <- err }() } @@ -195,3 +198,16 @@ func ForwardPort( return fw.ForwardPorts() } + +func validatePortForwardRequest(req *PortForwardRequest) error { + if req == nil { + return errors.New("request is nil") + } + if err := release.ValidateSpec(req.Spec); err != nil { + return err + } + if req.Port < 0 || req.Port > 65535 { + return fmt.Errorf("invalid port %d", req.Port) + } + return nil +} diff --git a/pkg/engine/operation/port_forward_test.go b/pkg/engine/operation/port_forward_test.go index 6bdb47a7..6a44719c 100644 --- a/pkg/engine/operation/port_forward_test.go +++ b/pkg/engine/operation/port_forward_test.go @@ -4,38 +4,36 @@ import ( "testing" "github.com/stretchr/testify/assert" + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" - "kusionstack.io/kusion/pkg/engine/operation/models" ) func TestPortForwardOperation_PortForward(t *testing.T) { testcases := []struct { name string req *PortForwardRequest - expectedErr error + expectedErr bool }{ { name: "empty spec", req: &PortForwardRequest{ Port: 8080, }, - expectedErr: ErrEmptySpec, + expectedErr: true, }, { name: "empty services", req: &PortForwardRequest{ - Request: models.Request{ - Intent: &v1.Spec{ - Resources: v1.Resources{ - { - ID: "v1:Namespace:quickstart", - Type: "Kubernetes", - Attributes: map[string]interface{}{ - "apiVersion": "v1", - "kind": "Namespace", - "metadata": map[string]interface{}{ - "name": "quickstart", - }, + Spec: &v1.Spec{ + Resources: v1.Resources{ + { + ID: "v1:Namespace:quickstart", + Type: "Kubernetes", + Attributes: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Namespace", + "metadata": map[string]interface{}{ + "name": "quickstart", }, }, }, @@ -43,38 +41,36 @@ func TestPortForwardOperation_PortForward(t *testing.T) { }, Port: 8080, }, - expectedErr: ErrEmptyService, + expectedErr: true, }, { name: "not one service with target port", req: &PortForwardRequest{ - Request: models.Request{ - Intent: &v1.Spec{ - Resources: v1.Resources{ - { - ID: "v1:Service:quickstart:quickstart-dev-quickstart-private", - Type: "Kubernetes", - Attributes: map[string]interface{}{ - "apiVersion": "v1", - "kind": "Service", - "metadata": map[string]interface{}{ - "name": "quickstart-dev-quickstart-private", - "namespace": "quickstart", - }, - "spec": map[string]interface{}{ - "ports": []interface{}{ - map[string]interface{}{ - "name": "quickstart-dev-quickstart-private-8080-tcp", - "port": 8888, - "protocol": "TCP", - "targetPort": 8888, - }, - }, - "selector": map[string]interface{}{ - "app.kubernetes.io/name": "quickstart", + Spec: &v1.Spec{ + Resources: v1.Resources{ + { + ID: "v1:Service:quickstart:quickstart-dev-quickstart-private", + Type: "Kubernetes", + Attributes: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": "quickstart-dev-quickstart-private", + "namespace": "quickstart", + }, + "spec": map[string]interface{}{ + "ports": []interface{}{ + map[string]interface{}{ + "name": "quickstart-dev-quickstart-private-8080-tcp", + "port": 8888, + "protocol": "TCP", + "targetPort": 8888, }, - "type": "ClusterIP", }, + "selector": map[string]interface{}{ + "app.kubernetes.io/name": "quickstart", + }, + "type": "ClusterIP", }, }, }, @@ -82,7 +78,7 @@ func TestPortForwardOperation_PortForward(t *testing.T) { }, Port: 8080, }, - expectedErr: ErrNotOneSvcWithTargetPort, + expectedErr: true, }, } @@ -91,8 +87,8 @@ func TestPortForwardOperation_PortForward(t *testing.T) { bpo := &PortForwardOperation{} err := bpo.PortForward(tc.req) - if tc.expectedErr != nil { - assert.ErrorContains(t, err, tc.expectedErr.Error()) + if tc.expectedErr { + assert.Error(t, err) } else { assert.NoError(t, err) } diff --git a/pkg/engine/operation/preview.go b/pkg/engine/operation/preview.go index bf935310..48ba2477 100644 --- a/pkg/engine/operation/preview.go +++ b/pkg/engine/operation/preview.go @@ -9,6 +9,7 @@ import ( v1 "kusionstack.io/kusion/pkg/apis/status/v1" "kusionstack.io/kusion/pkg/engine/operation/graph" models "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/release" runtimeinit "kusionstack.io/kusion/pkg/engine/runtime/init" "kusionstack.io/kusion/pkg/log" "kusionstack.io/kusion/third_party/terraform/dag" @@ -20,7 +21,9 @@ type PreviewOperation struct { } type PreviewRequest struct { - models.Request `json:",inline" yaml:",inline"` + models.Request + Spec *apiv1.Spec + State *apiv1.State } type PreviewResponse struct { @@ -29,7 +32,7 @@ type PreviewResponse struct { // Preview compute all changes between resources in request and the actual infrastructure. // The whole process is similar to the operation Apply, but the execution of each node is mocked and will not actually invoke the Runtime -func (po *PreviewOperation) Preview(request *PreviewRequest) (rsp *PreviewResponse, s v1.Status) { +func (po *PreviewOperation) Preview(req *PreviewRequest) (rsp *PreviewResponse, s v1.Status) { o := po.Operation defer func() { @@ -47,21 +50,15 @@ func (po *PreviewOperation) Preview(request *PreviewRequest) (rsp *PreviewRespon } }() - if s := validateRequest(&request.Request); v1.IsErr(s) { + if s = validatePreviewRequest(req); v1.IsErr(s) { return nil, s } - var ( - priorState, resultState *apiv1.DeprecatedState - priorStateResourceIndex map[string]*apiv1.Resource - ag *dag.AcyclicGraph - ) - // 1. init & build Indexes - priorState, resultState = po.InitStates(&request.Request) + priorState := req.State // Kusion is a multi-runtime system. We initialize runtimes dynamically by resource types - resources := request.Intent.Resources + resources := req.Spec.Resources resources = append(resources, priorState.Resources...) runtimesMap, s := runtimeinit.Runtimes(resources) if v1.IsErr(s) { @@ -69,14 +66,18 @@ func (po *PreviewOperation) Preview(request *PreviewRequest) (rsp *PreviewRespon } o.RuntimeMap = runtimesMap + var ( + priorStateResourceIndex map[string]*apiv1.Resource + ag *dag.AcyclicGraph + ) switch o.OperationType { case models.ApplyPreview: priorStateResourceIndex = priorState.Resources.Index() - ag, s = NewApplyGraph(request.Intent, priorState) + ag, s = newApplyGraph(req.Spec, priorState) case models.DestroyPreview: - resources := request.Request.Intent.Resources + resources = req.Spec.Resources priorStateResourceIndex = resources.Index() - ag, s = NewDestroyGraph(resources) + ag, s = newDestroyGraph(resources) } if v1.IsErr(s) { return nil, s @@ -93,7 +94,7 @@ func (po *PreviewOperation) Preview(request *PreviewRequest) (rsp *PreviewRespon previewOperation := &PreviewOperation{ Operation: models.Operation{ OperationType: o.OperationType, - StateStorage: o.StateStorage, + ReleaseStorage: o.ReleaseStorage, CtxResourceIndex: map[string]*apiv1.Resource{}, PriorStateResourceIndex: priorStateResourceIndex, StateResourceIndex: stateResourceIndex, @@ -101,7 +102,6 @@ func (po *PreviewOperation) Preview(request *PreviewRequest) (rsp *PreviewRespon ChangeOrder: o.ChangeOrder, RuntimeMap: o.RuntimeMap, Stack: o.Stack, - ResultState: resultState, Lock: &sync.Mutex{}, }, } @@ -131,3 +131,13 @@ func (po *PreviewOperation) previewWalkFun(v dag.Vertex) (diags tfdiags.Diagnost } return nil } + +func validatePreviewRequest(req *PreviewRequest) v1.Status { + if req == nil { + return v1.NewErrorStatusWithMsg(v1.InvalidArgument, "request is nil") + } + if err := release.ValidateSpec(req.Spec); err != nil { + return v1.NewErrorStatusWithMsg(v1.InvalidArgument, err.Error()) + } + return nil +} diff --git a/pkg/engine/operation/preview_test.go b/pkg/engine/operation/preview_test.go index e0d12877..e9903540 100644 --- a/pkg/engine/operation/preview_test.go +++ b/pkg/engine/operation/preview_test.go @@ -2,7 +2,6 @@ package operation import ( "context" - "os" "reflect" "sync" "testing" @@ -12,37 +11,13 @@ import ( apiv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" v1 "kusionstack.io/kusion/pkg/apis/status/v1" "kusionstack.io/kusion/pkg/engine/operation/models" + "kusionstack.io/kusion/pkg/engine/release" + "kusionstack.io/kusion/pkg/engine/release/storages" "kusionstack.io/kusion/pkg/engine/runtime" runtimeinit "kusionstack.io/kusion/pkg/engine/runtime/init" - "kusionstack.io/kusion/pkg/engine/state" - "kusionstack.io/kusion/pkg/engine/state/storages" "kusionstack.io/kusion/pkg/util/json" ) -var ( - FakeService = map[string]interface{}{ - "apiVersion": "v1", - "kind": "Service", - "metadata": map[string]interface{}{ - "name": "apple-service", - "namespace": "http-echo", - }, - "models": map[string]interface{}{ - "type": "NodePort", - }, - } - FakeResourceState = apiv1.Resource{ - ID: "fake-id", - Type: runtime.Kubernetes, - Attributes: FakeService, - } - FakeResourceState2 = apiv1.Resource{ - ID: "fake-id-2", - Type: runtime.Kubernetes, - Attributes: FakeService, - } -) - var _ runtime.Runtime = (*fakePreviewRuntime)(nil) type fakePreviewRuntime struct{} @@ -83,61 +58,82 @@ func (f *fakePreviewRuntime) Watch(_ context.Context, _ *runtime.WatchRequest) * return nil } -func TestOperation_Preview(t *testing.T) { - defer func() { - _ = os.Remove("state.yaml") - }() +func TestPreviewOperation_Preview(t *testing.T) { type fields struct { - OperationType models.OperationType - StateStorage state.Storage - CtxResourceIndex map[string]*apiv1.Resource - PriorStateResourceIndex map[string]*apiv1.Resource - StateResourceIndex map[string]*apiv1.Resource - Order *models.ChangeOrder - RuntimeMap map[apiv1.Type]runtime.Runtime - MsgCh chan models.Message - resultState *apiv1.DeprecatedState + operationType models.OperationType + releaseStorage release.Storage + ctxResourceIndex map[string]*apiv1.Resource + priorStateResourceIndex map[string]*apiv1.Resource + stateResourceIndex map[string]*apiv1.Resource + order *models.ChangeOrder + runtimeMap map[apiv1.Type]runtime.Runtime + msgCh chan models.Message + release *apiv1.Release lock *sync.Mutex } type args struct { - request *PreviewRequest + req *PreviewRequest } - s := &apiv1.Stack{ - Name: "fake-name", + + fakeStack := &apiv1.Stack{ + Name: "fake-stack", Path: "fake-path", } - p := &apiv1.Project{ - Name: "fake-name", + fakeProject := &apiv1.Project{ + Name: "fake-project", Path: "fake-path", - Stacks: []*apiv1.Stack{s}, + Stacks: []*apiv1.Stack{fakeStack}, + } + fakeService := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Service", + "metadata": map[string]interface{}{ + "name": "apple-service", + "namespace": "http-echo", + }, + "models": map[string]interface{}{ + "type": "NodePort", + }, + } + fakeResource := apiv1.Resource{ + ID: "fake-id", + Type: runtime.Kubernetes, + Attributes: fakeService, + } + fakeResource2 := apiv1.Resource{ + ID: "fake-id-2", + Type: runtime.Kubernetes, + Attributes: fakeService, } - tests := []struct { + + testcases := []struct { name string fields fields args args wantRsp *PreviewResponse - wantS v1.Status + wantErr bool }{ { name: "success-when-apply", fields: fields{ - OperationType: models.ApplyPreview, - RuntimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &fakePreviewRuntime{}}, - StateStorage: storages.NewLocalStorage("state.yaml"), - Order: &models.ChangeOrder{StepKeys: []string{}, ChangeSteps: map[string]*models.ChangeStep{}}, + operationType: models.ApplyPreview, + runtimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &fakePreviewRuntime{}}, + releaseStorage: &storages.LocalStorage{}, + order: &models.ChangeOrder{ + StepKeys: []string{}, + ChangeSteps: map[string]*models.ChangeStep{}, + }, }, args: args{ - request: &PreviewRequest{ + req: &PreviewRequest{ Request: models.Request{ - Stack: s, - Project: p, - Operator: "fake-operator", - Intent: &apiv1.Spec{ - Resources: []apiv1.Resource{ - FakeResourceState, - }, - }, + Stack: fakeStack, + Project: fakeProject, }, + Spec: &apiv1.Spec{ + Resources: apiv1.Resources{fakeResource}, + }, + State: &apiv1.State{}, }, }, wantRsp: &PreviewResponse{ @@ -148,33 +144,31 @@ func TestOperation_Preview(t *testing.T) { ID: "fake-id", Action: models.Create, From: (*apiv1.Resource)(nil), - To: &FakeResourceState, + To: &fakeResource, }, }, }, }, - wantS: nil, + wantErr: false, }, { name: "success-when-destroy", fields: fields{ - OperationType: models.DestroyPreview, - RuntimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &fakePreviewRuntime{}}, - StateStorage: storages.NewLocalStorage("state.yaml"), - Order: &models.ChangeOrder{}, + operationType: models.DestroyPreview, + runtimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &fakePreviewRuntime{}}, + releaseStorage: &storages.LocalStorage{}, + order: &models.ChangeOrder{}, }, args: args{ - request: &PreviewRequest{ + req: &PreviewRequest{ Request: models.Request{ - Stack: s, - Project: p, - Operator: "fake-operator", - Intent: &apiv1.Spec{ - Resources: []apiv1.Resource{ - FakeResourceState2, - }, - }, + Stack: fakeStack, + Project: fakeProject, }, + Spec: &apiv1.Spec{ + Resources: apiv1.Resources{fakeResource2}, + }, + State: &apiv1.State{}, }, }, wantRsp: &PreviewResponse{ @@ -184,77 +178,77 @@ func TestOperation_Preview(t *testing.T) { "fake-id-2": { ID: "fake-id-2", Action: models.Delete, - From: &FakeResourceState2, + From: &fakeResource2, To: (*apiv1.Resource)(nil), }, }, }, }, - wantS: nil, + wantErr: false, }, { name: "fail-because-empty-models", fields: fields{ - OperationType: models.ApplyPreview, - RuntimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &fakePreviewRuntime{}}, - StateStorage: storages.NewLocalStorage("state.yaml"), - Order: &models.ChangeOrder{}, + operationType: models.ApplyPreview, + runtimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &fakePreviewRuntime{}}, + releaseStorage: &storages.LocalStorage{}, + order: &models.ChangeOrder{}, }, args: args{ - request: &PreviewRequest{ - Request: models.Request{ - Intent: nil, - }, + req: &PreviewRequest{ + Spec: nil, + State: &apiv1.State{}, }, }, wantRsp: nil, - wantS: v1.NewErrorStatusWithMsg(v1.InvalidArgument, "request.Intent is empty. If you want to delete all resources, please use command 'destroy'"), + wantErr: true, }, { name: "fail-because-nonexistent-id", fields: fields{ - OperationType: models.ApplyPreview, - RuntimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &fakePreviewRuntime{}}, - StateStorage: storages.NewLocalStorage("state.yaml"), - Order: &models.ChangeOrder{}, + operationType: models.ApplyPreview, + runtimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &fakePreviewRuntime{}}, + releaseStorage: &storages.LocalStorage{}, + order: &models.ChangeOrder{}, }, args: args{ - request: &PreviewRequest{ + req: &PreviewRequest{ Request: models.Request{ - Stack: s, - Project: p, - Operator: "fake-operator", - Intent: &apiv1.Spec{ - Resources: []apiv1.Resource{ - { - ID: "fake-id", - Type: runtime.Kubernetes, - Attributes: FakeService, - DependsOn: []string{"nonexistent-id"}, - }, + Stack: fakeStack, + Project: fakeProject, + }, + Spec: &apiv1.Spec{ + Resources: []apiv1.Resource{ + { + ID: "fake-id", + Type: runtime.Kubernetes, + Attributes: fakeService, + DependsOn: []string{"nonexistent-id"}, }, }, }, + State: &apiv1.State{}, }, }, wantRsp: nil, - wantS: v1.NewErrorStatusWithMsg(v1.IllegalManifest, "can't find resource by key:nonexistent-id in models or state."), + wantErr: true, }, } - for _, tt := range tests { - mockey.PatchConvey(tt.name, t, func() { + + for _, tc := range testcases { + mockey.PatchConvey(tc.name, t, func() { o := &PreviewOperation{ Operation: models.Operation{ - OperationType: tt.fields.OperationType, - StateStorage: tt.fields.StateStorage, - CtxResourceIndex: tt.fields.CtxResourceIndex, - PriorStateResourceIndex: tt.fields.PriorStateResourceIndex, - StateResourceIndex: tt.fields.StateResourceIndex, - ChangeOrder: tt.fields.Order, - RuntimeMap: tt.fields.RuntimeMap, - MsgCh: tt.fields.MsgCh, - ResultState: tt.fields.resultState, - Lock: tt.fields.lock, + OperationType: tc.fields.operationType, + ReleaseStorage: tc.fields.releaseStorage, + CtxResourceIndex: tc.fields.ctxResourceIndex, + PriorStateResourceIndex: tc.fields.priorStateResourceIndex, + StateResourceIndex: tc.fields.stateResourceIndex, + ChangeOrder: tc.fields.order, + RuntimeMap: tc.fields.runtimeMap, + MsgCh: tc.fields.msgCh, + Release: tc.fields.release, + Lock: tc.fields.lock, }, } @@ -263,12 +257,13 @@ func TestOperation_Preview(t *testing.T) { ) (map[apiv1.Type]runtime.Runtime, v1.Status) { return map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: &fakePreviewRuntime{}}, nil }).Build() - gotRsp, gotS := o.Preview(tt.args.request) - if !reflect.DeepEqual(gotRsp, tt.wantRsp) { - t.Errorf("Operation.Preview() gotRsp = %v, want %v", json.Marshal2PrettyString(gotRsp), json.Marshal2PrettyString(tt.wantRsp)) + + gotRsp, gotS := o.Preview(tc.args.req) + if !reflect.DeepEqual(gotRsp, tc.wantRsp) { + t.Errorf("Operation.Preview() gotRsp = %v, want %v", json.Marshal2PrettyString(gotRsp), json.Marshal2PrettyString(tc.wantRsp)) } - if !reflect.DeepEqual(gotS, tt.wantS) { - t.Errorf("Operation.Preview() gotS = %v, want %v", gotS, tt.wantS) + if tc.wantErr && gotS == nil || !tc.wantErr && gotS != nil { + t.Errorf("Operation.Preview() gotS = %v, wantErr %v", gotS, tc.wantErr) } }) } diff --git a/pkg/engine/operation/testdata/state.yaml b/pkg/engine/operation/testdata/state.yaml deleted file mode 100755 index e69de29b..00000000 diff --git a/pkg/engine/operation/watch.go b/pkg/engine/operation/watch.go index 84e3c5bc..45194581 100644 --- a/pkg/engine/operation/watch.go +++ b/pkg/engine/operation/watch.go @@ -12,10 +12,12 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/watch" + apiv1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" v1 "kusionstack.io/kusion/pkg/apis/status/v1" "kusionstack.io/kusion/pkg/engine" "kusionstack.io/kusion/pkg/engine/operation/models" "kusionstack.io/kusion/pkg/engine/printers" + "kusionstack.io/kusion/pkg/engine/release" "kusionstack.io/kusion/pkg/engine/runtime" runtimeinit "kusionstack.io/kusion/pkg/engine/runtime/init" "kusionstack.io/kusion/pkg/log" @@ -27,15 +29,20 @@ type WatchOperation struct { } type WatchRequest struct { - models.Request `json:",inline" yaml:",inline"` + models.Request + Spec *apiv1.Spec } func (wo *WatchOperation) Watch(req *WatchRequest) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + if err := validateWatchRequest(req); err != nil { + return err + } + // init runtimes - resources := req.Intent.Resources + resources := req.Spec.Resources runtimes, s := runtimeinit.Runtimes(resources) if v1.IsErr(s) { return errors.New(s.Message()) @@ -195,6 +202,16 @@ func (wo *WatchOperation) printTables(w *uilive.Writer, ids []string, tables map _ = w.Flush() } +func validateWatchRequest(req *WatchRequest) error { + if req == nil { + return errors.New("request is nil") + } + if err := release.ValidateSpec(req.Spec); err != nil { + return err + } + return nil +} + func createSelectCases(chs []<-chan watch.Event) []reflect.SelectCase { cases := make([]reflect.SelectCase, 0, len(chs)) for _, ch := range chs { diff --git a/pkg/engine/operation/watch_test.go b/pkg/engine/operation/watch_test.go index 9ce447f1..458f06b9 100644 --- a/pkg/engine/operation/watch_test.go +++ b/pkg/engine/operation/watch_test.go @@ -16,32 +16,6 @@ import ( runtimeinit "kusionstack.io/kusion/pkg/engine/runtime/init" ) -func TestWatchOperation_Watch(t *testing.T) { - mockey.PatchConvey("test watch operation: watch", t, func() { - req := &WatchRequest{ - Request: models.Request{ - Intent: &apiv1.Spec{ - Resources: apiv1.Resources{ - { - ID: "apps/v1:Deployment:foo:bar", - Type: runtime.Kubernetes, - Attributes: barDeployment, - }, - }, - }, - }, - } - mockey.Mock(runtimeinit.Runtimes).To(func( - resources apiv1.Resources, - ) (map[apiv1.Type]runtime.Runtime, v1.Status) { - return map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: fooRuntime}, nil - }).Build() - wo := &WatchOperation{models.Operation{RuntimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: fooRuntime}}} - err := wo.Watch(req) - assert.Nil(t, err) - }) -} - var barDeployment = map[string]interface{}{ "apiVersion": "apps/v1", "kind": "Deployment", @@ -105,3 +79,27 @@ func (f *fooWatchRuntime) Watch(_ context.Context, _ *runtime.WatchRequest) *run Status: nil, } } + +func TestWatchOperation_Watch(t *testing.T) { + mockey.PatchConvey("test watch operation: watch", t, func() { + req := &WatchRequest{ + Spec: &apiv1.Spec{ + Resources: apiv1.Resources{ + { + ID: "apps/v1:Deployment:foo:bar", + Type: runtime.Kubernetes, + Attributes: barDeployment, + }, + }, + }, + } + mockey.Mock(runtimeinit.Runtimes).To(func( + resources apiv1.Resources, + ) (map[apiv1.Type]runtime.Runtime, v1.Status) { + return map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: fooRuntime}, nil + }).Build() + wo := &WatchOperation{models.Operation{RuntimeMap: map[apiv1.Type]runtime.Runtime{runtime.Kubernetes: fooRuntime}}} + err := wo.Watch(req) + assert.Nil(t, err) + }) +} diff --git a/pkg/engine/release/util.go b/pkg/engine/release/util.go new file mode 100644 index 00000000..ac0ca698 --- /dev/null +++ b/pkg/engine/release/util.go @@ -0,0 +1,135 @@ +package release + +import ( + "fmt" + "time" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/log" +) + +// GetLatestState returns the latest state. If no release exists, return nil. +func GetLatestState(storage Storage) (*v1.State, error) { + revision := storage.GetLatestRevision() + if revision == 0 { + return nil, nil + } + r, err := storage.Get(revision) + if err != nil { + return nil, err + } + return r.State, err +} + +// NewApplyRelease news a release object for apply operation, but no creation in the storage. +func NewApplyRelease(storage Storage, project, stack, workspace string) (*v1.Release, error) { + revision := storage.GetLatestRevision() + + var rel *v1.Release + currentTime := time.Now() + if revision == 0 { + rel = &v1.Release{ + Project: project, + Workspace: workspace, + Revision: revision + 1, + Stack: stack, + State: &v1.State{}, + Phase: v1.ReleasePhaseGenerating, + CreateTime: currentTime, + ModifiedTime: currentTime, + } + } else { + lastRelease, err := storage.Get(revision) + if err != nil { + return nil, err + } + if lastRelease.Phase != v1.ReleasePhaseSucceeded && lastRelease.Phase != v1.ReleasePhaseFailed { + return nil, fmt.Errorf("cannot new release of project %s, workspace %s cause there is release in progress", project, workspace) + } + if err != nil { + return nil, err + } + rel = &v1.Release{ + Project: project, + Workspace: workspace, + Revision: revision + 1, + Stack: stack, + State: lastRelease.State, + Phase: v1.ReleasePhaseGenerating, + CreateTime: currentTime, + ModifiedTime: currentTime, + } + } + + return rel, nil +} + +// UpdateApplyRelease updates the release in the storage if dryRun is false. If release phase is failed, +// only logging with no error return. +func UpdateApplyRelease(storage Storage, rel *v1.Release, dryRun bool) error { + if dryRun { + return nil + } + rel.ModifiedTime = time.Now() + err := storage.Update(rel) + if rel.Phase == v1.ReleasePhaseFailed && err != nil { + log.Errorf("failed update release phase to Failed, project %s, workspace %s, revision %d", rel.Project, rel.Workspace, rel.Revision) + return nil + } + return err +} + +// CreateDestroyRelease creates a release object in the storage for destroy operation. +func CreateDestroyRelease(storage Storage, project, stack, workspace string) (*v1.Release, error) { + revision := storage.GetLatestRevision() + if revision == 0 { + return nil, fmt.Errorf("cannot find release of project %s, workspace %s", project, workspace) + } + + lastRelease, err := storage.Get(revision) + if err != nil { + return nil, err + } + if lastRelease.Phase != v1.ReleasePhaseSucceeded && lastRelease.Phase != v1.ReleasePhaseFailed { + return nil, fmt.Errorf("cannot create release of project %s, workspace %s cause there is release in progress", project, workspace) + } + + resources := make([]v1.Resource, len(lastRelease.State.Resources)) + copy(resources, lastRelease.State.Resources) + spec := &v1.Spec{Resources: resources} + // if no resource managed, set phase to Succeeded directly. + phase := v1.ReleasePhasePreviewing + if len(resources) == 0 { + phase = v1.ReleasePhaseSucceeded + } + currentTime := time.Now() + rel := &v1.Release{ + Project: project, + Workspace: workspace, + Revision: revision + 1, + Stack: stack, + Spec: spec, + State: lastRelease.State, + Phase: phase, + CreateTime: currentTime, + ModifiedTime: currentTime, + } + + if err = storage.Create(rel); err != nil { + return nil, fmt.Errorf("create release of project %s workspace %s revision %d failed", project, workspace, rel.Revision) + } + + return rel, nil +} + +// UpdateDestroyRelease updates the release in the storage. If release phase is failed, only logging with +// no error return. +func UpdateDestroyRelease(storage Storage, rel *v1.Release) error { + rel.ModifiedTime = time.Now() + err := storage.Update(rel) + if rel.Phase == v1.ReleasePhaseFailed && err != nil { + log.Errorf("failed update release phase to Failed, project %s, workspace %s, revision %d", rel.Project, rel.Workspace, rel.Revision) + return nil + } + return err +} diff --git a/pkg/engine/release/util_test.go b/pkg/engine/release/util_test.go new file mode 100644 index 00000000..695a8b5c --- /dev/null +++ b/pkg/engine/release/util_test.go @@ -0,0 +1,49 @@ +package release + +import ( + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/engine/release/storages" +) + +func mockReleaseStorageOperation(revision uint64) { + mockey.Mock((*storages.LocalStorage).GetLatestRevision).Return(revision).Build() + mockey.Mock((*storages.LocalStorage).Get).Return(&v1.Release{State: &v1.State{}}, nil).Build() +} + +func TestGetLatestState(t *testing.T) { + testcases := []struct { + name string + success bool + revision uint64 + expectedNilState bool + }{ + { + name: "nil release", + success: true, + revision: 0, + expectedNilState: true, + }, + { + name: "not nil release", + success: true, + revision: 1, + expectedNilState: false, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + mockey.PatchConvey("mock s3 operation", t, func() { + mockReleaseStorageOperation(tc.revision) + state, err := GetLatestState(&storages.LocalStorage{}) + assert.Equal(t, tc.success, err == nil) + assert.Equal(t, tc.expectedNilState, state == nil) + }) + }) + } +} diff --git a/pkg/engine/release/validation.go b/pkg/engine/release/validation.go index faa556ed..55bad4ee 100644 --- a/pkg/engine/release/validation.go +++ b/pkg/engine/release/validation.go @@ -37,7 +37,10 @@ func ValidateRelease(r *v1.Release) error { if r.Stack == "" { return ErrEmptyStack } - if err := ValidateState(r.State); err != nil { + if err := ValidateSpec(r.Spec); err != nil { + return err + } + if err := validateState(r.State); err != nil { return err } if r.Phase == "" { @@ -62,7 +65,7 @@ func ValidateSpec(spec *v1.Spec) error { return nil } -func ValidateState(state *v1.State) error { +func validateState(state *v1.State) error { if state == nil { return ErrEmptyState } diff --git a/pkg/engine/release/validation_test.go b/pkg/engine/release/validation_test.go index 30fac222..6e6612c2 100644 --- a/pkg/engine/release/validation_test.go +++ b/pkg/engine/release/validation_test.go @@ -57,6 +57,7 @@ func TestValidateRelease(t *testing.T) { Workspace: "fake-ws", Revision: 1, Stack: "fake-stack", + Spec: &v1.Spec{Resources: v1.Resources{mockResource()}}, State: &v1.State{Resources: v1.Resources{mockResource()}}, Phase: v1.ReleasePhaseApplying, CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), @@ -71,6 +72,7 @@ func TestValidateRelease(t *testing.T) { Workspace: "fake-ws", Revision: 1, Stack: "fake-stack", + Spec: &v1.Spec{Resources: v1.Resources{mockResource()}}, State: &v1.State{Resources: v1.Resources{mockResource()}}, Phase: v1.ReleasePhaseApplying, CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), @@ -85,6 +87,7 @@ func TestValidateRelease(t *testing.T) { Workspace: "", Revision: 1, Stack: "fake-stack", + Spec: &v1.Spec{Resources: v1.Resources{mockResource()}}, State: &v1.State{Resources: v1.Resources{mockResource()}}, Phase: v1.ReleasePhaseApplying, CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), @@ -99,6 +102,7 @@ func TestValidateRelease(t *testing.T) { Workspace: "fake-ws", Revision: 0, Stack: "fake-stack", + Spec: &v1.Spec{Resources: v1.Resources{mockResource()}}, State: &v1.State{Resources: v1.Resources{mockResource()}}, Phase: v1.ReleasePhaseApplying, CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), @@ -113,6 +117,7 @@ func TestValidateRelease(t *testing.T) { Workspace: "fake-ws", Revision: 1, Stack: "", + Spec: &v1.Spec{Resources: v1.Resources{mockResource()}}, State: &v1.State{Resources: v1.Resources{mockResource()}}, Phase: v1.ReleasePhaseApplying, CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), @@ -120,13 +125,29 @@ func TestValidateRelease(t *testing.T) { }, }, { - name: "invalid release empty state", + name: "invalid release invalid spec", success: false, release: &v1.Release{ Project: "fake-project", Workspace: "fake-ws", Revision: 1, Stack: "fake-stack", + Spec: nil, + State: &v1.State{Resources: v1.Resources{mockResource()}}, + Phase: v1.ReleasePhaseApplying, + CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + ModifiedTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), + }, + }, + { + name: "invalid release invalid state", + success: false, + release: &v1.Release{ + Project: "fake-project", + Workspace: "fake-ws", + Revision: 1, + Stack: "fake-stack", + Spec: &v1.Spec{Resources: v1.Resources{mockResource()}}, State: nil, Phase: v1.ReleasePhaseApplying, CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), @@ -141,6 +162,7 @@ func TestValidateRelease(t *testing.T) { Workspace: "fake-ws", Revision: 1, Stack: "fake-stack", + Spec: &v1.Spec{Resources: v1.Resources{mockResource()}}, State: &v1.State{Resources: v1.Resources{mockResource()}}, Phase: "", CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), @@ -155,6 +177,7 @@ func TestValidateRelease(t *testing.T) { Workspace: "fake-ws", Revision: 1, Stack: "fake-stack", + Spec: &v1.Spec{Resources: v1.Resources{mockResource()}}, State: &v1.State{Resources: v1.Resources{mockResource()}}, Phase: v1.ReleasePhaseApplying, ModifiedTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), @@ -168,6 +191,7 @@ func TestValidateRelease(t *testing.T) { Workspace: "fake-ws", Revision: 1, Stack: "fake-stack", + Spec: &v1.Spec{Resources: v1.Resources{mockResource()}}, State: &v1.State{Resources: v1.Resources{mockResource()}}, Phase: v1.ReleasePhaseApplying, CreateTime: time.Date(2024, 5, 10, 16, 48, 0, 0, loc), @@ -205,7 +229,7 @@ func TestValidateState(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - err := ValidateState(tc.state) + err := validateState(tc.state) assert.Equal(t, tc.success, err == nil) }) } diff --git a/pkg/server/manager/stack/stack_manager.go b/pkg/server/manager/stack/stack_manager.go index 0efbf38e..4c414858 100644 --- a/pkg/server/manager/stack/stack_manager.go +++ b/pkg/server/manager/stack/stack_manager.go @@ -9,11 +9,13 @@ import ( "github.com/jinzhu/copier" "gorm.io/gorm" + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" "kusionstack.io/kusion/pkg/domain/constant" "kusionstack.io/kusion/pkg/domain/entity" "kusionstack.io/kusion/pkg/domain/repository" "kusionstack.io/kusion/pkg/domain/request" + "kusionstack.io/kusion/pkg/engine/release" engineapi "kusionstack.io/kusion/pkg/engine/api" "kusionstack.io/kusion/pkg/engine/operation/models" @@ -96,32 +98,114 @@ func (m *StackManager) GenerateStack(ctx context.Context, id uint, workspaceName func (m *StackManager) PreviewStack(ctx context.Context, id uint, workspaceName string) (*models.Changes, error) { logger := util.GetLogger(ctx) logger.Info("Starting previewing stack in StackManager ...") - _, changes, _, err := m.previewHelper(ctx, id, workspaceName) + opts, stackBackend, project, stack, ws, err := m.metaHelper(ctx, id, workspaceName) + if err != nil { + return nil, err + } + + // Generate spec + sp, err := engineapi.GenerateSpecWithSpinner(project, stack, ws, true) + if err != nil { + return nil, 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 sp == nil || len(sp.Resources) == 0 { + logger.Info("No resource change found in this stack...") + return nil, nil + } + + // Preview + releaseStorage, err := stackBackend.ReleaseStorage(project.Name, ws.Name) + if err != nil { + return nil, err + } + state, err := release.GetLatestState(releaseStorage) + if err != nil { + return nil, err + } + if state == nil { + state = &v1.State{} + } + changes, err := engineapi.Preview(opts, releaseStorage, sp, state, project, stack) return changes, err } -func (m *StackManager) ApplyStack(ctx context.Context, id uint, workspaceName, format string, detail, dryrun bool, w http.ResponseWriter) error { +func (m *StackManager) ApplyStack(ctx context.Context, id uint, workspaceName, format string, detail, dryrun bool, w http.ResponseWriter) (err error) { logger := util.GetLogger(ctx) logger.Info("Starting applying stack in StackManager ...") + var storage release.Storage + var rel *v1.Release + releaseCreated := false + defer func() { + if !releaseCreated { + return + } + if err != nil { + rel.Phase = v1.ReleasePhaseFailed + _ = release.UpdateApplyRelease(storage, rel, dryrun) + } else { + rel.Phase = v1.ReleasePhaseSucceeded + err = release.UpdateApplyRelease(storage, rel, dryrun) + } + }() + + // create release + opts, stackBackend, project, stack, ws, err := m.metaHelper(ctx, id, workspaceName) + if err != nil { + return err + } + storage, err = stackBackend.ReleaseStorage(project.Name, ws.Name) + if err != nil { + return + } + rel, err = release.NewApplyRelease(storage, project.Name, stack.Name, ws.Name) + if err != nil { + return + } + if !dryrun { + if err = storage.Create(rel); err != nil { + return + } + releaseCreated = true + } + // Get the stack entity by id stackEntity, err := m.stackRepo.Get(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrGettingNonExistingStack } - return err + return } - // Preview a stack - sp, changes, stateStorage, err := m.previewHelper(ctx, id, workspaceName) + // generate spec + sp, err := engineapi.GenerateSpecWithSpinner(project, stack, ws, true) if err != nil { - return err + return + } + // 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 sp == nil || len(sp.Resources) == 0 { + logger.Info("No resource change found in this stack...") + return nil } + // update release phase to previewing + rel.Spec = sp + rel.Phase = v1.ReleasePhasePreviewing + if err = release.UpdateApplyRelease(storage, rel, dryrun); err != nil { + return + } + // compute changes for preview + changes, err := engineapi.Preview(opts, storage, rel.Spec, rel.State, project, stack) + if err != nil { + return + } _, err = ProcessChanges(ctx, w, changes, format, detail) if err != nil { - return err + return } // if dry run, print the hint @@ -130,11 +214,18 @@ func (m *StackManager) ApplyStack(ctx context.Context, id uint, workspaceName, f return ErrDryrunDestroy } + rel.Phase = v1.ReleasePhaseApplying + if err = release.UpdateApplyRelease(storage, rel, dryrun); err != nil { + return + } logger.Info("Dryrun set to false. Start applying diffs ...") executeOptions := BuildOptions(dryrun) - if err = engineapi.Apply(executeOptions, stateStorage, sp, changes, os.Stdout); err != nil { - return err + var upRel *v1.Release + upRel, err = engineapi.Apply(executeOptions, storage, rel, changes, os.Stdout) + if err != nil { + return } + rel = upRel // Update LastSyncTimestamp to current time and set stack syncState to synced stackEntity.LastSyncTimestamp = time.Now() @@ -143,79 +234,55 @@ func (m *StackManager) ApplyStack(ctx context.Context, id uint, workspaceName, f // Update stack with repository err = m.stackRepo.Update(ctx, stackEntity) if err != nil { - return err + return } return nil } -func (m *StackManager) DestroyStack(ctx context.Context, id uint, workspaceName string, detail, dryrun bool, w http.ResponseWriter) error { +func (m *StackManager) DestroyStack(ctx context.Context, id uint, workspaceName string, detail, dryrun bool, w http.ResponseWriter) (err error) { logger := util.GetLogger(ctx) logger.Info("Starting applying stack in StackManager ...") - // Get the stack entity by id - stackEntity, err := m.stackRepo.Get(ctx, id) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrGettingNonExistingStack + // update release to succeeded or failed + var storage release.Storage + var rel *v1.Release + releaseCreated := false + defer func() { + if !releaseCreated { + return } - return err - } + if err != nil { + rel.Phase = v1.ReleasePhaseFailed + _ = release.UpdateDestroyRelease(storage, rel) + } else { + rel.Phase = v1.ReleasePhaseSucceeded + err = release.UpdateDestroyRelease(storage, rel) + } + }() - // Get project by id - project, err := stackEntity.Project.ConvertToCore() + // create release + _, stackBackend, project, stack, ws, err := m.metaHelper(ctx, id, workspaceName) if err != nil { return err } - - // Get stack by id - stack, err := stackEntity.ConvertToCore() + storage, err = stackBackend.ReleaseStorage(project.Name, ws.Name) if err != nil { - return err + return } - - stateBackend, err := m.getBackendFromWorkspaceName(ctx, workspaceName) + rel, err = release.CreateDestroyRelease(storage, project.Name, stack.Name, ws.Name) if err != nil { - return err + return } - - // Build API inputs - // get project to get source and workdir - projectEntity, err := handler.GetProjectByID(ctx, m.projectRepo, stackEntity.Project.ID) - if err != nil { - return err - } - - directory, workDir, err := GetWorkDirFromSource(ctx, stackEntity, projectEntity) - if err != nil { - return err - } - destroyOptions := BuildOptions(dryrun) - stack.Path = workDir - - // Cleanup - defer sourceapi.Cleanup(ctx, directory) - - // Compute state storage - stateStorage := stateBackend.StateStorage(project.Name, workspaceName) - logger.Info("Remote state storage found", "Remote", stateStorage) - - priorState, err := stateStorage.Get() - if err != nil || priorState == nil { - logger.Info("can't find state", "project", project.Name, "stack", stack.Name, "workspace", workspaceName) - return ErrGettingNonExistingStateForStack - } - destroyResources := priorState.Resources - - if destroyResources == nil || len(priorState.Resources) == 0 { + if len(rel.Spec.Resources) == 0 { return ErrNoManagedResourceToDestroy } + releaseCreated = true // compute changes for preview - i := &v1.Spec{Resources: destroyResources} - changes, err := engineapi.DestroyPreview(destroyOptions, i, project, stack, stateStorage) + changes, err := engineapi.DestroyPreview(rel.Spec, rel.State, project, stack, storage) if err != nil { - return err + return } // Summary preview table @@ -231,11 +298,19 @@ func (m *StackManager) DestroyStack(ctx context.Context, id uint, workspaceName return ErrDryrunDestroy } + // update release phase to destroying + rel.Phase = v1.ReleasePhaseDestroying + if err = release.UpdateDestroyRelease(storage, rel); err != nil { + return + } // Destroy logger.Info("Start destroying resources......") - if err = engineapi.Destroy(destroyOptions, i, changes, stateStorage); err != nil { - return err + var upRel *v1.Release + upRel, err = engineapi.Destroy(rel, changes, storage) + if err != nil { + return } + rel = upRel return nil } diff --git a/pkg/server/manager/stack/util.go b/pkg/server/manager/stack/util.go index 6a0a0f03..1f95cf7e 100644 --- a/pkg/server/manager/stack/util.go +++ b/pkg/server/manager/stack/util.go @@ -18,7 +18,6 @@ import ( engineapi "kusionstack.io/kusion/pkg/engine/api" sourceapi "kusionstack.io/kusion/pkg/engine/api/source" "kusionstack.io/kusion/pkg/engine/operation/models" - "kusionstack.io/kusion/pkg/engine/state" "kusionstack.io/kusion/pkg/server/handler" "kusionstack.io/kusion/pkg/server/util" ) @@ -139,65 +138,62 @@ func (m *StackManager) getBackendFromWorkspaceName(ctx context.Context, workspac return remoteBackend, nil } -func (m *StackManager) previewHelper( +func (m *StackManager) metaHelper( ctx context.Context, id uint, workspaceName string, -) (*v1.Spec, *models.Changes, state.Storage, error) { +) (*engineapi.APIOptions, backend.Backend, *v1.Project, *v1.Stack, *v1.Workspace, error) { logger := util.GetLogger(ctx) - logger.Info("Starting previewing stack in StackManager ...") + logger.Info("Starting getting metadata of the stack in StackManager ...") // Get the stack entity by id stackEntity, err := m.stackRepo.Get(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil, nil, ErrGettingNonExistingStack + return nil, nil, nil, nil, nil, ErrGettingNonExistingStack } - return nil, nil, nil, err + return nil, nil, nil, nil, nil, err } // Get project by id project, err := stackEntity.Project.ConvertToCore() if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, nil, err } // Get stack by id stack, err := stackEntity.ConvertToCore() if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, nil, err } // Get backend from workspace name stackBackend, err := m.getBackendFromWorkspaceName(ctx, workspaceName) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, nil, err } // Get workspace configurations from backend // TODO: temporarily local for now, should be replaced by variable sets wsStorage, err := stackBackend.WorkspaceStorage() if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, nil, err } ws, err := wsStorage.Get(workspaceName) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, nil, err } - // Compute state storage - stateStorage := stackBackend.StateStorage(project.Name, ws.Name) - logger.Info("Local state storage found", "Path", stateStorage) // Build API inputs // get project to get source and workdir projectEntity, err := handler.GetProjectByID(ctx, m.projectRepo, stackEntity.Project.ID) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, nil, err } directory, workDir, err := GetWorkDirFromSource(ctx, stackEntity, projectEntity) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, nil, err } executeOptions := BuildOptions(false) stack.Path = workDir @@ -205,19 +201,5 @@ func (m *StackManager) previewHelper( // Cleanup defer sourceapi.Cleanup(ctx, directory) - // Generate spec - sp, err := engineapi.GenerateSpecWithSpinner(project, stack, ws, true) - if err != nil { - return nil, nil, nil, 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 sp == nil || len(sp.Resources) == 0 { - logger.Info("No resource change found in this stack...") - return nil, nil, nil, nil - } - - changes, err := engineapi.Preview(executeOptions, stateStorage, sp, project, stack) - return sp, changes, stateStorage, err + return executeOptions, stackBackend, project, stack, ws, err }