From 2eadd91d6743d7735a85e76eb15b0b91e4442e65 Mon Sep 17 00:00:00 2001 From: liuhaoming Date: Thu, 27 Jun 2024 15:23:20 +0800 Subject: [PATCH] feat: support release unlock command --- pkg/cmd/cmd.go | 7 ++ pkg/cmd/release/release.go | 29 +++++ pkg/cmd/release/release_test.go | 17 +++ pkg/cmd/release/unlock.go | 143 ++++++++++++++++++++++++ pkg/cmd/release/unlock_test.go | 192 ++++++++++++++++++++++++++++++++ pkg/cmd/util/helpers.go | 1 - pkg/engine/release/util.go | 15 +++ 7 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/release/release.go create mode 100644 pkg/cmd/release/release_test.go create mode 100644 pkg/cmd/release/unlock.go create mode 100644 pkg/cmd/release/unlock_test.go diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 66a2c76b8..716ba79ec 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -17,6 +17,7 @@ import ( "kusionstack.io/kusion/pkg/cmd/mod" "kusionstack.io/kusion/pkg/cmd/preview" "kusionstack.io/kusion/pkg/cmd/project" + rel "kusionstack.io/kusion/pkg/cmd/release" "kusionstack.io/kusion/pkg/cmd/stack" "kusionstack.io/kusion/pkg/cmd/version" "kusionstack.io/kusion/pkg/cmd/workspace" @@ -128,6 +129,12 @@ Find more information at: https://www.kusionstack.io`), mod.NewCmdMod(o.IOStreams), }, }, + { + Message: "Release Management Commands:", + Commands: []*cobra.Command{ + rel.NewCmdRel(o.IOStreams), + }, + }, } groups.Add(rootCmd) diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go new file mode 100644 index 000000000..77676e2d5 --- /dev/null +++ b/pkg/cmd/release/release.go @@ -0,0 +1,29 @@ +package rel + +import ( + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericiooptions" + "k8s.io/kubectl/pkg/util/templates" + cmdutil "kusionstack.io/kusion/pkg/cmd/util" + "kusionstack.io/kusion/pkg/util/i18n" +) + +var relLong = i18n.T(` + Commands for managing Kusion release files. + + These commands help you manage the lifecycle of Kusion release files. `) + +// NewCmdRel returns an initialized Command instance for 'release' sub command. +func NewCmdRel(streams genericiooptions.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "release", + DisableFlagsInUseLine: true, + Short: "Manage Kusion release files", + Long: templates.LongDesc(relLong), + Run: cmdutil.DefaultSubCommandRun(streams.ErrOut), + } + + cmd.AddCommand(NewCmdUnlock(streams)) + + return cmd +} diff --git a/pkg/cmd/release/release_test.go b/pkg/cmd/release/release_test.go new file mode 100644 index 000000000..02004f74f --- /dev/null +++ b/pkg/cmd/release/release_test.go @@ -0,0 +1,17 @@ +package rel + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/cli-runtime/pkg/genericiooptions" +) + +func TestNewCmdRel(t *testing.T) { + t.Run("successfully get release help", func(t *testing.T) { + streams, _, _, _ := genericiooptions.NewTestIOStreams() + + cmd := NewCmdRel(streams) + assert.NotNil(t, cmd) + }) +} diff --git a/pkg/cmd/release/unlock.go b/pkg/cmd/release/unlock.go new file mode 100644 index 000000000..f6c865862 --- /dev/null +++ b/pkg/cmd/release/unlock.go @@ -0,0 +1,143 @@ +package rel + +import ( + "fmt" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericiooptions" + "k8s.io/kubectl/pkg/util/templates" + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/cmd/meta" + cmdutil "kusionstack.io/kusion/pkg/cmd/util" + "kusionstack.io/kusion/pkg/engine/release" + "kusionstack.io/kusion/pkg/util/i18n" +) + +var ( + unlockShort = i18n.T("Unlock the latest release file of the current stack") + + unlockLong = i18n.T(` + Unlock the latest release file of the current stack. + + The phase of the latest release file of the current stack in the current or a specified workspace + will be set to 'failed' if it was in the stages of 'generating', 'previewing', 'applying' or 'destroying'. + + Please note that using the 'kusion release unlock' command may cause unexpected concurrent read-write + issues with release files, so please use it with caution. + `) + + unlockExample = i18n.T(`# Unlock the latest release file of the current stack in the current workspace. + kusion release unlock + + # Unlock the latest release file of the current stack in a specified workspace. + kusion release unlock --workspace=dev +`) +) + +// UnlockFlags reflects the information that CLI is gathering via flags, +// which will be converted into UnlockOptions. +type UnlockFlags struct { + MetaFlags *meta.MetaFlags +} + +// UnlockOptions defines the configuration parameters for the `kusion release unlock` command. +type UnlockOptions struct { + *meta.MetaOptions +} + +// NewUnlockFlags returns a default UnlockFlags. +func NewUnlockFlags(streams genericiooptions.IOStreams) *UnlockFlags { + return &UnlockFlags{ + MetaFlags: meta.NewMetaFlags(), + } +} + +// NewCmdUnlock creates the `kusion release unlock` command. +func NewCmdUnlock(streams genericiooptions.IOStreams) *cobra.Command { + flags := NewUnlockFlags(streams) + + cmd := &cobra.Command{ + Use: "unlock", + Short: unlockShort, + Long: templates.LongDesc(unlockLong), + Example: templates.Examples(unlockExample), + RunE: func(cmd *cobra.Command, args []string) (err error) { + o, err := flags.ToOptions() + defer cmdutil.RecoverErr(&err) + cmdutil.CheckErr(err) + cmdutil.CheckErr(o.Validate(cmd, args)) + cmdutil.CheckErr(o.Run()) + + return + }, + } + + flags.AddFlags(cmd) + + return cmd +} + +// AddFlags registers flags for the CLI. +func (f *UnlockFlags) AddFlags(cmd *cobra.Command) { + f.MetaFlags.AddFlags(cmd) +} + +// ToOptions converts from CLI inputs to runtime inputs. +func (f *UnlockFlags) ToOptions() (*UnlockOptions, error) { + metaOpts, err := f.MetaFlags.ToOptions() + if err != nil { + return nil, err + } + + o := &UnlockOptions{ + MetaOptions: metaOpts, + } + + return o, nil +} + +// Validate verifies if UnlockOptions are valid and without conflicts. +func (o *UnlockOptions) Validate(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return cmdutil.UsageErrorf(cmd, "Unexpected args: %v", args) + } + + return nil +} + +// Run executes the `kusion release unlock` command. +func (o *UnlockOptions) Run() error { + // Get the storage backend of the release. + storage, err := o.Backend.ReleaseStorage(o.RefProject.Name, o.RefWorkspace.Name) + if err != nil { + return err + } + + // Get the latest release. + r, err := release.GetLatestRelease(storage) + if err != nil { + return err + } + if r == nil { + fmt.Printf("No release file found for project: %s, workspace: %s\n", + o.RefProject.Name, o.RefWorkspace.Name) + return nil + } + + // Update the phase to 'failed', if it was not succeeded or failed. + if r.Phase != v1.ReleasePhaseSucceeded && r.Phase != v1.ReleasePhaseFailed { + r.Phase = v1.ReleasePhaseFailed + + if err := storage.Update(r); err != nil { + return err + } + + fmt.Printf("Successfully update release phase to Failed, project: %s, workspace: %s, revision: %d\n", + r.Project, r.Workspace, r.Revision) + + return nil + } + + fmt.Printf("No need to update the release phase, current phase: %s\n", r.Phase) + return nil +} diff --git a/pkg/cmd/release/unlock_test.go b/pkg/cmd/release/unlock_test.go new file mode 100644 index 000000000..f296b597f --- /dev/null +++ b/pkg/cmd/release/unlock_test.go @@ -0,0 +1,192 @@ +package rel + +import ( + "fmt" + "testing" + + "github.com/bytedance/mockey" + "github.com/stretchr/testify/assert" + "k8s.io/cli-runtime/pkg/genericiooptions" + v1 "kusionstack.io/kusion/pkg/apis/api.kusion.io/v1" + "kusionstack.io/kusion/pkg/backend" + "kusionstack.io/kusion/pkg/cmd/meta" + "kusionstack.io/kusion/pkg/engine/release" + "kusionstack.io/kusion/pkg/project" + "kusionstack.io/kusion/pkg/workspace" +) + +func TestUnlockFlags_ToOptions(t *testing.T) { + streams := genericiooptions.IOStreams{} + + f := NewUnlockFlags(streams) + + t.Run("Successful Option Creation", func(t *testing.T) { + mockey.PatchConvey("mock detect project and stack", t, func() { + mockey.Mock(project.DetectProjectAndStackFrom).Return(&v1.Project{ + Name: "mock-project", + }, &v1.Stack{ + Name: "mock-stack", + }, nil).Build() + _, err := f.ToOptions() + assert.NoError(t, err) + }) + }) + + t.Run("Failed Option Creation Due to Invalid Backend", func(t *testing.T) { + s := "invalid-backend" + f.MetaFlags.Backend = &s + _, err := f.ToOptions() + assert.Error(t, err) + }) +} + +func TestUnlockOptions_Validate(t *testing.T) { + opts := &UnlockOptions{} + streams := genericiooptions.IOStreams{} + cmd := NewCmdUnlock(streams) + + t.Run("Valid Args", func(t *testing.T) { + err := opts.Validate(cmd, []string{}) + assert.NoError(t, err) + }) + + t.Run("Invalid Args", func(t *testing.T) { + err := opts.Validate(cmd, []string{"invalid-args"}) + assert.Error(t, err) + }) +} + +func TestUnlockOptions_Run(t *testing.T) { + opts := &UnlockOptions{ + MetaOptions: &meta.MetaOptions{ + RefProject: &v1.Project{ + Name: "mock-project", + }, + RefStack: &v1.Stack{ + Name: "mock-stack", + }, + RefWorkspace: &v1.Workspace{ + Name: "mock-workspace", + }, + Backend: &fakeBackend{}, + }, + } + + t.Run("Failed to Get Latest Storage Backend", func(t *testing.T) { + mockey.PatchConvey("mock release storage", t, func() { + mockey.Mock((*fakeBackend).ReleaseStorage). + Return(nil, fmt.Errorf("failed to get release storage")).Build() + + err := opts.Run() + assert.ErrorContains(t, err, "failed to get release storage") + }) + }) + + t.Run("Failed to Get Latest Release", func(t *testing.T) { + mockey.PatchConvey("mock release storage and release getter", t, func() { + mockey.Mock((*fakeBackend).ReleaseStorage). + Return(nil, nil).Build() + mockey.Mock(release.GetLatestRelease). + Return(nil, fmt.Errorf("failed to get latest release")).Build() + + err := opts.Run() + assert.ErrorContains(t, err, "failed to get latest release") + }) + }) + + t.Run("No Release File Found", func(t *testing.T) { + mockey.PatchConvey("mock release storage and release getter", t, func() { + mockey.Mock((*fakeBackend).ReleaseStorage). + Return(nil, nil).Build() + mockey.Mock(release.GetLatestRelease). + Return(nil, nil).Build() + + err := opts.Run() + assert.NoError(t, err) + }) + }) + + t.Run("Failed to Update Release", func(t *testing.T) { + mockey.PatchConvey("mock release storage and release getter", t, func() { + mockey.Mock((*fakeBackend).ReleaseStorage). + Return(&fakeStorage{}, nil).Build() + mockey.Mock(release.GetLatestRelease). + Return(&v1.Release{ + Phase: v1.ReleasePhaseApplying, + }, nil).Build() + mockey.Mock((*fakeStorage).Update). + Return(fmt.Errorf("failed to update release")).Build() + + err := opts.Run() + assert.ErrorContains(t, err, "failed to update release") + }) + }) + + t.Run("Successfully Update Release Phase", func(t *testing.T) { + mockey.PatchConvey("mock release storage and release getter", t, func() { + mockey.Mock((*fakeBackend).ReleaseStorage). + Return(&fakeStorage{}, nil).Build() + mockey.Mock(release.GetLatestRelease). + Return(&v1.Release{ + Phase: v1.ReleasePhaseApplying, + }, nil).Build() + + err := opts.Run() + assert.NoError(t, err) + }) + }) + + t.Run("No Need to Update Release", func(t *testing.T) { + mockey.PatchConvey("mock release storage and release getter", t, func() { + mockey.Mock((*fakeBackend).ReleaseStorage). + Return(&fakeStorage{}, nil).Build() + mockey.Mock(release.GetLatestRelease). + Return(&v1.Release{ + Phase: v1.ReleasePhaseSucceeded, + }, nil).Build() + + err := opts.Run() + assert.NoError(t, err) + }) + }) +} + +var _ backend.Backend = (*fakeBackend)(nil) + +type fakeBackend struct{} + +func (f *fakeBackend) WorkspaceStorage() (workspace.Storage, error) { + return nil, nil +} + +func (f *fakeBackend) ReleaseStorage(project, workspace string) (release.Storage, error) { + return nil, nil +} + +var _ release.Storage = (*fakeStorage)(nil) + +type fakeStorage struct{} + +func (f *fakeStorage) Get(revision uint64) (*v1.Release, error) { + return nil, nil +} + +func (f *fakeStorage) GetRevisions() []uint64 { + return nil +} + +func (f *fakeStorage) GetStackBoundRevisions(stack string) []uint64 { + return nil +} + +func (f *fakeStorage) GetLatestRevision() uint64 { + return 0 +} + +func (f *fakeStorage) Create(release *v1.Release) error { + return nil +} + +func (f *fakeStorage) Update(release *v1.Release) error { + return nil +} diff --git a/pkg/cmd/util/helpers.go b/pkg/cmd/util/helpers.go index 1386cbae1..69fb6e097 100644 --- a/pkg/cmd/util/helpers.go +++ b/pkg/cmd/util/helpers.go @@ -53,6 +53,5 @@ func DefaultSubCommandRun(out io.Writer) func(c *cobra.Command, args []string) { c.SetOut(out) RequireNoArguments(c, args) c.Help() - CheckErr(ErrExit) } } diff --git a/pkg/engine/release/util.go b/pkg/engine/release/util.go index 3a6233753..7379053d7 100644 --- a/pkg/engine/release/util.go +++ b/pkg/engine/release/util.go @@ -9,6 +9,21 @@ import ( "kusionstack.io/kusion/pkg/log" ) +// GetLatestRelease returns the latest release. If no release exists, return nil. +func GetLatestRelease(storage Storage) (*v1.Release, error) { + revision := storage.GetLatestRevision() + if revision == 0 { + return nil, nil + } + + r, err := storage.Get(revision) + if err != nil { + return nil, err + } + + return r, err +} + // GetLatestState returns the latest state. If no release exists, return nil. func GetLatestState(storage Storage) (*v1.State, error) { revision := storage.GetLatestRevision()