diff --git a/clierrors/errors.go b/clierrors/errors.go index 3ced52fee7..05ab96cb00 100644 --- a/clierrors/errors.go +++ b/clierrors/errors.go @@ -6,11 +6,11 @@ var ( ErrProjectNotPassed = "project id wasn't passed\n" // #nosec ErrProjectIDBothPassed = "both project and id are passed\n" ErrProjectNameNotPassed = "project name is a required flag" - ErrFailedProjectUpdate = "Project %v failed to update due to %v\n" + ErrFailedProjectUpdate = "Project %v failed to update due to %w\n" ErrLPNotPassed = "launch plan name wasn't passed\n" ErrLPVersionNotPassed = "launch plan version wasn't passed\n" //nolint - ErrFailedLPUpdate = "launch plan %v failed to update due to %v\n" + ErrFailedLPUpdate = "launch plan %v failed to update due to %w\n" ErrExecutionNotPassed = "execution name wasn't passed\n" ErrFailedExecutionUpdate = "execution %v failed to update due to %v\n" diff --git a/cmd/config/subcommand/clusterresourceattribute/attrupdateconfig_flags.go b/cmd/config/subcommand/clusterresourceattribute/attrupdateconfig_flags.go index 4f05b3a8a8..f0f8103f12 100755 --- a/cmd/config/subcommand/clusterresourceattribute/attrupdateconfig_flags.go +++ b/cmd/config/subcommand/clusterresourceattribute/attrupdateconfig_flags.go @@ -52,5 +52,6 @@ func (cfg AttrUpdateConfig) GetPFlagSet(prefix string) *pflag.FlagSet { cmdFlags := pflag.NewFlagSet("AttrUpdateConfig", pflag.ExitOnError) cmdFlags.StringVar(&DefaultUpdateConfig.AttrFile, fmt.Sprintf("%v%v", prefix, "attrFile"), DefaultUpdateConfig.AttrFile, "attribute file name to be used for updating attribute for the resource type.") cmdFlags.BoolVar(&DefaultUpdateConfig.DryRun, fmt.Sprintf("%v%v", prefix, "dryRun"), DefaultUpdateConfig.DryRun, "execute command without making any modifications.") + cmdFlags.BoolVar(&DefaultUpdateConfig.Force, fmt.Sprintf("%v%v", prefix, "force"), DefaultUpdateConfig.Force, "do not ask for an acknowledgement during updates.") return cmdFlags } diff --git a/cmd/config/subcommand/clusterresourceattribute/attrupdateconfig_flags_test.go b/cmd/config/subcommand/clusterresourceattribute/attrupdateconfig_flags_test.go index 29c9328801..9396a2e9e4 100755 --- a/cmd/config/subcommand/clusterresourceattribute/attrupdateconfig_flags_test.go +++ b/cmd/config/subcommand/clusterresourceattribute/attrupdateconfig_flags_test.go @@ -127,4 +127,18 @@ func TestAttrUpdateConfig_SetFlags(t *testing.T) { } }) }) + t.Run("Test_force", func(t *testing.T) { + + t.Run("Override", func(t *testing.T) { + testValue := "1" + + cmdFlags.Set("force", testValue) + if vBool, err := cmdFlags.GetBool("force"); err == nil { + testDecodeJson_AttrUpdateConfig(t, fmt.Sprintf("%v", vBool), &actual.Force) + + } else { + assert.FailNow(t, err.Error()) + } + }) + }) } diff --git a/cmd/config/subcommand/clusterresourceattribute/update_config.go b/cmd/config/subcommand/clusterresourceattribute/update_config.go index 3727fbdb2a..9b4534eed7 100644 --- a/cmd/config/subcommand/clusterresourceattribute/update_config.go +++ b/cmd/config/subcommand/clusterresourceattribute/update_config.go @@ -6,6 +6,7 @@ package clusterresourceattribute type AttrUpdateConfig struct { AttrFile string `json:"attrFile" pflag:",attribute file name to be used for updating attribute for the resource type."` DryRun bool `json:"dryRun" pflag:",execute command without making any modifications."` + Force bool `json:"force" pflag:",do not ask for an acknowledgement during updates."` } var DefaultUpdateConfig = &AttrUpdateConfig{} diff --git a/cmd/config/subcommand/execution/update_config.go b/cmd/config/subcommand/execution/update_config.go index 8623435784..7d55a5e9b0 100644 --- a/cmd/config/subcommand/execution/update_config.go +++ b/cmd/config/subcommand/execution/update_config.go @@ -10,4 +10,5 @@ type UpdateConfig struct { Archive bool `json:"archive" pflag:",archive execution."` Activate bool `json:"activate" pflag:",activate execution."` DryRun bool `json:"dryRun" pflag:",execute command without making any modifications."` + Force bool `json:"force" pflag:",do not ask for an acknowledgement during updates."` } diff --git a/cmd/config/subcommand/execution/updateconfig_flags.go b/cmd/config/subcommand/execution/updateconfig_flags.go index 03c9d0f90b..a1b251c18d 100755 --- a/cmd/config/subcommand/execution/updateconfig_flags.go +++ b/cmd/config/subcommand/execution/updateconfig_flags.go @@ -53,5 +53,6 @@ func (cfg UpdateConfig) GetPFlagSet(prefix string) *pflag.FlagSet { cmdFlags.BoolVar(&UConfig.Archive, fmt.Sprintf("%v%v", prefix, "archive"), UConfig.Archive, "archive execution.") cmdFlags.BoolVar(&UConfig.Activate, fmt.Sprintf("%v%v", prefix, "activate"), UConfig.Activate, "activate execution.") cmdFlags.BoolVar(&UConfig.DryRun, fmt.Sprintf("%v%v", prefix, "dryRun"), UConfig.DryRun, "execute command without making any modifications.") + cmdFlags.BoolVar(&UConfig.Force, fmt.Sprintf("%v%v", prefix, "force"), UConfig.Force, "do not ask for an acknowledgement during updates.") return cmdFlags } diff --git a/cmd/config/subcommand/execution/updateconfig_flags_test.go b/cmd/config/subcommand/execution/updateconfig_flags_test.go index 6c65c2d14b..2e6c693ea8 100755 --- a/cmd/config/subcommand/execution/updateconfig_flags_test.go +++ b/cmd/config/subcommand/execution/updateconfig_flags_test.go @@ -141,4 +141,18 @@ func TestUpdateConfig_SetFlags(t *testing.T) { } }) }) + t.Run("Test_force", func(t *testing.T) { + + t.Run("Override", func(t *testing.T) { + testValue := "1" + + cmdFlags.Set("force", testValue) + if vBool, err := cmdFlags.GetBool("force"); err == nil { + testDecodeJson_UpdateConfig(t, fmt.Sprintf("%v", vBool), &actual.Force) + + } else { + assert.FailNow(t, err.Error()) + } + }) + }) } diff --git a/cmd/config/subcommand/executionclusterlabel/attrupdateconfig_flags.go b/cmd/config/subcommand/executionclusterlabel/attrupdateconfig_flags.go index 8b1beee0bf..979e08ea50 100755 --- a/cmd/config/subcommand/executionclusterlabel/attrupdateconfig_flags.go +++ b/cmd/config/subcommand/executionclusterlabel/attrupdateconfig_flags.go @@ -52,5 +52,6 @@ func (cfg AttrUpdateConfig) GetPFlagSet(prefix string) *pflag.FlagSet { cmdFlags := pflag.NewFlagSet("AttrUpdateConfig", pflag.ExitOnError) cmdFlags.StringVar(&DefaultUpdateConfig.AttrFile, fmt.Sprintf("%v%v", prefix, "attrFile"), DefaultUpdateConfig.AttrFile, "attribute file name to be used for updating attribute for the resource type.") cmdFlags.BoolVar(&DefaultUpdateConfig.DryRun, fmt.Sprintf("%v%v", prefix, "dryRun"), DefaultUpdateConfig.DryRun, "execute command without making any modifications.") + cmdFlags.BoolVar(&DefaultUpdateConfig.Force, fmt.Sprintf("%v%v", prefix, "force"), DefaultUpdateConfig.Force, "do not ask for an acknowledgement during updates.") return cmdFlags } diff --git a/cmd/config/subcommand/executionclusterlabel/attrupdateconfig_flags_test.go b/cmd/config/subcommand/executionclusterlabel/attrupdateconfig_flags_test.go index 4712ba314e..e2a8fe2a2e 100755 --- a/cmd/config/subcommand/executionclusterlabel/attrupdateconfig_flags_test.go +++ b/cmd/config/subcommand/executionclusterlabel/attrupdateconfig_flags_test.go @@ -127,4 +127,18 @@ func TestAttrUpdateConfig_SetFlags(t *testing.T) { } }) }) + t.Run("Test_force", func(t *testing.T) { + + t.Run("Override", func(t *testing.T) { + testValue := "1" + + cmdFlags.Set("force", testValue) + if vBool, err := cmdFlags.GetBool("force"); err == nil { + testDecodeJson_AttrUpdateConfig(t, fmt.Sprintf("%v", vBool), &actual.Force) + + } else { + assert.FailNow(t, err.Error()) + } + }) + }) } diff --git a/cmd/config/subcommand/executionclusterlabel/update_config.go b/cmd/config/subcommand/executionclusterlabel/update_config.go index 62d853a7cc..be0de45cef 100644 --- a/cmd/config/subcommand/executionclusterlabel/update_config.go +++ b/cmd/config/subcommand/executionclusterlabel/update_config.go @@ -6,6 +6,7 @@ package executionclusterlabel type AttrUpdateConfig struct { AttrFile string `json:"attrFile" pflag:",attribute file name to be used for updating attribute for the resource type."` DryRun bool `json:"dryRun" pflag:",execute command without making any modifications."` + Force bool `json:"force" pflag:",do not ask for an acknowledgement during updates."` } var DefaultUpdateConfig = &AttrUpdateConfig{} diff --git a/cmd/config/subcommand/executionqueueattribute/attrupdateconfig_flags.go b/cmd/config/subcommand/executionqueueattribute/attrupdateconfig_flags.go index cff8301fb7..7643a98017 100755 --- a/cmd/config/subcommand/executionqueueattribute/attrupdateconfig_flags.go +++ b/cmd/config/subcommand/executionqueueattribute/attrupdateconfig_flags.go @@ -52,5 +52,6 @@ func (cfg AttrUpdateConfig) GetPFlagSet(prefix string) *pflag.FlagSet { cmdFlags := pflag.NewFlagSet("AttrUpdateConfig", pflag.ExitOnError) cmdFlags.StringVar(&DefaultUpdateConfig.AttrFile, fmt.Sprintf("%v%v", prefix, "attrFile"), DefaultUpdateConfig.AttrFile, "attribute file name to be used for updating attribute for the resource type.") cmdFlags.BoolVar(&DefaultUpdateConfig.DryRun, fmt.Sprintf("%v%v", prefix, "dryRun"), DefaultUpdateConfig.DryRun, "execute command without making any modifications.") + cmdFlags.BoolVar(&DefaultUpdateConfig.Force, fmt.Sprintf("%v%v", prefix, "force"), DefaultUpdateConfig.Force, "do not ask for an acknowledgement during updates.") return cmdFlags } diff --git a/cmd/config/subcommand/executionqueueattribute/attrupdateconfig_flags_test.go b/cmd/config/subcommand/executionqueueattribute/attrupdateconfig_flags_test.go index ff19cfa42f..82c697d17a 100755 --- a/cmd/config/subcommand/executionqueueattribute/attrupdateconfig_flags_test.go +++ b/cmd/config/subcommand/executionqueueattribute/attrupdateconfig_flags_test.go @@ -127,4 +127,18 @@ func TestAttrUpdateConfig_SetFlags(t *testing.T) { } }) }) + t.Run("Test_force", func(t *testing.T) { + + t.Run("Override", func(t *testing.T) { + testValue := "1" + + cmdFlags.Set("force", testValue) + if vBool, err := cmdFlags.GetBool("force"); err == nil { + testDecodeJson_AttrUpdateConfig(t, fmt.Sprintf("%v", vBool), &actual.Force) + + } else { + assert.FailNow(t, err.Error()) + } + }) + }) } diff --git a/cmd/config/subcommand/executionqueueattribute/update_config.go b/cmd/config/subcommand/executionqueueattribute/update_config.go index 7826602ab6..65dd680d5a 100644 --- a/cmd/config/subcommand/executionqueueattribute/update_config.go +++ b/cmd/config/subcommand/executionqueueattribute/update_config.go @@ -6,6 +6,7 @@ package executionqueueattribute type AttrUpdateConfig struct { AttrFile string `json:"attrFile" pflag:",attribute file name to be used for updating attribute for the resource type."` DryRun bool `json:"dryRun" pflag:",execute command without making any modifications."` + Force bool `json:"force" pflag:",do not ask for an acknowledgement during updates."` } var DefaultUpdateConfig = &AttrUpdateConfig{} diff --git a/cmd/config/subcommand/launchplan/updateconfig.go b/cmd/config/subcommand/launchplan/updateconfig.go index b7bd87bbc7..36e353c2e1 100644 --- a/cmd/config/subcommand/launchplan/updateconfig.go +++ b/cmd/config/subcommand/launchplan/updateconfig.go @@ -10,5 +10,6 @@ type UpdateConfig struct { Archive bool `json:"archive" pflag:",disable the launch plan schedule (if it has an active schedule associated with it)."` Activate bool `json:"activate" pflag:",activate launchplan."` DryRun bool `json:"dryRun" pflag:",execute command without making any modifications."` + Force bool `json:"force" pflag:",do not ask for an acknowledgement during updates."` Version string `json:"version" pflag:",version of the launchplan to be fetched."` } diff --git a/cmd/config/subcommand/launchplan/updateconfig_flags.go b/cmd/config/subcommand/launchplan/updateconfig_flags.go index 14570a00ca..4a9cad23ba 100755 --- a/cmd/config/subcommand/launchplan/updateconfig_flags.go +++ b/cmd/config/subcommand/launchplan/updateconfig_flags.go @@ -53,6 +53,7 @@ func (cfg UpdateConfig) GetPFlagSet(prefix string) *pflag.FlagSet { cmdFlags.BoolVar(&UConfig.Archive, fmt.Sprintf("%v%v", prefix, "archive"), UConfig.Archive, "disable the launch plan schedule (if it has an active schedule associated with it).") cmdFlags.BoolVar(&UConfig.Activate, fmt.Sprintf("%v%v", prefix, "activate"), UConfig.Activate, "activate launchplan.") cmdFlags.BoolVar(&UConfig.DryRun, fmt.Sprintf("%v%v", prefix, "dryRun"), UConfig.DryRun, "execute command without making any modifications.") + cmdFlags.BoolVar(&UConfig.Force, fmt.Sprintf("%v%v", prefix, "force"), UConfig.Force, "do not ask for an acknowledgement during updates.") cmdFlags.StringVar(&UConfig.Version, fmt.Sprintf("%v%v", prefix, "version"), UConfig.Version, "version of the launchplan to be fetched.") return cmdFlags } diff --git a/cmd/config/subcommand/launchplan/updateconfig_flags_test.go b/cmd/config/subcommand/launchplan/updateconfig_flags_test.go index a0d1c1adf6..e9acca7bbe 100755 --- a/cmd/config/subcommand/launchplan/updateconfig_flags_test.go +++ b/cmd/config/subcommand/launchplan/updateconfig_flags_test.go @@ -141,6 +141,20 @@ func TestUpdateConfig_SetFlags(t *testing.T) { } }) }) + t.Run("Test_force", func(t *testing.T) { + + t.Run("Override", func(t *testing.T) { + testValue := "1" + + cmdFlags.Set("force", testValue) + if vBool, err := cmdFlags.GetBool("force"); err == nil { + testDecodeJson_UpdateConfig(t, fmt.Sprintf("%v", vBool), &actual.Force) + + } else { + assert.FailNow(t, err.Error()) + } + }) + }) t.Run("Test_version", func(t *testing.T) { t.Run("Override", func(t *testing.T) { diff --git a/cmd/config/subcommand/plugin_override/attrupdateconfig_flags.go b/cmd/config/subcommand/plugin_override/attrupdateconfig_flags.go index 8642031b92..82e5cb6661 100755 --- a/cmd/config/subcommand/plugin_override/attrupdateconfig_flags.go +++ b/cmd/config/subcommand/plugin_override/attrupdateconfig_flags.go @@ -52,5 +52,6 @@ func (cfg AttrUpdateConfig) GetPFlagSet(prefix string) *pflag.FlagSet { cmdFlags := pflag.NewFlagSet("AttrUpdateConfig", pflag.ExitOnError) cmdFlags.StringVar(&DefaultUpdateConfig.AttrFile, fmt.Sprintf("%v%v", prefix, "attrFile"), DefaultUpdateConfig.AttrFile, "attribute file name to be used for updating attribute for the resource type.") cmdFlags.BoolVar(&DefaultUpdateConfig.DryRun, fmt.Sprintf("%v%v", prefix, "dryRun"), DefaultUpdateConfig.DryRun, "execute command without making any modifications.") + cmdFlags.BoolVar(&DefaultUpdateConfig.Force, fmt.Sprintf("%v%v", prefix, "force"), DefaultUpdateConfig.Force, "do not ask for an acknowledgement during updates.") return cmdFlags } diff --git a/cmd/config/subcommand/plugin_override/attrupdateconfig_flags_test.go b/cmd/config/subcommand/plugin_override/attrupdateconfig_flags_test.go index 84628be5e7..309c31746a 100755 --- a/cmd/config/subcommand/plugin_override/attrupdateconfig_flags_test.go +++ b/cmd/config/subcommand/plugin_override/attrupdateconfig_flags_test.go @@ -127,4 +127,18 @@ func TestAttrUpdateConfig_SetFlags(t *testing.T) { } }) }) + t.Run("Test_force", func(t *testing.T) { + + t.Run("Override", func(t *testing.T) { + testValue := "1" + + cmdFlags.Set("force", testValue) + if vBool, err := cmdFlags.GetBool("force"); err == nil { + testDecodeJson_AttrUpdateConfig(t, fmt.Sprintf("%v", vBool), &actual.Force) + + } else { + assert.FailNow(t, err.Error()) + } + }) + }) } diff --git a/cmd/config/subcommand/plugin_override/update_config.go b/cmd/config/subcommand/plugin_override/update_config.go index 8e99970d4b..dc3c260074 100644 --- a/cmd/config/subcommand/plugin_override/update_config.go +++ b/cmd/config/subcommand/plugin_override/update_config.go @@ -6,6 +6,7 @@ package pluginoverride type AttrUpdateConfig struct { AttrFile string `json:"attrFile" pflag:",attribute file name to be used for updating attribute for the resource type."` DryRun bool `json:"dryRun" pflag:",execute command without making any modifications."` + Force bool `json:"force" pflag:",do not ask for an acknowledgement during updates."` } var DefaultUpdateConfig = &AttrUpdateConfig{} diff --git a/cmd/config/subcommand/project/configproject_flags.go b/cmd/config/subcommand/project/configproject_flags.go index e0e1c75f08..6de8107e76 100755 --- a/cmd/config/subcommand/project/configproject_flags.go +++ b/cmd/config/subcommand/project/configproject_flags.go @@ -55,6 +55,7 @@ func (cfg ConfigProject) GetPFlagSet(prefix string) *pflag.FlagSet { cmdFlags.BoolVar(&DefaultProjectConfig.ArchiveProject, fmt.Sprintf("%v%v", prefix, "archiveProject"), DefaultProjectConfig.ArchiveProject, "(Deprecated) Archives the project specified as argument. Only used in update") cmdFlags.BoolVar(&DefaultProjectConfig.Activate, fmt.Sprintf("%v%v", prefix, "activate"), DefaultProjectConfig.Activate, "Activates the project specified as argument. Only used in update") cmdFlags.BoolVar(&DefaultProjectConfig.Archive, fmt.Sprintf("%v%v", prefix, "archive"), DefaultProjectConfig.Archive, "Archives the project specified as argument. Only used in update") + cmdFlags.BoolVar(&DefaultProjectConfig.Force, fmt.Sprintf("%v%v", prefix, "force"), DefaultProjectConfig.Force, "Skips asking for an acknowledgement during an update operation. Only used in update") cmdFlags.StringVar(&DefaultProjectConfig.Name, fmt.Sprintf("%v%v", prefix, "name"), DefaultProjectConfig.Name, "name for the project specified as argument.") cmdFlags.BoolVar(&DefaultProjectConfig.DryRun, fmt.Sprintf("%v%v", prefix, "dryRun"), DefaultProjectConfig.DryRun, "execute command without making any modifications.") cmdFlags.StringVar(&DefaultProjectConfig.Description, fmt.Sprintf("%v%v", prefix, "description"), DefaultProjectConfig.Description, "description for the project specified as argument.") diff --git a/cmd/config/subcommand/project/configproject_flags_test.go b/cmd/config/subcommand/project/configproject_flags_test.go index abe5e1e627..98847d779a 100755 --- a/cmd/config/subcommand/project/configproject_flags_test.go +++ b/cmd/config/subcommand/project/configproject_flags_test.go @@ -169,6 +169,20 @@ func TestConfigProject_SetFlags(t *testing.T) { } }) }) + t.Run("Test_force", func(t *testing.T) { + + t.Run("Override", func(t *testing.T) { + testValue := "1" + + cmdFlags.Set("force", testValue) + if vBool, err := cmdFlags.GetBool("force"); err == nil { + testDecodeJson_ConfigProject(t, fmt.Sprintf("%v", vBool), &actual.Force) + + } else { + assert.FailNow(t, err.Error()) + } + }) + }) t.Run("Test_name", func(t *testing.T) { t.Run("Override", func(t *testing.T) { diff --git a/cmd/config/subcommand/project/project_config.go b/cmd/config/subcommand/project/project_config.go index 96b9c643dd..c278443f16 100644 --- a/cmd/config/subcommand/project/project_config.go +++ b/cmd/config/subcommand/project/project_config.go @@ -33,6 +33,7 @@ type ConfigProject struct { ArchiveProject bool `json:"archiveProject" pflag:",(Deprecated) Archives the project specified as argument. Only used in update"` Activate bool `json:"activate" pflag:",Activates the project specified as argument. Only used in update"` Archive bool `json:"archive" pflag:",Archives the project specified as argument. Only used in update"` + Force bool `json:"force" pflag:",Skips asking for an acknowledgement during an update operation. Only used in update"` Name string `json:"name" pflag:",name for the project specified as argument."` DryRun bool `json:"dryRun" pflag:",execute command without making any modifications."` Description string `json:"description" pflag:",description for the project specified as argument."` diff --git a/cmd/config/subcommand/taskresourceattribute/attrupdateconfig_flags.go b/cmd/config/subcommand/taskresourceattribute/attrupdateconfig_flags.go index 1a0e3e3a86..402add1c0d 100755 --- a/cmd/config/subcommand/taskresourceattribute/attrupdateconfig_flags.go +++ b/cmd/config/subcommand/taskresourceattribute/attrupdateconfig_flags.go @@ -52,5 +52,6 @@ func (cfg AttrUpdateConfig) GetPFlagSet(prefix string) *pflag.FlagSet { cmdFlags := pflag.NewFlagSet("AttrUpdateConfig", pflag.ExitOnError) cmdFlags.StringVar(&DefaultUpdateConfig.AttrFile, fmt.Sprintf("%v%v", prefix, "attrFile"), DefaultUpdateConfig.AttrFile, "attribute file name to be used for updating attribute for the resource type.") cmdFlags.BoolVar(&DefaultUpdateConfig.DryRun, fmt.Sprintf("%v%v", prefix, "dryRun"), DefaultUpdateConfig.DryRun, "execute command without making any modifications.") + cmdFlags.BoolVar(&DefaultUpdateConfig.Force, fmt.Sprintf("%v%v", prefix, "force"), DefaultUpdateConfig.Force, "do not ask for an acknowledgement during updates.") return cmdFlags } diff --git a/cmd/config/subcommand/taskresourceattribute/attrupdateconfig_flags_test.go b/cmd/config/subcommand/taskresourceattribute/attrupdateconfig_flags_test.go index 2a141e049b..aaf429a732 100755 --- a/cmd/config/subcommand/taskresourceattribute/attrupdateconfig_flags_test.go +++ b/cmd/config/subcommand/taskresourceattribute/attrupdateconfig_flags_test.go @@ -127,4 +127,18 @@ func TestAttrUpdateConfig_SetFlags(t *testing.T) { } }) }) + t.Run("Test_force", func(t *testing.T) { + + t.Run("Override", func(t *testing.T) { + testValue := "1" + + cmdFlags.Set("force", testValue) + if vBool, err := cmdFlags.GetBool("force"); err == nil { + testDecodeJson_AttrUpdateConfig(t, fmt.Sprintf("%v", vBool), &actual.Force) + + } else { + assert.FailNow(t, err.Error()) + } + }) + }) } diff --git a/cmd/config/subcommand/taskresourceattribute/update_config.go b/cmd/config/subcommand/taskresourceattribute/update_config.go index 084c65d6d4..4ee836e433 100644 --- a/cmd/config/subcommand/taskresourceattribute/update_config.go +++ b/cmd/config/subcommand/taskresourceattribute/update_config.go @@ -6,6 +6,7 @@ package taskresourceattribute type AttrUpdateConfig struct { AttrFile string `json:"attrFile" pflag:",attribute file name to be used for updating attribute for the resource type."` DryRun bool `json:"dryRun" pflag:",execute command without making any modifications."` + Force bool `json:"force" pflag:",do not ask for an acknowledgement during updates."` } var DefaultUpdateConfig = &AttrUpdateConfig{} diff --git a/cmd/config/subcommand/workflowexecutionconfig/attrupdateconfig_flags.go b/cmd/config/subcommand/workflowexecutionconfig/attrupdateconfig_flags.go index 571daa90d5..a8423cdb86 100755 --- a/cmd/config/subcommand/workflowexecutionconfig/attrupdateconfig_flags.go +++ b/cmd/config/subcommand/workflowexecutionconfig/attrupdateconfig_flags.go @@ -52,5 +52,6 @@ func (cfg AttrUpdateConfig) GetPFlagSet(prefix string) *pflag.FlagSet { cmdFlags := pflag.NewFlagSet("AttrUpdateConfig", pflag.ExitOnError) cmdFlags.StringVar(&DefaultUpdateConfig.AttrFile, fmt.Sprintf("%v%v", prefix, "attrFile"), DefaultUpdateConfig.AttrFile, "attribute file name to be used for updating attribute for the resource type.") cmdFlags.BoolVar(&DefaultUpdateConfig.DryRun, fmt.Sprintf("%v%v", prefix, "dryRun"), DefaultUpdateConfig.DryRun, "execute command without making any modifications.") + cmdFlags.BoolVar(&DefaultUpdateConfig.Force, fmt.Sprintf("%v%v", prefix, "force"), DefaultUpdateConfig.Force, "do not ask for an acknowledgement during updates.") return cmdFlags } diff --git a/cmd/config/subcommand/workflowexecutionconfig/attrupdateconfig_flags_test.go b/cmd/config/subcommand/workflowexecutionconfig/attrupdateconfig_flags_test.go index 5707cccbc8..5c470ca066 100755 --- a/cmd/config/subcommand/workflowexecutionconfig/attrupdateconfig_flags_test.go +++ b/cmd/config/subcommand/workflowexecutionconfig/attrupdateconfig_flags_test.go @@ -127,4 +127,18 @@ func TestAttrUpdateConfig_SetFlags(t *testing.T) { } }) }) + t.Run("Test_force", func(t *testing.T) { + + t.Run("Override", func(t *testing.T) { + testValue := "1" + + cmdFlags.Set("force", testValue) + if vBool, err := cmdFlags.GetBool("force"); err == nil { + testDecodeJson_AttrUpdateConfig(t, fmt.Sprintf("%v", vBool), &actual.Force) + + } else { + assert.FailNow(t, err.Error()) + } + }) + }) } diff --git a/cmd/config/subcommand/workflowexecutionconfig/update_config.go b/cmd/config/subcommand/workflowexecutionconfig/update_config.go index 7fd6a1de64..2b244000e2 100644 --- a/cmd/config/subcommand/workflowexecutionconfig/update_config.go +++ b/cmd/config/subcommand/workflowexecutionconfig/update_config.go @@ -6,6 +6,7 @@ package workflowexecutionconfig type AttrUpdateConfig struct { AttrFile string `json:"attrFile" pflag:",attribute file name to be used for updating attribute for the resource type."` DryRun bool `json:"dryRun" pflag:",execute command without making any modifications."` + Force bool `json:"force" pflag:",do not ask for an acknowledgement during updates."` } var DefaultUpdateConfig = &AttrUpdateConfig{} diff --git a/cmd/testutils/test_utils.go b/cmd/testutils/test_utils.go index 067720c8ad..37f71af493 100644 --- a/cmd/testutils/test_utils.go +++ b/cmd/testutils/test_utils.go @@ -6,20 +6,19 @@ import ( "fmt" "io" "log" + "math/rand" "os" "regexp" "strings" "testing" - "github.com/flyteorg/flyteidl/clients/go/admin/mocks" - - "github.com/flyteorg/flyteidl/clients/go/admin" - "github.com/stretchr/testify/assert" "github.com/flyteorg/flytectl/cmd/config" cmdCore "github.com/flyteorg/flytectl/cmd/core" extMocks "github.com/flyteorg/flytectl/pkg/ext/mocks" + "github.com/flyteorg/flyteidl/clients/go/admin" + "github.com/flyteorg/flyteidl/clients/go/admin/mocks" ) const projectValue = "dummyProject" @@ -112,6 +111,34 @@ func (s *TestStruct) TearDownAndVerify(t *testing.T, expectedLog string) { assert.Equal(t, sanitizeString(expectedLog), sanitizeString(buf.String())) } +func (s *TestStruct) TearDownAndVerifyContains(t *testing.T, expectedLog string) { + if err := s.Writer.Close(); err != nil { + panic(fmt.Errorf("could not close test context writer: %w", err)) + } + + var buf bytes.Buffer + if _, err := io.Copy(&buf, s.Reader); err != nil { + panic(fmt.Errorf("could not read from test context reader: %w", err)) + } + + assert.Contains(t, sanitizeString(buf.String()), sanitizeString(expectedLog)) +} + +// RandomName returns a string composed of random lowercase English letters of specified length. +func RandomName(length int) string { + if length < 0 { + panic("length should be a non-negative number") + } + + var b strings.Builder + for i := 0; i < length; i++ { + c := rune('a' + rand.Intn('z'-'a')) // #nosec G404 - we use this function for testing only, do not need a cryptographically secure random number generator + b.WriteRune(c) + } + + return b.String() +} + func sanitizeString(str string) string { // Not the most comprehensive ANSI pattern, but this should capture common color operations // such as \x1b[107;0m and \x1b[0m. Expand if needed (insert regex 2 problems joke here). diff --git a/cmd/update/diff.go b/cmd/update/diff.go new file mode 100644 index 0000000000..b0f9190f93 --- /dev/null +++ b/cmd/update/diff.go @@ -0,0 +1,68 @@ +package update + +import ( + "encoding/json" + "fmt" + + "github.com/hexops/gotextdiff" + "github.com/hexops/gotextdiff/myers" + "gopkg.in/yaml.v3" +) + +const ( + diffPathBefore = "before" + diffPathAfter = "after" +) + +// DiffAsYaml marshals both objects as YAML and returns differences +// between marshalled values in unified format. Marshalling respects +// JSON field annotations. +func DiffAsYaml(path1, path2 string, object1, object2 any) (string, error) { + yaml1, err := marshalToYamlString(object1) + if err != nil { + return "", fmt.Errorf("diff as yaml: %w", err) + } + + yaml2, err := marshalToYamlString(object2) + if err != nil { + return "", fmt.Errorf("diff as yaml: %w", err) + } + + patch := diffStrings(path1, path2, yaml1, yaml2) + return patch, nil +} + +// marshalToYamlString marshals value to a YAML string, while respecting +// JSON field annotations. +func marshalToYamlString(value any) (string, error) { + jsonText, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("marshalling object to json: %w", err) + } + + var jsonObject interface{} + if err := yaml.Unmarshal(jsonText, &jsonObject); err != nil { + return "", fmt.Errorf("unmarshalling yaml to object: %w", err) + } + + data, err := yaml.Marshal(jsonObject) + if err != nil { + return "", fmt.Errorf("marshalling object to yaml: %w", err) + } + + return string(data), nil +} + +// diffStrings returns differences between two strings in unified format. +// An empty string will be returned if both strings are equal. +func diffStrings(path1, path2, s1, s2 string) string { + // We add new lines at the end of each string to avoid + // "\ No newline at end of file" appended to each diff. + s1 += "\n" + s2 += "\n" + + edits := myers.ComputeEdits("", s1, s2) + diff := fmt.Sprint(gotextdiff.ToUnified(path1, path2, s1, edits)) + + return diff +} diff --git a/cmd/update/diff_test.go b/cmd/update/diff_test.go new file mode 100644 index 0000000000..0bb3df74c2 --- /dev/null +++ b/cmd/update/diff_test.go @@ -0,0 +1,62 @@ +package update + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMarshalToYamlStringRespectsJsonFieldAnnotations(t *testing.T) { + type T struct { + FieldIncluded1 int `json:"fieldIncluded1"` + FieldIncluded2 string `json:"fieldIncluded2"` + FieldOmitted string `json:"fieldOmitted,omitempty"` + } + value := T{} + + result, err := marshalToYamlString(value) + + assert.Nil(t, err) + assert.Equal(t, `fieldIncluded1: 0 +fieldIncluded2: "" +`, result) +} + +func TestDiffStringsReturnsAUnifiedDiff(t *testing.T) { + s1 := "abc\ndef\nghi" + s2 := "aaa\ndef\nghi" + + patch := diffStrings("before", "after", s1, s2) + + assert.Equal(t, `--- before ++++ after +@@ -1,3 +1,3 @@ +-abc ++aaa + def + ghi +`, patch) +} + +func TestDiffAsYamlReturnsAUnifiedDiffOfObjectsMarshalledAsYAML(t *testing.T) { + type T struct { + F1 int `json:"f1"` + F2 string `json:"f2"` + F3 string `json:"f3,omitempty"` + } + object1 := T{F1: 5, F2: "apple"} + object2 := T{F1: 10, F2: "apple", F3: "banana"} + + patch, err := DiffAsYaml("before", "after", object1, object2) + + assert.Nil(t, err) + assert.Equal(t, `--- before ++++ after +@@ -1,3 +1,4 @@ +-f1: 5 ++f1: 10 + f2: apple ++f3: banana + +`, patch) +} diff --git a/cmd/update/execution.go b/cmd/update/execution.go index 517a28a352..d3f1fccdd5 100644 --- a/cmd/update/execution.go +++ b/cmd/update/execution.go @@ -3,14 +3,15 @@ package update import ( "context" "fmt" + "os" "github.com/flyteorg/flytectl/clierrors" "github.com/flyteorg/flytectl/cmd/config" "github.com/flyteorg/flytectl/cmd/config/subcommand/execution" cmdCore "github.com/flyteorg/flytectl/cmd/core" + cmdUtil "github.com/flyteorg/flytectl/pkg/commandutils" "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/core" - "github.com/flyteorg/flytestdlib/logger" ) const ( @@ -38,36 +39,62 @@ func updateExecutionFunc(ctx context.Context, args []string, cmdCtx cmdCore.Comm return fmt.Errorf(clierrors.ErrExecutionNotPassed) } executionName := args[0] - activateExec := execution.UConfig.Activate - archiveExec := execution.UConfig.Archive - if activateExec && archiveExec { + activate := execution.UConfig.Activate + archive := execution.UConfig.Archive + if activate && archive { return fmt.Errorf(clierrors.ErrInvalidStateUpdate) } - var executionState admin.ExecutionState - if activateExec { - executionState = admin.ExecutionState_EXECUTION_ACTIVE - } else if archiveExec { - executionState = admin.ExecutionState_EXECUTION_ARCHIVED + var newState admin.ExecutionState + if activate { + newState = admin.ExecutionState_EXECUTION_ACTIVE + } else if archive { + newState = admin.ExecutionState_EXECUTION_ARCHIVED } + exec, err := cmdCtx.AdminFetcherExt().FetchExecution(ctx, executionName, project, domain) + if err != nil { + return fmt.Errorf("update execution: could not fetch execution %s: %w", executionName, err) + } + oldState := exec.GetClosure().GetStateChangeDetails().GetState() + + type Execution struct { + State admin.ExecutionState `json:"state"` + } + patch, err := DiffAsYaml(diffPathBefore, diffPathAfter, Execution{oldState}, Execution{newState}) + if err != nil { + panic(err) + } + + if patch == "" { + fmt.Printf("No changes detected. Skipping the update.\n") + return nil + } + + fmt.Printf("The following changes are to be applied.\n%s\n", patch) + if execution.UConfig.DryRun { - logger.Debugf(ctx, "skipping UpdateExecution request (DryRun)") - } else { - _, err := cmdCtx.AdminClient().UpdateExecution(ctx, &admin.ExecutionUpdateRequest{ - Id: &core.WorkflowExecutionIdentifier{ - Project: project, - Domain: domain, - Name: executionName, - }, - State: executionState, - }) - if err != nil { - fmt.Printf(clierrors.ErrFailedExecutionUpdate, executionName, err) - return err - } + fmt.Printf("skipping UpdateExecution request (DryRun)\n") + return nil + } + + if !execution.UConfig.Force && !cmdUtil.AskForConfirmation("Continue?", os.Stdin) { + return fmt.Errorf("update aborted by user") + } + + _, err = cmdCtx.AdminClient().UpdateExecution(ctx, &admin.ExecutionUpdateRequest{ + Id: &core.WorkflowExecutionIdentifier{ + Project: project, + Domain: domain, + Name: executionName, + }, + State: newState, + }) + if err != nil { + fmt.Printf(clierrors.ErrFailedExecutionUpdate, executionName, err) + return err } - fmt.Printf("updated execution %s successfully to state %s\n", executionName, executionState.String()) + fmt.Printf("updated execution %s successfully to state %s\n", executionName, newState) return nil } diff --git a/cmd/update/execution_test.go b/cmd/update/execution_test.go index 35ab591212..44a790e415 100644 --- a/cmd/update/execution_test.go +++ b/cmd/update/execution_test.go @@ -4,58 +4,240 @@ import ( "fmt" "testing" + "github.com/flyteorg/flytectl/cmd/config" "github.com/flyteorg/flytectl/cmd/config/subcommand/execution" "github.com/flyteorg/flytectl/cmd/testutils" + "github.com/flyteorg/flytectl/pkg/ext" "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) -func TestExecutionUpdate(t *testing.T) { - s := testutils.Setup() - args := []string{"execution1"} - // Activate - execution.UConfig.Activate = true - s.MockAdminClient.OnUpdateExecutionMatch(mock.Anything, mock.Anything).Return(&admin.ExecutionUpdateResponse{}, nil) - assert.Nil(t, updateExecutionFunc(s.Ctx, args, s.CmdCtx)) - // Archive - execution.UConfig.Activate = false - execution.UConfig.Archive = true - assert.Nil(t, updateExecutionFunc(s.Ctx, args, s.CmdCtx)) - // Reset - execution.UConfig.Activate = false - execution.UConfig.Archive = false - - // Dry run - execution.UConfig.DryRun = true - assert.Nil(t, updateExecutionFunc(s.Ctx, args, s.CmdCtx)) - s.MockAdminClient.AssertNotCalled(t, "UpdateExecution", mock.Anything) - - // Reset - execution.UConfig.DryRun = false -} - -func TestExecutionUpdateValidationFailure(t *testing.T) { - s := testutils.Setup() - args := []string{"execution1"} - execution.UConfig.Activate = true - execution.UConfig.Archive = true - assert.NotNil(t, updateExecutionFunc(s.Ctx, args, s.CmdCtx)) - // Reset - execution.UConfig.Activate = false - execution.UConfig.Archive = false +func TestExecutionCanBeActivated(t *testing.T) { + testExecutionUpdate( + /* setup */ func(s *testutils.TestStruct, config *execution.UpdateConfig, execution *admin.Execution) { + execution.Closure.StateChangeDetails.State = admin.ExecutionState_EXECUTION_ARCHIVED + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertCalled( + t, "UpdateExecution", s.Ctx, + mock.MatchedBy( + func(r *admin.ExecutionUpdateRequest) bool { + return r.State == admin.ExecutionState_EXECUTION_ACTIVE + })) + }) +} + +func TestExecutionCanBeArchived(t *testing.T) { + testExecutionUpdate( + /* setup */ func(s *testutils.TestStruct, config *execution.UpdateConfig, execution *admin.Execution) { + execution.Closure.StateChangeDetails.State = admin.ExecutionState_EXECUTION_ACTIVE + config.Archive = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertCalled( + t, "UpdateExecution", s.Ctx, + mock.MatchedBy( + func(r *admin.ExecutionUpdateRequest) bool { + return r.State == admin.ExecutionState_EXECUTION_ARCHIVED + })) + }) +} + +func TestExecutionCannotBeActivatedAndArchivedAtTheSameTime(t *testing.T) { + testExecutionUpdate( + /* setup */ func(s *testutils.TestStruct, config *execution.UpdateConfig, execution *admin.Execution) { + config.Activate = true + config.Archive = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "Specify either activate or archive") + s.MockAdminClient.AssertNotCalled(t, "UpdateExecution", mock.Anything, mock.Anything) + }) +} + +func TestExecutionUpdateDoesNothingWhenThereAreNoChanges(t *testing.T) { + testExecutionUpdate( + /* setup */ func(s *testutils.TestStruct, config *execution.UpdateConfig, execution *admin.Execution) { + execution.Closure.StateChangeDetails.State = admin.ExecutionState_EXECUTION_ACTIVE + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateExecution", mock.Anything, mock.Anything) + }) } -func TestExecutionUpdateFail(t *testing.T) { +func TestExecutionUpdateWithoutForceFlagFails(t *testing.T) { + testExecutionUpdate( + /* setup */ func(s *testutils.TestStruct, config *execution.UpdateConfig, execution *admin.Execution) { + execution.Closure.StateChangeDetails.State = admin.ExecutionState_EXECUTION_ARCHIVED + config.Activate = true + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.MockAdminClient.AssertNotCalled(t, "UpdateExecution", mock.Anything, mock.Anything) + }) +} + +func TestExecutionUpdateDoesNothingWithDryRunFlag(t *testing.T) { + testExecutionUpdate( + /* setup */ func(s *testutils.TestStruct, config *execution.UpdateConfig, execution *admin.Execution) { + execution.Closure.StateChangeDetails.State = admin.ExecutionState_EXECUTION_ARCHIVED + config.Activate = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateExecution", mock.Anything, mock.Anything) + }) +} + +func TestForceFlagIsIgnoredWithDryRunDuringExecutionUpdate(t *testing.T) { + t.Run("without --force", func(t *testing.T) { + testExecutionUpdate( + /* setup */ func(s *testutils.TestStruct, config *execution.UpdateConfig, execution *admin.Execution) { + execution.Closure.StateChangeDetails.State = admin.ExecutionState_EXECUTION_ARCHIVED + config.Activate = true + + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateExecution", mock.Anything, mock.Anything) + }) + }) + + t.Run("with --force", func(t *testing.T) { + testExecutionUpdate( + /* setup */ func(s *testutils.TestStruct, config *execution.UpdateConfig, execution *admin.Execution) { + execution.Closure.StateChangeDetails.State = admin.ExecutionState_EXECUTION_ARCHIVED + config.Activate = true + + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateExecution", mock.Anything, mock.Anything) + }) + }) +} + +func TestExecutionUpdateFailsWhenExecutionDoesNotExist(t *testing.T) { + testExecutionUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, execution *admin.Execution) { + s.FetcherExt. + OnFetchExecution(s.Ctx, execution.Id.Name, execution.Id.Project, execution.Id.Domain). + Return(nil, ext.NewNotFoundError("execution not found")) + s.MockAdminClient. + OnUpdateExecutionMatch(s.Ctx, mock.Anything). + Return(&admin.ExecutionUpdateResponse{}, nil) + }, + /* setup */ nil, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateExecution", mock.Anything, mock.Anything) + }, + ) +} + +func TestExecutionUpdateFailsWhenAdminClientFails(t *testing.T) { + testExecutionUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, execution *admin.Execution) { + s.FetcherExt. + OnFetchExecution(s.Ctx, execution.Id.Name, execution.Id.Project, execution.Id.Domain). + Return(execution, nil) + s.MockAdminClient. + OnUpdateExecutionMatch(s.Ctx, mock.Anything). + Return(nil, fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *execution.UpdateConfig, execution *admin.Execution) { + execution.Closure.StateChangeDetails.State = admin.ExecutionState_EXECUTION_ARCHIVED + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.MockAdminClient.AssertCalled(t, "UpdateExecution", mock.Anything, mock.Anything) + }, + ) +} + +func TestExecutionUpdateRequiresExecutionName(t *testing.T) { s := testutils.Setup() - args := []string{"execution1"} - s.MockAdminClient.OnUpdateExecutionMatch(mock.Anything, mock.Anything).Return(nil, fmt.Errorf("failed to update")) - assert.NotNil(t, updateExecutionFunc(s.Ctx, args, s.CmdCtx)) + err := updateExecutionFunc(s.Ctx, nil, s.CmdCtx) + + assert.ErrorContains(t, err, "execution name wasn't passed") +} + +func testExecutionUpdate( + setup func(s *testutils.TestStruct, config *execution.UpdateConfig, execution *admin.Execution), + asserter func(s *testutils.TestStruct, err error), +) { + testExecutionUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, execution *admin.Execution) { + s.FetcherExt. + OnFetchExecution(s.Ctx, execution.Id.Name, execution.Id.Project, execution.Id.Domain). + Return(execution, nil) + s.MockAdminClient. + OnUpdateExecutionMatch(s.Ctx, mock.Anything). + Return(&admin.ExecutionUpdateResponse{}, nil) + }, + setup, + asserter, + ) } -func TestExecutionUpdateInvalidArgs(t *testing.T) { +func testExecutionUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, execution *admin.Execution), + setup func(s *testutils.TestStruct, config *execution.UpdateConfig, execution *admin.Execution), + asserter func(s *testutils.TestStruct, err error), +) { s := testutils.Setup() - args := []string{} - assert.NotNil(t, updateExecutionFunc(s.Ctx, args, s.CmdCtx)) + target := newTestExecution() + + if mockSetup != nil { + mockSetup(&s, target) + } + + execution.UConfig = &execution.UpdateConfig{} + if setup != nil { + setup(&s, execution.UConfig, target) + } + + args := []string{target.Id.Name} + err := updateExecutionFunc(s.Ctx, args, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + execution.UConfig = &execution.UpdateConfig{} +} + +func newTestExecution() *admin.Execution { + return &admin.Execution{ + Id: &core.WorkflowExecutionIdentifier{ + Name: testutils.RandomName(12), + Project: config.GetConfig().Project, + Domain: config.GetConfig().Domain, + }, + Closure: &admin.ExecutionClosure{ + StateChangeDetails: &admin.ExecutionStateChangeDetails{ + State: admin.ExecutionState_EXECUTION_ACTIVE, + }, + }, + } } diff --git a/cmd/update/launch_plan.go b/cmd/update/launch_plan.go index 369f756cd7..f1a9474e7c 100644 --- a/cmd/update/launch_plan.go +++ b/cmd/update/launch_plan.go @@ -3,14 +3,15 @@ package update import ( "context" "fmt" + "os" "github.com/flyteorg/flytectl/clierrors" "github.com/flyteorg/flytectl/cmd/config" "github.com/flyteorg/flytectl/cmd/config/subcommand/launchplan" cmdCore "github.com/flyteorg/flytectl/cmd/core" + cmdUtil "github.com/flyteorg/flytectl/pkg/commandutils" "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/core" - "github.com/flyteorg/flytestdlib/logger" ) const ( @@ -41,37 +42,67 @@ func updateLPFunc(ctx context.Context, args []string, cmdCtx cmdCore.CommandCont if len(version) == 0 { return fmt.Errorf(clierrors.ErrLPVersionNotPassed) } - activateLP := launchplan.UConfig.Activate - archiveLP := launchplan.UConfig.Archive - if activateLP == archiveLP && archiveLP { + + activate := launchplan.UConfig.Activate + archive := launchplan.UConfig.Archive + if activate == archive && archive { return fmt.Errorf(clierrors.ErrInvalidStateUpdate) } - var lpState admin.LaunchPlanState - if activateLP { - lpState = admin.LaunchPlanState_ACTIVE - } else if archiveLP { - lpState = admin.LaunchPlanState_INACTIVE + var newState admin.LaunchPlanState + if activate { + newState = admin.LaunchPlanState_ACTIVE + } else if archive { + newState = admin.LaunchPlanState_INACTIVE + } + + id := &core.Identifier{ + Project: project, + Domain: domain, + Name: name, + Version: version, + ResourceType: core.ResourceType_LAUNCH_PLAN, + } + + launchPlan, err := cmdCtx.AdminClient().GetLaunchPlan(ctx, &admin.ObjectGetRequest{Id: id}) + if err != nil { + return fmt.Errorf("update launch plan %s: could not fetch launch plan: %w", name, err) + } + oldState := launchPlan.GetClosure().GetState() + + type LaunchPlan struct { + State admin.LaunchPlanState `json:"state"` + } + patch, err := DiffAsYaml(diffPathBefore, diffPathAfter, LaunchPlan{oldState}, LaunchPlan{newState}) + if err != nil { + panic(err) + } + + if patch == "" { + fmt.Printf("No changes detected. Skipping the update.\n") + return nil } + fmt.Printf("The following changes are to be applied.\n%s\n", patch) + if launchplan.UConfig.DryRun { - logger.Debugf(ctx, "skipping CreateExecution request (DryRun)") - } else { - _, err := cmdCtx.AdminClient().UpdateLaunchPlan(ctx, &admin.LaunchPlanUpdateRequest{ - Id: &core.Identifier{ - Project: project, - Domain: domain, - Name: name, - Version: version, - }, - State: lpState, - }) - if err != nil { - fmt.Printf(clierrors.ErrFailedLPUpdate, name, err) - return err - } + fmt.Printf("skipping LaunchPlanUpdate request (DryRun)") + return nil + } + + if !launchplan.UConfig.Force && !cmdUtil.AskForConfirmation("Continue?", os.Stdin) { + return fmt.Errorf("update aborted by user") + } + + _, err = cmdCtx.AdminClient().UpdateLaunchPlan(ctx, &admin.LaunchPlanUpdateRequest{ + Id: id, + State: newState, + }) + if err != nil { + return fmt.Errorf(clierrors.ErrFailedLPUpdate, name, err) } - fmt.Printf("updated launchplan successfully on %v", name) + + fmt.Printf("updated launch plan successfully on %s", name) return nil } diff --git a/cmd/update/launch_plan_meta.go b/cmd/update/launch_plan_meta.go index e9aa1ae9a1..123413d6bf 100644 --- a/cmd/update/launch_plan_meta.go +++ b/cmd/update/launch_plan_meta.go @@ -42,8 +42,7 @@ func getUpdateLPMetaFunc(namedEntityConfig *NamedEntityConfig) func(ctx context. name := args[0] err := namedEntityConfig.UpdateNamedEntity(ctx, name, project, domain, core.ResourceType_LAUNCH_PLAN, cmdCtx) if err != nil { - fmt.Printf(clierrors.ErrFailedLPUpdate, name, err) - return err + return fmt.Errorf(clierrors.ErrFailedLPUpdate, name, err) } fmt.Printf("updated metadata successfully on %v", name) return nil diff --git a/cmd/update/launch_plan_meta_test.go b/cmd/update/launch_plan_meta_test.go index f0119b9eb1..84187be99d 100644 --- a/cmd/update/launch_plan_meta_test.go +++ b/cmd/update/launch_plan_meta_test.go @@ -4,32 +4,193 @@ import ( "fmt" "testing" + "github.com/google/go-cmp/cmp" + "github.com/flyteorg/flytectl/cmd/testutils" + "github.com/flyteorg/flytectl/pkg/ext" "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) -func TestLPMetaUpdate(t *testing.T) { - s := testutils.Setup() - namedEntityConfig := &NamedEntityConfig{} - args := []string{"task1"} - s.MockAdminClient.OnUpdateNamedEntityMatch(mock.Anything, mock.Anything).Return(&admin.NamedEntityUpdateResponse{}, nil) - assert.Nil(t, getUpdateLPMetaFunc(namedEntityConfig)(s.Ctx, args, s.CmdCtx)) +func TestLaunchPlanMetadataCanBeActivated(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_LAUNCH_PLAN, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertCalled( + t, "UpdateNamedEntity", s.Ctx, + mock.MatchedBy( + func(r *admin.NamedEntityUpdateRequest) bool { + return r.GetMetadata().GetState() == admin.NamedEntityState_NAMED_ENTITY_ACTIVE + })) + }) } -func TestLPMetaUpdateFail(t *testing.T) { - s := testutils.Setup() - namedEntityConfig := &NamedEntityConfig{} - args := []string{"task1"} - s.MockAdminClient.OnUpdateNamedEntityMatch(mock.Anything, mock.Anything).Return(nil, fmt.Errorf("failed to update")) - assert.NotNil(t, getUpdateTaskFunc(namedEntityConfig)(s.Ctx, args, s.CmdCtx)) +func TestLaunchPlanMetadataCanBeArchived(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_LAUNCH_PLAN, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ACTIVE + config.Archive = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertCalled( + t, "UpdateNamedEntity", s.Ctx, + mock.MatchedBy( + func(r *admin.NamedEntityUpdateRequest) bool { + return r.GetMetadata().GetState() == admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + })) + }) +} + +func TestLaunchPlanMetadataCannotBeActivatedAndArchivedAtTheSameTime(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_LAUNCH_PLAN, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + config.Activate = true + config.Archive = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "Specify either activate or archive") + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) +} + +func TestLaunchPlanMetadataUpdateDoesNothingWhenThereAreNoChanges(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_LAUNCH_PLAN, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ACTIVE + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) +} + +func TestLaunchPlanMetadataUpdateWithoutForceFlagFails(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_LAUNCH_PLAN, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) +} + +func TestLaunchPlanMetadataUpdateDoesNothingWithDryRunFlag(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_LAUNCH_PLAN, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) } -func TestLPMetaUpdateInvalidArgs(t *testing.T) { +func TestForceFlagIsIgnoredWithDryRunDuringLaunchPlanMetadataUpdate(t *testing.T) { + t.Run("without --force", func(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_LAUNCH_PLAN, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) + }) + + t.Run("with --force", func(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_LAUNCH_PLAN, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) + }) +} + +func TestLaunchPlanMetadataUpdateFailsWhenLaunchPlanDoesNotExist(t *testing.T) { + testNamedEntityUpdateWithMockSetup( + core.ResourceType_LAUNCH_PLAN, + /* mockSetup */ func(s *testutils.TestStruct, namedEntity *admin.NamedEntity) { + s.MockAdminClient. + OnGetNamedEntityMatch( + s.Ctx, + mock.MatchedBy(func(r *admin.NamedEntityGetRequest) bool { + return r.ResourceType == namedEntity.ResourceType && + cmp.Equal(r.Id, namedEntity.Id) + })). + Return(nil, ext.NewNotFoundError("named entity not found")) + s.MockAdminClient. + OnUpdateNamedEntityMatch(s.Ctx, mock.Anything). + Return(&admin.NamedEntityUpdateResponse{}, nil) + }, + /* setup */ nil, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }, + ) +} + +func TestLaunchPlanMetadataUpdateFailsWhenAdminClientFails(t *testing.T) { + testNamedEntityUpdateWithMockSetup( + core.ResourceType_LAUNCH_PLAN, + /* mockSetup */ func(s *testutils.TestStruct, namedEntity *admin.NamedEntity) { + s.MockAdminClient. + OnGetNamedEntityMatch( + s.Ctx, + mock.MatchedBy(func(r *admin.NamedEntityGetRequest) bool { + return r.ResourceType == namedEntity.ResourceType && + cmp.Equal(r.Id, namedEntity.Id) + })). + Return(namedEntity, nil) + s.MockAdminClient. + OnUpdateNamedEntityMatch(s.Ctx, mock.Anything). + Return(nil, fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.MockAdminClient.AssertCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }, + ) +} + +func TestLaunchPlanMetadataUpdateRequiresLaunchPlanName(t *testing.T) { s := testutils.Setup() - namedEntityConfig := &NamedEntityConfig{} - args := []string{} - assert.NotNil(t, getUpdateTaskFunc(namedEntityConfig)(s.Ctx, args, s.CmdCtx)) + config := &NamedEntityConfig{} + + err := getUpdateLPMetaFunc(config)(s.Ctx, nil, s.CmdCtx) + + assert.ErrorContains(t, err, "launch plan name wasn't passed") } diff --git a/cmd/update/launch_plan_test.go b/cmd/update/launch_plan_test.go index 0e5010c5cf..11eb15f8f0 100644 --- a/cmd/update/launch_plan_test.go +++ b/cmd/update/launch_plan_test.go @@ -4,33 +4,274 @@ import ( "fmt" "testing" + "github.com/google/go-cmp/cmp" + + "github.com/flyteorg/flytectl/cmd/config" "github.com/flyteorg/flytectl/cmd/config/subcommand/launchplan" "github.com/flyteorg/flytectl/cmd/testutils" + "github.com/flyteorg/flytectl/pkg/ext" "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) -func TestLPUpdate(t *testing.T) { +func TestLaunchPlanCanBeActivated(t *testing.T) { + testLaunchPlanUpdate( + /* setup */ func(s *testutils.TestStruct, config *launchplan.UpdateConfig, launchplan *admin.LaunchPlan) { + launchplan.Closure.State = admin.LaunchPlanState_INACTIVE + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertCalled( + t, "UpdateLaunchPlan", s.Ctx, + mock.MatchedBy( + func(r *admin.LaunchPlanUpdateRequest) bool { + return r.State == admin.LaunchPlanState_ACTIVE + })) + }) +} + +func TestLaunchPlanCanBeArchived(t *testing.T) { + testLaunchPlanUpdate( + /* setup */ func(s *testutils.TestStruct, config *launchplan.UpdateConfig, launchplan *admin.LaunchPlan) { + launchplan.Closure.State = admin.LaunchPlanState_ACTIVE + config.Archive = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertCalled( + t, "UpdateLaunchPlan", s.Ctx, + mock.MatchedBy( + func(r *admin.LaunchPlanUpdateRequest) bool { + return r.State == admin.LaunchPlanState_INACTIVE + })) + }) +} + +func TestLaunchPlanCannotBeActivatedAndArchivedAtTheSameTime(t *testing.T) { + testLaunchPlanUpdate( + /* setup */ func(s *testutils.TestStruct, config *launchplan.UpdateConfig, launchplan *admin.LaunchPlan) { + config.Activate = true + config.Archive = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "Specify either activate or archive") + s.MockAdminClient.AssertNotCalled(t, "UpdateLaunchPlan", mock.Anything, mock.Anything) + }) +} + +func TestLaunchPlanUpdateDoesNothingWhenThereAreNoChanges(t *testing.T) { + testLaunchPlanUpdate( + /* setup */ func(s *testutils.TestStruct, config *launchplan.UpdateConfig, launchplan *admin.LaunchPlan) { + launchplan.Closure.State = admin.LaunchPlanState_ACTIVE + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateLaunchPlan", mock.Anything, mock.Anything) + }) +} + +func TestLaunchPlanUpdateWithoutForceFlagFails(t *testing.T) { + testLaunchPlanUpdate( + /* setup */ func(s *testutils.TestStruct, config *launchplan.UpdateConfig, launchplan *admin.LaunchPlan) { + launchplan.Closure.State = admin.LaunchPlanState_INACTIVE + config.Activate = true + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.MockAdminClient.AssertNotCalled(t, "UpdateLaunchPlan", mock.Anything, mock.Anything) + }) +} + +func TestLaunchPlanUpdateDoesNothingWithDryRunFlag(t *testing.T) { + testLaunchPlanUpdate( + /* setup */ func(s *testutils.TestStruct, config *launchplan.UpdateConfig, launchplan *admin.LaunchPlan) { + launchplan.Closure.State = admin.LaunchPlanState_INACTIVE + config.Activate = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateLaunchPlan", mock.Anything, mock.Anything) + }) +} + +func TestForceFlagIsIgnoredWithDryRunDuringLaunchPlanUpdate(t *testing.T) { + t.Run("without --force", func(t *testing.T) { + testLaunchPlanUpdate( + /* setup */ func(s *testutils.TestStruct, config *launchplan.UpdateConfig, launchplan *admin.LaunchPlan) { + launchplan.Closure.State = admin.LaunchPlanState_INACTIVE + config.Activate = true + + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateLaunchPlan", mock.Anything, mock.Anything) + }) + }) + + t.Run("with --force", func(t *testing.T) { + testLaunchPlanUpdate( + /* setup */ func(s *testutils.TestStruct, config *launchplan.UpdateConfig, launchplan *admin.LaunchPlan) { + launchplan.Closure.State = admin.LaunchPlanState_INACTIVE + config.Activate = true + + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateLaunchPlan", mock.Anything, mock.Anything) + }) + }) +} + +func TestLaunchPlanUpdateFailsWhenLaunchPlanDoesNotExist(t *testing.T) { + testLaunchPlanUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, launchplan *admin.LaunchPlan) { + s.MockAdminClient. + OnGetLaunchPlanMatch( + s.Ctx, + mock.MatchedBy(func(r *admin.ObjectGetRequest) bool { + return cmp.Equal(r.Id, launchplan.Id) + })). + Return(nil, ext.NewNotFoundError("launch plan not found")) + s.MockAdminClient. + OnUpdateLaunchPlanMatch(s.Ctx, mock.Anything). + Return(&admin.LaunchPlanUpdateResponse{}, nil) + }, + /* setup */ nil, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateLaunchPlan", mock.Anything, mock.Anything) + }, + ) +} + +func TestLaunchPlanUpdateFailsWhenAdminClientFails(t *testing.T) { + testLaunchPlanUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, launchplan *admin.LaunchPlan) { + s.MockAdminClient. + OnGetLaunchPlanMatch( + s.Ctx, + mock.MatchedBy(func(r *admin.ObjectGetRequest) bool { + return cmp.Equal(r.Id, launchplan.Id) + })). + Return(launchplan, nil) + s.MockAdminClient. + OnUpdateLaunchPlanMatch(s.Ctx, mock.Anything). + Return(nil, fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *launchplan.UpdateConfig, launchplan *admin.LaunchPlan) { + launchplan.Closure.State = admin.LaunchPlanState_INACTIVE + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.MockAdminClient.AssertCalled(t, "UpdateLaunchPlan", mock.Anything, mock.Anything) + }, + ) +} + +func TestLaunchPlanUpdateRequiresLaunchPlanName(t *testing.T) { s := testutils.Setup() - launchplan.UConfig = &launchplan.UpdateConfig{Version: "v1", Archive: true} - args := []string{"lp1"} - s.MockAdminClient.OnUpdateLaunchPlanMatch(mock.Anything, mock.Anything).Return(&admin.LaunchPlanUpdateResponse{}, nil) - assert.Nil(t, updateLPFunc(s.Ctx, args, s.CmdCtx)) + launchplan.UConfig = &launchplan.UpdateConfig{} + + launchplan.UConfig.Version = testutils.RandomName(2) + err := updateLPFunc(s.Ctx, nil, s.CmdCtx) + + assert.ErrorContains(t, err, "launch plan name wasn't passed") + + // cleanup + launchplan.UConfig = &launchplan.UpdateConfig{} } -func TestLPUpdateFail(t *testing.T) { +func TestLaunchPlanUpdateRequiresLaunchPlanVersion(t *testing.T) { s := testutils.Setup() - launchplan.UConfig = &launchplan.UpdateConfig{Version: "v1", Archive: true} - args := []string{"task1"} - s.MockAdminClient.OnUpdateLaunchPlanMatch(mock.Anything, mock.Anything).Return(nil, fmt.Errorf("failed to update")) - assert.NotNil(t, updateLPFunc(s.Ctx, args, s.CmdCtx)) + launchplan.UConfig = &launchplan.UpdateConfig{} + + name := testutils.RandomName(12) + err := updateLPFunc(s.Ctx, []string{name}, s.CmdCtx) + + assert.ErrorContains(t, err, "launch plan version wasn't passed") + + // cleanup + launchplan.UConfig = &launchplan.UpdateConfig{} } -func TestLPUpdateInvalidArgs(t *testing.T) { +func testLaunchPlanUpdate( + setup func(s *testutils.TestStruct, config *launchplan.UpdateConfig, launchplan *admin.LaunchPlan), + asserter func(s *testutils.TestStruct, err error), +) { + testLaunchPlanUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, launchplan *admin.LaunchPlan) { + s.MockAdminClient. + OnGetLaunchPlanMatch( + s.Ctx, + mock.MatchedBy(func(r *admin.ObjectGetRequest) bool { + return cmp.Equal(r.Id, launchplan.Id) + })). + Return(launchplan, nil) + s.MockAdminClient. + OnUpdateLaunchPlanMatch(s.Ctx, mock.Anything). + Return(&admin.LaunchPlanUpdateResponse{}, nil) + }, + setup, + asserter, + ) +} + +func testLaunchPlanUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, launchplan *admin.LaunchPlan), + setup func(s *testutils.TestStruct, config *launchplan.UpdateConfig, launchplan *admin.LaunchPlan), + asserter func(s *testutils.TestStruct, err error), +) { s := testutils.Setup() - launchplan.UConfig = &launchplan.UpdateConfig{Version: "v1", Archive: true, Activate: true} - args := []string{} - assert.NotNil(t, updateLPFunc(s.Ctx, args, s.CmdCtx)) + target := newTestLaunchPlan() + + if mockSetup != nil { + mockSetup(&s, target) + } + + launchplan.UConfig = &launchplan.UpdateConfig{} + if setup != nil { + setup(&s, launchplan.UConfig, target) + } + + args := []string{target.Id.Name} + launchplan.UConfig.Version = target.Id.Version + err := updateLPFunc(s.Ctx, args, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + launchplan.UConfig = &launchplan.UpdateConfig{} +} + +func newTestLaunchPlan() *admin.LaunchPlan { + return &admin.LaunchPlan{ + Id: &core.Identifier{ + Name: testutils.RandomName(12), + Project: config.GetConfig().Project, + Domain: config.GetConfig().Domain, + ResourceType: core.ResourceType_LAUNCH_PLAN, + Version: testutils.RandomName(2), + }, + Closure: &admin.LaunchPlanClosure{ + State: admin.LaunchPlanState_ACTIVE, + }, + } } diff --git a/cmd/update/matchable_attribute_util.go b/cmd/update/matchable_attribute_util.go index 23efa16912..c978d9dd97 100644 --- a/cmd/update/matchable_attribute_util.go +++ b/cmd/update/matchable_attribute_util.go @@ -3,45 +3,170 @@ package update import ( "context" "fmt" + "os" sconfig "github.com/flyteorg/flytectl/cmd/config/subcommand" + cmdCore "github.com/flyteorg/flytectl/cmd/core" + cmdUtil "github.com/flyteorg/flytectl/pkg/commandutils" "github.com/flyteorg/flytectl/pkg/ext" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" ) -func DecorateAndUpdateMatchableAttr(ctx context.Context, project, domain, workflowName string, - updater ext.AdminUpdaterExtInterface, mcDecorator sconfig.MatchableAttributeDecorator, dryRun bool) error { - matchingAttr := mcDecorator.Decorate() - if len(workflowName) > 0 { - // Update the workflow attribute using the admin. - if dryRun { - fmt.Printf("skipping UpdateWorkflowAttributes request (dryRun)\n") - } else { - err := updater.UpdateWorkflowAttributes(ctx, project, domain, workflowName, matchingAttr) - if err != nil { - return err - } - } - fmt.Printf("Updated attributes from %v project and domain %v and workflow %v\n", project, domain, workflowName) - } else { - // Update the project domain attribute using the admin. - if dryRun { - fmt.Printf("skipping UpdateProjectDomainAttributes request (dryRun)\n") - } else { - if len(domain) == 0 { - err := updater.UpdateProjectAttributes(ctx, project, matchingAttr) - if err != nil { - return err - } - fmt.Printf("Updated attributes from %v project\n", project) - } else { - err := updater.UpdateProjectDomainAttributes(ctx, project, domain, matchingAttr) - if err != nil { - return err - } - fmt.Printf("Updated attributes from %v project and domain %v\n", project, domain) - } - } +func DecorateAndUpdateMatchableAttr( + ctx context.Context, + cmdCtx cmdCore.CommandContext, + project, domain, workflow string, + resourceType admin.MatchableResource, + attributeDecorator sconfig.MatchableAttributeDecorator, + dryRun bool, + force bool, +) error { + if project == "" { + return fmt.Errorf("project is required") + } + if domain == "" && workflow != "" { + return fmt.Errorf("domain is required") + } + + switch { + case workflow != "": + return updateWorkflowMatchableAttributes(ctx, cmdCtx, project, domain, workflow, resourceType, attributeDecorator, dryRun, force) + case domain != "": + return updateProjectDomainMatchableAttributes(ctx, cmdCtx, project, domain, resourceType, attributeDecorator, dryRun, force) + default: + return updateProjectMatchableAttributes(ctx, cmdCtx, project, resourceType, attributeDecorator, dryRun, force) + } +} + +func updateProjectMatchableAttributes( + ctx context.Context, + cmdCtx cmdCore.CommandContext, + project string, + resourceType admin.MatchableResource, + attributeDecorator sconfig.MatchableAttributeDecorator, + dryRun bool, + force bool, +) error { + if project == "" { + panic("project is empty") + } + + response, err := cmdCtx.AdminFetcherExt().FetchProjectAttributes(ctx, project, resourceType) + if err != nil && !ext.IsNotFoundError(err) { + return fmt.Errorf("update project %s matchable attributes: could not fetch attributes: %w", project, err) + } + + oldMatchingAttributes := response.GetAttributes().GetMatchingAttributes() + newMatchingAttributes := attributeDecorator.Decorate() + + if confirmed, err := confirmMatchableAttributeUpdate(oldMatchingAttributes, newMatchingAttributes, dryRun, force); err != nil || !confirmed { + return err + } + + if err := cmdCtx.AdminUpdaterExt().UpdateProjectAttributes(ctx, project, newMatchingAttributes); err != nil { + return fmt.Errorf("update project %s matchable attributes: update failed: %w", project, err) + } + + fmt.Printf("Updated attributes from %s project\n", project) + return nil +} + +func updateProjectDomainMatchableAttributes( + ctx context.Context, + cmdCtx cmdCore.CommandContext, + project, domain string, + resourceType admin.MatchableResource, + attributeDecorator sconfig.MatchableAttributeDecorator, + dryRun bool, + force bool, +) error { + if project == "" { + panic("project is empty") + } + if domain == "" { + panic("domain is empty") + } + response, err := cmdCtx.AdminFetcherExt().FetchProjectDomainAttributes(ctx, project, domain, resourceType) + if err != nil && !ext.IsNotFoundError(err) { + return fmt.Errorf("update project %s domain %s matchable attributes: could not fetch attributes: %w", project, domain, err) } + + oldMatchingAttributes := response.GetAttributes().GetMatchingAttributes() + newMatchingAttributes := attributeDecorator.Decorate() + + if confirmed, err := confirmMatchableAttributeUpdate(oldMatchingAttributes, newMatchingAttributes, dryRun, force); err != nil || !confirmed { + return err + } + + if err := cmdCtx.AdminUpdaterExt().UpdateProjectDomainAttributes(ctx, project, domain, newMatchingAttributes); err != nil { + return fmt.Errorf("update project %s domain %s matchable attributes: update failed: %w", project, domain, err) + } + + fmt.Printf("Updated attributes from %s project and domain %s\n", project, domain) return nil } + +func updateWorkflowMatchableAttributes( + ctx context.Context, + cmdCtx cmdCore.CommandContext, + project, domain, workflow string, + resourceType admin.MatchableResource, + attributeDecorator sconfig.MatchableAttributeDecorator, + dryRun bool, + force bool, +) error { + if project == "" { + panic("project is empty") + } + if domain == "" { + panic("domain is empty") + } + if workflow == "" { + panic("workflow is empty") + } + + response, err := cmdCtx.AdminFetcherExt().FetchWorkflowAttributes(ctx, project, domain, workflow, resourceType) + if err != nil && !ext.IsNotFoundError(err) { + return fmt.Errorf("update project %s domain %s workflow %s matchable attributes: could not fetch attributes: %w", project, domain, workflow, err) + } + + oldMatchingAttributes := response.GetAttributes().GetMatchingAttributes() + newMatchingAttributes := attributeDecorator.Decorate() + + if confirmed, err := confirmMatchableAttributeUpdate(oldMatchingAttributes, newMatchingAttributes, dryRun, force); err != nil || !confirmed { + return err + } + + if err := cmdCtx.AdminUpdaterExt().UpdateWorkflowAttributes(ctx, project, domain, workflow, newMatchingAttributes); err != nil { + return fmt.Errorf("update project %s domain %s workflow %s matchable attributes: update failed: %w", project, domain, workflow, err) + } + + fmt.Printf("Updated attributes from %s project and domain %s and workflow %s\n", project, domain, workflow) + return nil +} + +func confirmMatchableAttributeUpdate(old, new *admin.MatchingAttributes, dryRun, force bool) (bool, error) { + patch, err := DiffAsYaml(diffPathBefore, diffPathAfter, old.GetTarget(), new.GetTarget()) + if err != nil { + return false, fmt.Errorf("update matchable attributes: %w", err) + } + + if patch == "" { + fmt.Printf("No changes detected. Skipping the update.\n") + return false, nil + } + + fmt.Printf("The following changes are to be applied.\n%s\n", patch) + + if dryRun { + fmt.Printf("Skipping update request (dryRun)\n") + return false, nil + } + + if !force && !cmdUtil.AskForConfirmation("Continue?", os.Stdin) { + return false, fmt.Errorf("update aborted by user") + } + + return true, nil +} diff --git a/cmd/update/matchable_cluster_resource_attribute.go b/cmd/update/matchable_cluster_resource_attribute.go index 79c03480ba..d18896cac5 100644 --- a/cmd/update/matchable_cluster_resource_attribute.go +++ b/cmd/update/matchable_cluster_resource_attribute.go @@ -7,6 +7,7 @@ import ( sconfig "github.com/flyteorg/flytectl/cmd/config/subcommand" "github.com/flyteorg/flytectl/cmd/config/subcommand/clusterresourceattribute" cmdCore "github.com/flyteorg/flytectl/cmd/core" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" ) const ( @@ -71,9 +72,9 @@ func updateClusterResourceAttributesFunc(ctx context.Context, args []string, cmd domain := clustrResourceAttrFileConfig.Domain workflowName := clustrResourceAttrFileConfig.Workflow - // Updates the admin matchable attribute from taskResourceAttrFileConfig - if err := DecorateAndUpdateMatchableAttr(ctx, project, domain, workflowName, cmdCtx.AdminUpdaterExt(), - clustrResourceAttrFileConfig, updateConfig.DryRun); err != nil { + if err := DecorateAndUpdateMatchableAttr(ctx, cmdCtx, project, domain, workflowName, + admin.MatchableResource_CLUSTER_RESOURCE, clustrResourceAttrFileConfig, + updateConfig.DryRun, updateConfig.Force); err != nil { return err } return nil diff --git a/cmd/update/matchable_cluster_resource_attribute_test.go b/cmd/update/matchable_cluster_resource_attribute_test.go index 8b6f8b2faf..f355a53656 100644 --- a/cmd/update/matchable_cluster_resource_attribute_test.go +++ b/cmd/update/matchable_cluster_resource_attribute_test.go @@ -8,83 +8,564 @@ import ( "github.com/stretchr/testify/mock" "github.com/flyteorg/flytectl/cmd/config/subcommand/clusterresourceattribute" + "github.com/flyteorg/flytectl/cmd/testutils" + "github.com/flyteorg/flytectl/pkg/ext" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" ) -func updateClusterResourceAttributeSetup() { - clusterresourceattribute.DefaultUpdateConfig = &clusterresourceattribute.AttrUpdateConfig{} +const ( + validWorkflowClusterResourceAttributesFilePath = "testdata/valid_workflow_cluster_attribute.yaml" + validProjectDomainClusterResourceAttributesFilePath = "testdata/valid_project_domain_cluster_attribute.yaml" + validProjectClusterResourceAttributesFilePath = "testdata/valid_project_cluster_attribute.yaml" +) + +func TestClusterResourceAttributeUpdateRequiresAttributeFile(t *testing.T) { + testWorkflowClusterResourceAttributeUpdate( + /* setup */ nil, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "attrFile is mandatory") + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestClusterResourceAttributeUpdateFailsWhenAttributeFileDoesNotExist(t *testing.T) { + testWorkflowClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = testDataNonExistentFile + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "unable to read from testdata/non-existent-file yaml file") + s.UpdaterExt.AssertNotCalled(t, "FetchWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestClusterResourceAttributeUpdateFailsWhenAttributeFileIsMalformed(t *testing.T) { + testWorkflowClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = testDataInvalidAttrFile + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "error unmarshaling JSON: while decoding JSON: json: unknown field \"InvalidDomain\"") + s.UpdaterExt.AssertNotCalled(t, "FetchWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestClusterResourceAttributeUpdateHappyPath(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowClusterResourceAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainClusterResourceAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectClusterResourceAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project`) + }) + }) +} + +func TestClusterResourceAttributeUpdateFailsWithoutForceFlag(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowClusterResourceAttributesFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainClusterResourceAttributesFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectClusterResourceAttributesFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func TestClusterResourceAttributeUpdateDoesNothingWithDryRunFlag(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowClusterResourceAttributesFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainClusterResourceAttributesFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectClusterResourceAttributesFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) } -func TestUpdateClusterResourceAttributes(t *testing.T) { - t.Run("no input file for update", func(t *testing.T) { - s := setup() - updateClusterResourceAttributeSetup() - err := updateClusterResourceAttributesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("attrFile is mandatory while calling update for cluster resource attribute"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("successfully updated project domain attribute", func(t *testing.T) { - s := setup() - updateClusterResourceAttributeSetup() - clusterresourceattribute.DefaultUpdateConfig.AttrFile = "testdata/valid_project_domain_cluster_attribute.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateProjectDomainAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything).Return(nil) - err := updateClusterResourceAttributesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.Nil(t, err) - s.TearDownAndVerify(t, `Updated attributes from flytesnacks project and domain development`) - }) - t.Run("failed to update project domain attribute", func(t *testing.T) { - s := setup() - updateClusterResourceAttributeSetup() - clusterresourceattribute.DefaultUpdateConfig.AttrFile = "testdata/valid_project_domain_cluster_attribute.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateProjectDomainAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything).Return(fmt.Errorf("failed to update attributes")) - err := updateClusterResourceAttributesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("failed to update attributes"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("successfully updated workflow attribute", func(t *testing.T) { - s := setup() - updateClusterResourceAttributeSetup() - clusterresourceattribute.DefaultUpdateConfig.AttrFile = "testdata/valid_workflow_cluster_attribute.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateWorkflowAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything).Return(nil) - err := updateClusterResourceAttributesFunc(s.Ctx, nil, s.CmdCtx) - assert.Nil(t, err) - s.TearDownAndVerify(t, `Updated attributes from flytesnacks project and domain development and workflow core.control_flow.merge_sort.merge_sort`) - }) - t.Run("failed to update workflow attribute", func(t *testing.T) { - s := setup() - updateClusterResourceAttributeSetup() - clusterresourceattribute.DefaultUpdateConfig.AttrFile = "testdata/valid_workflow_cluster_attribute.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateWorkflowAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything).Return(fmt.Errorf("failed to update attributes")) - err := updateClusterResourceAttributesFunc(s.Ctx, nil, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("failed to update attributes"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("non existent file", func(t *testing.T) { - s := setup() - updateClusterResourceAttributeSetup() - clusterresourceattribute.DefaultUpdateConfig.AttrFile = testDataNonExistentFile - err := updateClusterResourceAttributesFunc(s.Ctx, nil, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("unable to read from testdata/non-existent-file yaml file"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("invalid update file", func(t *testing.T) { - s := setup() - updateClusterResourceAttributeSetup() - clusterresourceattribute.DefaultUpdateConfig.AttrFile = testDataInvalidAttrFile - err := updateClusterResourceAttributesFunc(s.Ctx, nil, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("error unmarshaling JSON: while decoding JSON: json: unknown field \"InvalidDomain\""), err) - s.TearDownAndVerify(t, ``) +func TestClusterResourceAttributeUpdateIgnoresForceFlagWithDryRun(t *testing.T) { + t.Run("workflow without --force", func(t *testing.T) { + testWorkflowClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowClusterResourceAttributesFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("workflow with --force", func(t *testing.T) { + testWorkflowClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowClusterResourceAttributesFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain without --force", func(t *testing.T) { + testProjectDomainClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainClusterResourceAttributesFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) }) + + t.Run("domain with --force", func(t *testing.T) { + testProjectDomainClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainClusterResourceAttributesFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project without --force", func(t *testing.T) { + testProjectClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectClusterResourceAttributesFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project with --force", func(t *testing.T) { + testProjectClusterResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectClusterResourceAttributesFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func TestClusterResourceAttributeUpdateSucceedsWhenAttributesDoNotExist(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowClusterResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_CLUSTER_RESOURCE). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowClusterResourceAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainClusterResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_CLUSTER_RESOURCE). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainClusterResourceAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectClusterResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_CLUSTER_RESOURCE). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectClusterResourceAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project`) + }) + }) +} + +func TestClusterResourceAttributeUpdateFailsWhenAdminClientFails(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowClusterResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_CLUSTER_RESOURCE). + Return(&admin.WorkflowAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowClusterResourceAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainClusterResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_CLUSTER_RESOURCE). + Return(&admin.ProjectDomainAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainClusterResourceAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectClusterResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_CLUSTER_RESOURCE). + Return(&admin.ProjectAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectClusterResourceAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func testWorkflowClusterResourceAttributeUpdate( + setup func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testWorkflowClusterResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_CLUSTER_RESOURCE). + Return(&admin.WorkflowAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testWorkflowClusterResourceAttributeUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.WorkflowAttributes), + setup func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + clusterresourceattribute.DefaultUpdateConfig = &clusterresourceattribute.AttrUpdateConfig{} + target := newTestWorkflowClusterResourceAttribute() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, clusterresourceattribute.DefaultUpdateConfig, target) + } + + err := updateClusterResourceAttributesFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + clusterresourceattribute.DefaultUpdateConfig = &clusterresourceattribute.AttrUpdateConfig{} +} + +func newTestWorkflowClusterResourceAttribute() *admin.WorkflowAttributes { + return &admin.WorkflowAttributes{ + // project, domain, and workflow names need to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + Domain: "development", + Workflow: "core.control_flow.merge_sort.merge_sort", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_ClusterResourceAttributes{ + ClusterResourceAttributes: &admin.ClusterResourceAttributes{ + Attributes: map[string]string{ + testutils.RandomName(5): testutils.RandomName(10), + testutils.RandomName(5): testutils.RandomName(10), + testutils.RandomName(5): testutils.RandomName(10), + }, + }, + }, + }, + } +} + +func testProjectClusterResourceAttributeUpdate( + setup func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testProjectClusterResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_CLUSTER_RESOURCE). + Return(&admin.ProjectAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testProjectClusterResourceAttributeUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.ProjectAttributes), + setup func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + clusterresourceattribute.DefaultUpdateConfig = &clusterresourceattribute.AttrUpdateConfig{} + target := newTestProjectClusterResourceAttribute() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, clusterresourceattribute.DefaultUpdateConfig, target) + } + + err := updateClusterResourceAttributesFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + clusterresourceattribute.DefaultUpdateConfig = &clusterresourceattribute.AttrUpdateConfig{} +} + +func newTestProjectClusterResourceAttribute() *admin.ProjectAttributes { + return &admin.ProjectAttributes{ + // project name needs to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_ClusterResourceAttributes{ + ClusterResourceAttributes: &admin.ClusterResourceAttributes{ + Attributes: map[string]string{ + testutils.RandomName(5): testutils.RandomName(10), + testutils.RandomName(5): testutils.RandomName(10), + testutils.RandomName(5): testutils.RandomName(10), + }, + }, + }, + }, + } +} + +func testProjectDomainClusterResourceAttributeUpdate( + setup func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testProjectDomainClusterResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_CLUSTER_RESOURCE). + Return(&admin.ProjectDomainAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testProjectDomainClusterResourceAttributeUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes), + setup func(s *testutils.TestStruct, config *clusterresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + clusterresourceattribute.DefaultUpdateConfig = &clusterresourceattribute.AttrUpdateConfig{} + target := newTestProjectDomainClusterResourceAttribute() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, clusterresourceattribute.DefaultUpdateConfig, target) + } + + err := updateClusterResourceAttributesFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + clusterresourceattribute.DefaultUpdateConfig = &clusterresourceattribute.AttrUpdateConfig{} +} + +func newTestProjectDomainClusterResourceAttribute() *admin.ProjectDomainAttributes { + return &admin.ProjectDomainAttributes{ + // project and domain names need to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + Domain: "development", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_ClusterResourceAttributes{ + ClusterResourceAttributes: &admin.ClusterResourceAttributes{ + Attributes: map[string]string{ + testutils.RandomName(5): testutils.RandomName(10), + testutils.RandomName(5): testutils.RandomName(10), + testutils.RandomName(5): testutils.RandomName(10), + }, + }, + }, + }, + } } diff --git a/cmd/update/matchable_execution_cluster_label.go b/cmd/update/matchable_execution_cluster_label.go index e3c41e1015..dee80cb459 100644 --- a/cmd/update/matchable_execution_cluster_label.go +++ b/cmd/update/matchable_execution_cluster_label.go @@ -7,6 +7,7 @@ import ( sconfig "github.com/flyteorg/flytectl/cmd/config/subcommand" "github.com/flyteorg/flytectl/cmd/config/subcommand/executionclusterlabel" cmdCore "github.com/flyteorg/flytectl/cmd/core" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" ) const ( @@ -64,9 +65,9 @@ func updateExecutionClusterLabelFunc(ctx context.Context, args []string, cmdCtx domain := executionClusterLabelFileConfig.Domain workflowName := executionClusterLabelFileConfig.Workflow - // Updates the admin matchable attribute from executionClusterLabelFileConfig - if err := DecorateAndUpdateMatchableAttr(ctx, project, domain, workflowName, cmdCtx.AdminUpdaterExt(), - executionClusterLabelFileConfig, updateConfig.DryRun); err != nil { + if err := DecorateAndUpdateMatchableAttr(ctx, cmdCtx, project, domain, workflowName, + admin.MatchableResource_EXECUTION_CLUSTER_LABEL, executionClusterLabelFileConfig, + updateConfig.DryRun, updateConfig.Force); err != nil { return err } return nil diff --git a/cmd/update/matchable_execution_cluster_label_test.go b/cmd/update/matchable_execution_cluster_label_test.go index fffbd0f250..3b234c4a22 100644 --- a/cmd/update/matchable_execution_cluster_label_test.go +++ b/cmd/update/matchable_execution_cluster_label_test.go @@ -8,83 +8,552 @@ import ( "github.com/stretchr/testify/mock" "github.com/flyteorg/flytectl/cmd/config/subcommand/executionclusterlabel" + "github.com/flyteorg/flytectl/cmd/testutils" + "github.com/flyteorg/flytectl/pkg/ext" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" ) -func updateExecutionClusterLabelSetup() { - executionclusterlabel.DefaultUpdateConfig = &executionclusterlabel.AttrUpdateConfig{} +const ( + validProjectExecutionClusterLabelFilePath = "testdata/valid_project_execution_cluster_label.yaml" + validProjectDomainExecutionClusterLabelFilePath = "testdata/valid_project_domain_execution_cluster_label.yaml" + validWorkflowExecutionClusterLabelFilePath = "testdata/valid_workflow_execution_cluster_label.yaml" +) + +func TestExecutionClusterLabelUpdateRequiresAttributeFile(t *testing.T) { + testWorkflowExecutionClusterLabelUpdate( + /* setup */ nil, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "attrFile is mandatory") + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestExecutionClusterLabelUpdateFailsWhenAttributeFileDoesNotExist(t *testing.T) { + testWorkflowExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = testDataNonExistentFile + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "unable to read from testdata/non-existent-file yaml file") + s.UpdaterExt.AssertNotCalled(t, "FetchWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestExecutionClusterLabelUpdateFailsWhenAttributeFileIsMalformed(t *testing.T) { + testWorkflowExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = testDataInvalidAttrFile + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "error unmarshaling JSON: while decoding JSON: json: unknown field \"InvalidDomain\"") + s.UpdaterExt.AssertNotCalled(t, "FetchWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestExecutionClusterLabelUpdateHappyPath(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionClusterLabelFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainExecutionClusterLabelFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectExecutionClusterLabelFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project`) + }) + }) +} + +func TestExecutionClusterLabelUpdateFailsWithoutForceFlag(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionClusterLabelFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainExecutionClusterLabelFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectExecutionClusterLabelFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func TestExecutionClusterLabelUpdateDoesNothingWithDryRunFlag(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionClusterLabelFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainExecutionClusterLabelFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectExecutionClusterLabelFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) } -func TestExecutionClusterLabel(t *testing.T) { - t.Run("no input file for update", func(t *testing.T) { - s := setup() - updateExecutionClusterLabelSetup() - err := updateExecutionClusterLabelFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("attrFile is mandatory while calling update for execution cluster label"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("successful update project domain attribute", func(t *testing.T) { - s := setup() - updateExecutionClusterLabelSetup() - executionclusterlabel.DefaultUpdateConfig.AttrFile = "testdata/valid_project_domain_execution_cluster_label.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateProjectDomainAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything).Return(nil) - err := updateExecutionClusterLabelFunc(s.Ctx, []string{}, s.CmdCtx) - assert.Nil(t, err) - s.TearDownAndVerify(t, `Updated attributes from flytesnacks project and domain development`) - }) - t.Run("failed update project domain attribute", func(t *testing.T) { - s := setup() - updateExecutionClusterLabelSetup() - executionclusterlabel.DefaultUpdateConfig.AttrFile = "testdata/valid_project_domain_execution_cluster_label.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateProjectDomainAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything).Return(fmt.Errorf("failed to update attributes")) - err := updateExecutionClusterLabelFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("failed to update attributes"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("successful update workflow attribute", func(t *testing.T) { - s := setup() - updateExecutionClusterLabelSetup() - executionclusterlabel.DefaultUpdateConfig.AttrFile = "testdata/valid_workflow_execution_cluster_label.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateWorkflowAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything).Return(nil) - err := updateExecutionClusterLabelFunc(s.Ctx, nil, s.CmdCtx) - assert.Nil(t, err) - s.TearDownAndVerify(t, `Updated attributes from flytesnacks project and domain development and workflow core.control_flow.merge_sort.merge_sort`) - }) - t.Run("failed update workflow attribute", func(t *testing.T) { - s := setup() - updateExecutionClusterLabelSetup() - executionclusterlabel.DefaultUpdateConfig.AttrFile = "testdata/valid_workflow_execution_cluster_label.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateWorkflowAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything).Return(fmt.Errorf("failed to update attributes")) - err := updateExecutionClusterLabelFunc(s.Ctx, nil, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("failed to update attributes"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("non existent file", func(t *testing.T) { - s := setup() - updateExecutionClusterLabelSetup() - executionclusterlabel.DefaultUpdateConfig.AttrFile = testDataNonExistentFile - err := updateExecutionClusterLabelFunc(s.Ctx, nil, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("unable to read from testdata/non-existent-file yaml file"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("invalid update file", func(t *testing.T) { - s := setup() - updateExecutionClusterLabelSetup() - executionclusterlabel.DefaultUpdateConfig.AttrFile = testDataInvalidAttrFile - err := updateExecutionClusterLabelFunc(s.Ctx, nil, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("error unmarshaling JSON: while decoding JSON: json: unknown field \"InvalidDomain\""), err) - s.TearDownAndVerify(t, ``) +func TestExecutionClusterLabelUpdateIgnoresForceFlagWithDryRun(t *testing.T) { + t.Run("workflow without --force", func(t *testing.T) { + testWorkflowExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionClusterLabelFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("workflow with --force", func(t *testing.T) { + testWorkflowExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionClusterLabelFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain without --force", func(t *testing.T) { + testProjectDomainExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainExecutionClusterLabelFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) }) + + t.Run("domain with --force", func(t *testing.T) { + testProjectDomainExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainExecutionClusterLabelFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project without --force", func(t *testing.T) { + testProjectExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectExecutionClusterLabelFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project with --force", func(t *testing.T) { + testProjectExecutionClusterLabelUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectExecutionClusterLabelFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func TestExecutionClusterLabelUpdateSucceedsWhenAttributesDoNotExist(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowExecutionClusterLabelUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_EXECUTION_CLUSTER_LABEL). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionClusterLabelFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainExecutionClusterLabelUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_EXECUTION_CLUSTER_LABEL). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainExecutionClusterLabelFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectExecutionClusterLabelUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_EXECUTION_CLUSTER_LABEL). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectExecutionClusterLabelFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project`) + }) + }) +} + +func TestExecutionClusterLabelUpdateFailsWhenAdminClientFails(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowExecutionClusterLabelUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_EXECUTION_CLUSTER_LABEL). + Return(&admin.WorkflowAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionClusterLabelFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainExecutionClusterLabelUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_EXECUTION_CLUSTER_LABEL). + Return(&admin.ProjectDomainAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainExecutionClusterLabelFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectExecutionClusterLabelUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_EXECUTION_CLUSTER_LABEL). + Return(&admin.ProjectAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectExecutionClusterLabelFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func testWorkflowExecutionClusterLabelUpdate( + setup func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.WorkflowAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testWorkflowExecutionClusterLabelUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_EXECUTION_CLUSTER_LABEL). + Return(&admin.WorkflowAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testWorkflowExecutionClusterLabelUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.WorkflowAttributes), + setup func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.WorkflowAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + executionclusterlabel.DefaultUpdateConfig = &executionclusterlabel.AttrUpdateConfig{} + target := newTestWorkflowExecutionClusterLabel() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, executionclusterlabel.DefaultUpdateConfig, target) + } + + err := updateExecutionClusterLabelFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + executionclusterlabel.DefaultUpdateConfig = &executionclusterlabel.AttrUpdateConfig{} +} + +func newTestWorkflowExecutionClusterLabel() *admin.WorkflowAttributes { + return &admin.WorkflowAttributes{ + // project, domain, and workflow names need to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + Domain: "development", + Workflow: "core.control_flow.merge_sort.merge_sort", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_ExecutionClusterLabel{ + ExecutionClusterLabel: &admin.ExecutionClusterLabel{ + Value: testutils.RandomName(12), + }, + }, + }, + } +} + +func testProjectExecutionClusterLabelUpdate( + setup func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testProjectExecutionClusterLabelUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_EXECUTION_CLUSTER_LABEL). + Return(&admin.ProjectAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testProjectExecutionClusterLabelUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.ProjectAttributes), + setup func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + executionclusterlabel.DefaultUpdateConfig = &executionclusterlabel.AttrUpdateConfig{} + target := newTestProjectExecutionClusterLabel() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, executionclusterlabel.DefaultUpdateConfig, target) + } + + err := updateExecutionClusterLabelFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + executionclusterlabel.DefaultUpdateConfig = &executionclusterlabel.AttrUpdateConfig{} +} + +func newTestProjectExecutionClusterLabel() *admin.ProjectAttributes { + return &admin.ProjectAttributes{ + // project name needs to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_ExecutionClusterLabel{ + ExecutionClusterLabel: &admin.ExecutionClusterLabel{ + Value: testutils.RandomName(12), + }, + }, + }, + } +} + +func testProjectDomainExecutionClusterLabelUpdate( + setup func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectDomainAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testProjectDomainExecutionClusterLabelUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_EXECUTION_CLUSTER_LABEL). + Return(&admin.ProjectDomainAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testProjectDomainExecutionClusterLabelUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes), + setup func(s *testutils.TestStruct, config *executionclusterlabel.AttrUpdateConfig, target *admin.ProjectDomainAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + executionclusterlabel.DefaultUpdateConfig = &executionclusterlabel.AttrUpdateConfig{} + target := newTestProjectDomainExecutionClusterLabel() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, executionclusterlabel.DefaultUpdateConfig, target) + } + + err := updateExecutionClusterLabelFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + executionclusterlabel.DefaultUpdateConfig = &executionclusterlabel.AttrUpdateConfig{} +} + +func newTestProjectDomainExecutionClusterLabel() *admin.ProjectDomainAttributes { + return &admin.ProjectDomainAttributes{ + // project and domain names need to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + Domain: "development", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_ExecutionClusterLabel{ + ExecutionClusterLabel: &admin.ExecutionClusterLabel{ + Value: testutils.RandomName(12), + }, + }, + }, + } } diff --git a/cmd/update/matchable_execution_queue_attribute.go b/cmd/update/matchable_execution_queue_attribute.go index feb8d5224b..966972e771 100644 --- a/cmd/update/matchable_execution_queue_attribute.go +++ b/cmd/update/matchable_execution_queue_attribute.go @@ -7,6 +7,7 @@ import ( sconfig "github.com/flyteorg/flytectl/cmd/config/subcommand" "github.com/flyteorg/flytectl/cmd/config/subcommand/executionqueueattribute" cmdCore "github.com/flyteorg/flytectl/cmd/core" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" ) const ( @@ -75,9 +76,9 @@ func updateExecutionQueueAttributesFunc(ctx context.Context, args []string, cmdC domain := executionQueueAttrFileConfig.Domain workflowName := executionQueueAttrFileConfig.Workflow - // Updates the admin matchable attribute from executionQueueAttrFileConfig - if err := DecorateAndUpdateMatchableAttr(ctx, project, domain, workflowName, cmdCtx.AdminUpdaterExt(), - executionQueueAttrFileConfig, updateConfig.DryRun); err != nil { + if err := DecorateAndUpdateMatchableAttr(ctx, cmdCtx, project, domain, workflowName, + admin.MatchableResource_EXECUTION_QUEUE, executionQueueAttrFileConfig, + updateConfig.DryRun, updateConfig.Force); err != nil { return err } return nil diff --git a/cmd/update/matchable_execution_queue_attribute_test.go b/cmd/update/matchable_execution_queue_attribute_test.go index cffad3da3d..8a14997660 100644 --- a/cmd/update/matchable_execution_queue_attribute_test.go +++ b/cmd/update/matchable_execution_queue_attribute_test.go @@ -8,83 +8,564 @@ import ( "github.com/stretchr/testify/mock" "github.com/flyteorg/flytectl/cmd/config/subcommand/executionqueueattribute" + "github.com/flyteorg/flytectl/cmd/testutils" + "github.com/flyteorg/flytectl/pkg/ext" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" ) -func updateExecutionQueueAttributeSetup() { - executionqueueattribute.DefaultUpdateConfig = &executionqueueattribute.AttrUpdateConfig{} +const ( + validWorkflowExecutionQueueMatchableAttributesFilePath = "testdata/valid_workflow_execution_queue_attribute.yaml" + validProjectDomainExecutionQueueMatchableAttributeFilePath = "testdata/valid_project_domain_execution_queue_attribute.yaml" + validProjectExecutionQueueMatchableAttributeFilePath = "testdata/valid_project_execution_queue_attribute.yaml" +) + +func TestExecutionQueueAttributeUpdateRequiresAttributeFile(t *testing.T) { + testWorkflowExecutionQueueAttributeUpdate( + /* setup */ nil, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "attrFile is mandatory") + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestExecutionQueueAttributeUpdateFailsWhenAttributeFileDoesNotExist(t *testing.T) { + testWorkflowExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = testDataNonExistentFile + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "unable to read from testdata/non-existent-file yaml file") + s.UpdaterExt.AssertNotCalled(t, "FetchWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestExecutionQueueAttributeUpdateFailsWhenAttributeFileIsMalformed(t *testing.T) { + testWorkflowExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = testDataInvalidAttrFile + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "error unmarshaling JSON: while decoding JSON: json: unknown field \"InvalidDomain\"") + s.UpdaterExt.AssertNotCalled(t, "FetchWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestExecutionQueueAttributeUpdateHappyPath(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionQueueMatchableAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainExecutionQueueMatchableAttributeFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectExecutionQueueMatchableAttributeFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project`) + }) + }) +} + +func TestExecutionQueueAttributeUpdateFailsWithoutForceFlag(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionQueueMatchableAttributesFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainExecutionQueueMatchableAttributeFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectExecutionQueueMatchableAttributeFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func TestExecutionQueueAttributeUpdateDoesNothingWithDryRunFlag(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionQueueMatchableAttributesFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainExecutionQueueMatchableAttributeFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectExecutionQueueMatchableAttributeFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) } -func TestExecutionQueueAttributes(t *testing.T) { - t.Run("no input file for update", func(t *testing.T) { - s := setup() - updateExecutionQueueAttributeSetup() - err := updateExecutionQueueAttributesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("attrFile is mandatory while calling update for execution queue attribute"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("successful update project domain attribute", func(t *testing.T) { - s := setup() - updateExecutionQueueAttributeSetup() - executionqueueattribute.DefaultUpdateConfig.AttrFile = "testdata/valid_project_domain_execution_queue_attribute.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateProjectDomainAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything).Return(nil) - err := updateExecutionQueueAttributesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.Nil(t, err) - s.TearDownAndVerify(t, `Updated attributes from flytesnacks project and domain development`) - }) - t.Run("failed update project domain attribute", func(t *testing.T) { - s := setup() - updateExecutionQueueAttributeSetup() - executionqueueattribute.DefaultUpdateConfig.AttrFile = "testdata/valid_project_domain_execution_queue_attribute.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateProjectDomainAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything).Return(fmt.Errorf("failed to update attributes")) - err := updateExecutionQueueAttributesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("failed to update attributes"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("successful update workflow attribute", func(t *testing.T) { - s := setup() - updateExecutionQueueAttributeSetup() - executionqueueattribute.DefaultUpdateConfig.AttrFile = "testdata/valid_workflow_execution_queue_attribute.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateWorkflowAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything).Return(nil) - err := updateExecutionQueueAttributesFunc(s.Ctx, nil, s.CmdCtx) - assert.Nil(t, err) - s.TearDownAndVerify(t, `Updated attributes from flytesnacks project and domain development and workflow core.control_flow.merge_sort.merge_sort`) - }) - t.Run("failed update workflow attribute", func(t *testing.T) { - s := setup() - updateExecutionQueueAttributeSetup() - executionqueueattribute.DefaultUpdateConfig.AttrFile = "testdata/valid_workflow_execution_queue_attribute.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateWorkflowAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything).Return(fmt.Errorf("failed to update attributes")) - err := updateExecutionQueueAttributesFunc(s.Ctx, nil, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("failed to update attributes"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("non existent file", func(t *testing.T) { - s := setup() - updateExecutionQueueAttributeSetup() - executionqueueattribute.DefaultUpdateConfig.AttrFile = testDataNonExistentFile - err := updateExecutionQueueAttributesFunc(s.Ctx, nil, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("unable to read from testdata/non-existent-file yaml file"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("invalid update file", func(t *testing.T) { - s := setup() - updateExecutionQueueAttributeSetup() - executionqueueattribute.DefaultUpdateConfig.AttrFile = testDataInvalidAttrFile - err := updateExecutionQueueAttributesFunc(s.Ctx, nil, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("error unmarshaling JSON: while decoding JSON: json: unknown field \"InvalidDomain\""), err) - s.TearDownAndVerify(t, ``) +func TestExecutionQueueAttributeUpdateIgnoresForceFlagWithDryRun(t *testing.T) { + t.Run("workflow without --force", func(t *testing.T) { + testWorkflowExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionQueueMatchableAttributesFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("workflow with --force", func(t *testing.T) { + testWorkflowExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionQueueMatchableAttributesFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain without --force", func(t *testing.T) { + testProjectDomainExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainExecutionQueueMatchableAttributeFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) }) + + t.Run("domain with --force", func(t *testing.T) { + testProjectDomainExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainExecutionQueueMatchableAttributeFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project without --force", func(t *testing.T) { + testProjectExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectExecutionQueueMatchableAttributeFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project with --force", func(t *testing.T) { + testProjectExecutionQueueAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectExecutionQueueMatchableAttributeFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func TestExecutionQueueAttributeUpdateSucceedsWhenAttributesDoNotExist(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowExecutionQueueAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_EXECUTION_QUEUE). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionQueueMatchableAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainExecutionQueueAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_EXECUTION_QUEUE). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainExecutionQueueMatchableAttributeFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectExecutionQueueAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_EXECUTION_QUEUE). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectExecutionQueueMatchableAttributeFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project`) + }) + }) +} + +func TestExecutionQueueAttributeUpdateFailsWhenAdminClientFails(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowExecutionQueueAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_EXECUTION_QUEUE). + Return(&admin.WorkflowAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionQueueMatchableAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainExecutionQueueAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_EXECUTION_QUEUE). + Return(&admin.ProjectDomainAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainExecutionQueueMatchableAttributeFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectExecutionQueueAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_EXECUTION_QUEUE). + Return(&admin.ProjectAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectExecutionQueueMatchableAttributeFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func testWorkflowExecutionQueueAttributeUpdate( + setup func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.WorkflowAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testWorkflowExecutionQueueAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_EXECUTION_QUEUE). + Return(&admin.WorkflowAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testWorkflowExecutionQueueAttributeUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.WorkflowAttributes), + setup func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.WorkflowAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + executionqueueattribute.DefaultUpdateConfig = &executionqueueattribute.AttrUpdateConfig{} + target := newTestWorkflowExecutionQueueAttribute() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, executionqueueattribute.DefaultUpdateConfig, target) + } + + err := updateExecutionQueueAttributesFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + executionqueueattribute.DefaultUpdateConfig = &executionqueueattribute.AttrUpdateConfig{} +} + +func newTestWorkflowExecutionQueueAttribute() *admin.WorkflowAttributes { + return &admin.WorkflowAttributes{ + // project, domain, and workflow names need to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + Domain: "development", + Workflow: "core.control_flow.merge_sort.merge_sort", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_ExecutionQueueAttributes{ + ExecutionQueueAttributes: &admin.ExecutionQueueAttributes{ + Tags: []string{ + testutils.RandomName(5), + testutils.RandomName(5), + testutils.RandomName(5), + }, + }, + }, + }, + } +} + +func testProjectExecutionQueueAttributeUpdate( + setup func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testProjectExecutionQueueAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_EXECUTION_QUEUE). + Return(&admin.ProjectAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testProjectExecutionQueueAttributeUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.ProjectAttributes), + setup func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + executionqueueattribute.DefaultUpdateConfig = &executionqueueattribute.AttrUpdateConfig{} + target := newTestProjectExecutionQueueAttribute() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, executionqueueattribute.DefaultUpdateConfig, target) + } + + err := updateExecutionQueueAttributesFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + executionqueueattribute.DefaultUpdateConfig = &executionqueueattribute.AttrUpdateConfig{} +} + +func newTestProjectExecutionQueueAttribute() *admin.ProjectAttributes { + return &admin.ProjectAttributes{ + // project name needs to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_ExecutionQueueAttributes{ + ExecutionQueueAttributes: &admin.ExecutionQueueAttributes{ + Tags: []string{ + testutils.RandomName(5), + testutils.RandomName(5), + testutils.RandomName(5), + }, + }, + }, + }, + } +} + +func testProjectDomainExecutionQueueAttributeUpdate( + setup func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testProjectDomainExecutionQueueAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_EXECUTION_QUEUE). + Return(&admin.ProjectDomainAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testProjectDomainExecutionQueueAttributeUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes), + setup func(s *testutils.TestStruct, config *executionqueueattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + executionqueueattribute.DefaultUpdateConfig = &executionqueueattribute.AttrUpdateConfig{} + target := newTestProjectDomainExecutionQueueAttribute() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, executionqueueattribute.DefaultUpdateConfig, target) + } + + err := updateExecutionQueueAttributesFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + executionqueueattribute.DefaultUpdateConfig = &executionqueueattribute.AttrUpdateConfig{} +} + +func newTestProjectDomainExecutionQueueAttribute() *admin.ProjectDomainAttributes { + return &admin.ProjectDomainAttributes{ + // project and domain names need to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + Domain: "development", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_ExecutionQueueAttributes{ + ExecutionQueueAttributes: &admin.ExecutionQueueAttributes{ + Tags: []string{ + testutils.RandomName(5), + testutils.RandomName(5), + testutils.RandomName(5), + }, + }, + }, + }, + } } diff --git a/cmd/update/matchable_plugin_override.go b/cmd/update/matchable_plugin_override.go index 981a124b50..4b6d1358f1 100644 --- a/cmd/update/matchable_plugin_override.go +++ b/cmd/update/matchable_plugin_override.go @@ -7,6 +7,7 @@ import ( sconfig "github.com/flyteorg/flytectl/cmd/config/subcommand" pluginoverride "github.com/flyteorg/flytectl/cmd/config/subcommand/plugin_override" cmdCore "github.com/flyteorg/flytectl/cmd/core" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" ) const ( @@ -77,9 +78,9 @@ func updatePluginOverridesFunc(ctx context.Context, args []string, cmdCtx cmdCor domain := pluginOverrideFileConfig.Domain workflowName := pluginOverrideFileConfig.Workflow - // Updates the admin matchable attribute from pluginOverrideFileConfig - if err := DecorateAndUpdateMatchableAttr(ctx, project, domain, workflowName, cmdCtx.AdminUpdaterExt(), - pluginOverrideFileConfig, updateConfig.DryRun); err != nil { + if err := DecorateAndUpdateMatchableAttr(ctx, cmdCtx, project, domain, workflowName, + admin.MatchableResource_PLUGIN_OVERRIDE, pluginOverrideFileConfig, + updateConfig.DryRun, updateConfig.Force); err != nil { return err } return nil diff --git a/cmd/update/matchable_plugin_override_test.go b/cmd/update/matchable_plugin_override_test.go index 5165b0091e..7089df984f 100644 --- a/cmd/update/matchable_plugin_override_test.go +++ b/cmd/update/matchable_plugin_override_test.go @@ -8,83 +8,582 @@ import ( "github.com/stretchr/testify/mock" pluginoverride "github.com/flyteorg/flytectl/cmd/config/subcommand/plugin_override" + "github.com/flyteorg/flytectl/cmd/testutils" + "github.com/flyteorg/flytectl/pkg/ext" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" ) -func updatePluginOverrideSetup() { - pluginoverride.DefaultUpdateConfig = &pluginoverride.AttrUpdateConfig{} +const ( + validProjectPluginOverrideFilePath = "testdata/valid_project_plugin_override.yaml" + validProjectDomainPluginOverrideFilePath = "testdata/valid_project_domain_plugin_override.yaml" + validWorkflowPluginOverrideFilePath = "testdata/valid_workflow_plugin_override.yaml" +) + +func TestPluginOverrideUpdateRequiresAttributeFile(t *testing.T) { + testWorkflowPluginOverrideUpdate( + /* setup */ nil, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "attrFile is mandatory") + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestPluginOverrideUpdateFailsWhenAttributeFileDoesNotExist(t *testing.T) { + testWorkflowPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = testDataNonExistentFile + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "unable to read from testdata/non-existent-file yaml file") + s.UpdaterExt.AssertNotCalled(t, "FetchWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestPluginOverrideUpdateFailsWhenAttributeFileIsMalformed(t *testing.T) { + testWorkflowPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = testDataInvalidAttrFile + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "error unmarshaling JSON: while decoding JSON: json: unknown field \"InvalidDomain\"") + s.UpdaterExt.AssertNotCalled(t, "FetchWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestPluginOverrideUpdateHappyPath(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowPluginOverrideFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainPluginOverrideFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectPluginOverrideFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project`) + }) + }) +} + +func TestPluginOverrideUpdateFailsWithoutForceFlag(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowPluginOverrideFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainPluginOverrideFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectPluginOverrideFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func TestPluginOverrideUpdateDoesNothingWithDryRunFlag(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowPluginOverrideFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainPluginOverrideFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectPluginOverrideFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) } -func TestPluginOverride(t *testing.T) { - t.Run("no input file for update", func(t *testing.T) { - s := setup() - updatePluginOverrideSetup() - err := updatePluginOverridesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("attrFile is mandatory while calling update for plugin override"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("successful update project domain attribute", func(t *testing.T) { - s := setup() - updatePluginOverrideSetup() - pluginoverride.DefaultUpdateConfig.AttrFile = "testdata/valid_project_domain_plugin_override.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateProjectDomainAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything).Return(nil) - err := updatePluginOverridesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.Nil(t, err) - s.TearDownAndVerify(t, `Updated attributes from flytesnacks project and domain development`) - }) - t.Run("failed update project domain attribute", func(t *testing.T) { - s := setup() - updatePluginOverrideSetup() - pluginoverride.DefaultUpdateConfig.AttrFile = "testdata/valid_project_domain_plugin_override.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateProjectDomainAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything).Return(fmt.Errorf("failed to update attributes")) - err := updatePluginOverridesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("failed to update attributes"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("successful update workflow attribute", func(t *testing.T) { - s := setup() - updatePluginOverrideSetup() - pluginoverride.DefaultUpdateConfig.AttrFile = "testdata/valid_workflow_plugin_override.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateWorkflowAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything).Return(nil) - err := updatePluginOverridesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.Nil(t, err) - s.TearDownAndVerify(t, `Updated attributes from flytesnacks project and domain development and workflow core.control_flow.merge_sort.merge_sort`) - }) - t.Run("failed update workflow attribute", func(t *testing.T) { - s := setup() - updatePluginOverrideSetup() - pluginoverride.DefaultUpdateConfig.AttrFile = "testdata/valid_workflow_plugin_override.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateWorkflowAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything).Return(fmt.Errorf("failed to update attributes")) - err := updatePluginOverridesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("failed to update attributes"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("non existent file", func(t *testing.T) { - s := setup() - updatePluginOverrideSetup() - pluginoverride.DefaultUpdateConfig.AttrFile = testDataNonExistentFile - err := updatePluginOverridesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("unable to read from testdata/non-existent-file yaml file"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("invalid update file", func(t *testing.T) { - s := setup() - updatePluginOverrideSetup() - pluginoverride.DefaultUpdateConfig.AttrFile = testDataInvalidAttrFile - err := updatePluginOverridesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("error unmarshaling JSON: while decoding JSON: json: unknown field \"InvalidDomain\""), err) - s.TearDownAndVerify(t, ``) +func TestPluginOverrideUpdateIgnoresForceFlagWithDryRun(t *testing.T) { + t.Run("workflow without --force", func(t *testing.T) { + testWorkflowPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowPluginOverrideFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("workflow with --force", func(t *testing.T) { + testWorkflowPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowPluginOverrideFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain without --force", func(t *testing.T) { + testProjectDomainPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainPluginOverrideFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) }) + + t.Run("domain with --force", func(t *testing.T) { + testProjectDomainPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainPluginOverrideFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project without --force", func(t *testing.T) { + testProjectPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectPluginOverrideFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project with --force", func(t *testing.T) { + testProjectPluginOverrideUpdate( + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectPluginOverrideFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func TestPluginOverrideUpdateSucceedsWhenAttributesDoNotExist(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowPluginOverrideUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_PLUGIN_OVERRIDE). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowPluginOverrideFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainPluginOverrideUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_PLUGIN_OVERRIDE). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainPluginOverrideFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectPluginOverrideUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_PLUGIN_OVERRIDE). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectPluginOverrideFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project`) + }) + }) +} + +func TestPluginOverrideUpdateFailsWhenAdminClientFails(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowPluginOverrideUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_PLUGIN_OVERRIDE). + Return(&admin.WorkflowAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowPluginOverrideFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainPluginOverrideUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_PLUGIN_OVERRIDE). + Return(&admin.ProjectDomainAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainPluginOverrideFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectPluginOverrideUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_PLUGIN_OVERRIDE). + Return(&admin.ProjectAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectPluginOverrideFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func testWorkflowPluginOverrideUpdate( + setup func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.WorkflowAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testWorkflowPluginOverrideUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_PLUGIN_OVERRIDE). + Return(&admin.WorkflowAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testWorkflowPluginOverrideUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.WorkflowAttributes), + setup func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.WorkflowAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + pluginoverride.DefaultUpdateConfig = &pluginoverride.AttrUpdateConfig{} + target := newTestWorkflowPluginOverride() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, pluginoverride.DefaultUpdateConfig, target) + } + + err := updatePluginOverridesFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + pluginoverride.DefaultUpdateConfig = &pluginoverride.AttrUpdateConfig{} +} + +func newTestWorkflowPluginOverride() *admin.WorkflowAttributes { + return &admin.WorkflowAttributes{ + // project, domain, and workflow names need to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + Domain: "development", + Workflow: "core.control_flow.merge_sort.merge_sort", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_PluginOverrides{ + PluginOverrides: &admin.PluginOverrides{ + Overrides: []*admin.PluginOverride{ + { + TaskType: testutils.RandomName(15), + PluginId: []string{ + testutils.RandomName(12), + testutils.RandomName(12), + testutils.RandomName(12), + }, + MissingPluginBehavior: admin.PluginOverride_FAIL, + }, + }, + }, + }, + }, + } +} + +func testProjectPluginOverrideUpdate( + setup func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testProjectPluginOverrideUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_PLUGIN_OVERRIDE). + Return(&admin.ProjectAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testProjectPluginOverrideUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.ProjectAttributes), + setup func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + pluginoverride.DefaultUpdateConfig = &pluginoverride.AttrUpdateConfig{} + target := newTestProjectPluginOverride() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, pluginoverride.DefaultUpdateConfig, target) + } + + err := updatePluginOverridesFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + pluginoverride.DefaultUpdateConfig = &pluginoverride.AttrUpdateConfig{} +} + +func newTestProjectPluginOverride() *admin.ProjectAttributes { + return &admin.ProjectAttributes{ + // project name needs to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_PluginOverrides{ + PluginOverrides: &admin.PluginOverrides{ + Overrides: []*admin.PluginOverride{ + { + TaskType: testutils.RandomName(15), + PluginId: []string{ + testutils.RandomName(12), + testutils.RandomName(12), + testutils.RandomName(12), + }, + MissingPluginBehavior: admin.PluginOverride_FAIL, + }, + }, + }, + }, + }, + } +} + +func testProjectDomainPluginOverrideUpdate( + setup func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectDomainAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testProjectDomainPluginOverrideUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_PLUGIN_OVERRIDE). + Return(&admin.ProjectDomainAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testProjectDomainPluginOverrideUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes), + setup func(s *testutils.TestStruct, config *pluginoverride.AttrUpdateConfig, target *admin.ProjectDomainAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + pluginoverride.DefaultUpdateConfig = &pluginoverride.AttrUpdateConfig{} + target := newTestProjectDomainPluginOverride() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, pluginoverride.DefaultUpdateConfig, target) + } + + err := updatePluginOverridesFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + pluginoverride.DefaultUpdateConfig = &pluginoverride.AttrUpdateConfig{} +} + +func newTestProjectDomainPluginOverride() *admin.ProjectDomainAttributes { + return &admin.ProjectDomainAttributes{ + // project and domain names need to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + Domain: "development", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_PluginOverrides{ + PluginOverrides: &admin.PluginOverrides{ + Overrides: []*admin.PluginOverride{ + { + TaskType: testutils.RandomName(15), + PluginId: []string{ + testutils.RandomName(12), + testutils.RandomName(12), + testutils.RandomName(12), + }, + MissingPluginBehavior: admin.PluginOverride_FAIL, + }, + }, + }, + }, + }, + } } diff --git a/cmd/update/matchable_task_resource_attribute.go b/cmd/update/matchable_task_resource_attribute.go index e18825c069..a296a4bd51 100644 --- a/cmd/update/matchable_task_resource_attribute.go +++ b/cmd/update/matchable_task_resource_attribute.go @@ -7,6 +7,7 @@ import ( sconfig "github.com/flyteorg/flytectl/cmd/config/subcommand" "github.com/flyteorg/flytectl/cmd/config/subcommand/taskresourceattribute" cmdCore "github.com/flyteorg/flytectl/cmd/core" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" ) const ( @@ -77,9 +78,9 @@ func updateTaskResourceAttributesFunc(ctx context.Context, args []string, cmdCtx domain := taskResourceAttrFileConfig.Domain workflowName := taskResourceAttrFileConfig.Workflow - // Updates the admin matchable attribute from taskResourceAttrFileConfig - if err := DecorateAndUpdateMatchableAttr(ctx, project, domain, workflowName, cmdCtx.AdminUpdaterExt(), - taskResourceAttrFileConfig, updateConfig.DryRun); err != nil { + if err := DecorateAndUpdateMatchableAttr(ctx, cmdCtx, project, domain, workflowName, + admin.MatchableResource_TASK_RESOURCE, taskResourceAttrFileConfig, + updateConfig.DryRun, updateConfig.Force); err != nil { return err } return nil diff --git a/cmd/update/matchable_task_resource_attribute_test.go b/cmd/update/matchable_task_resource_attribute_test.go index e9135f1355..fd485f910c 100644 --- a/cmd/update/matchable_task_resource_attribute_test.go +++ b/cmd/update/matchable_task_resource_attribute_test.go @@ -8,83 +8,561 @@ import ( "github.com/stretchr/testify/mock" "github.com/flyteorg/flytectl/cmd/config/subcommand/taskresourceattribute" + "github.com/flyteorg/flytectl/cmd/testutils" + "github.com/flyteorg/flytectl/pkg/ext" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" ) -func updateTaskResourceAttributeSetup() { - taskresourceattribute.DefaultUpdateConfig = &taskresourceattribute.AttrUpdateConfig{} +const ( + validProjectTaskAttributesFilePath = "testdata/valid_project_task_attribute.yaml" + validProjectDomainTaskAttributesFilePath = "testdata/valid_project_domain_task_attribute.yaml" + validWorkflowTaskAttributesFilePath = "testdata/valid_workflow_task_attribute.yaml" +) + +func TestTaskResourceAttributeUpdateRequiresAttributeFile(t *testing.T) { + testWorkflowTaskResourceAttributeUpdate( + /* setup */ nil, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "attrFile is mandatory") + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestTaskResourceAttributeUpdateFailsWhenAttributeFileDoesNotExist(t *testing.T) { + testWorkflowTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = testDataNonExistentFile + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "unable to read from testdata/non-existent-file yaml file") + s.UpdaterExt.AssertNotCalled(t, "FetchWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestTaskResourceAttributeUpdateFailsWhenAttributeFileIsMalformed(t *testing.T) { + testWorkflowTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = testDataInvalidAttrFile + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "error unmarshaling JSON: while decoding JSON: json: unknown field \"InvalidDomain\"") + s.UpdaterExt.AssertNotCalled(t, "FetchWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestTaskResourceAttributeUpdateHappyPath(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowTaskAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainTaskAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectTaskAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project`) + }) + }) +} + +func TestTaskResourceAttributeUpdateFailsWithoutForceFlag(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowTaskAttributesFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainTaskAttributesFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectTaskAttributesFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func TestTaskResourceAttributeUpdateDoesNothingWithDryRunFlag(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowTaskAttributesFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainTaskAttributesFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectTaskAttributesFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) } -func TestUpdateTaskResourceAttributes(t *testing.T) { - t.Run("no input file for update", func(t *testing.T) { - s := setup() - updateTaskResourceAttributeSetup() - err := updateTaskResourceAttributesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("attrFile is mandatory while calling update for task resource attribute"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("successful update project domain attribute", func(t *testing.T) { - s := setup() - updateTaskResourceAttributeSetup() - taskresourceattribute.DefaultUpdateConfig.AttrFile = "testdata/valid_project_domain_task_attribute.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateProjectDomainAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything).Return(nil) - err := updateTaskResourceAttributesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.Nil(t, err) - s.TearDownAndVerify(t, `Updated attributes from flytesnacks project and domain development`) - }) - t.Run("failed update project domain attribute", func(t *testing.T) { - s := setup() - updateTaskResourceAttributeSetup() - taskresourceattribute.DefaultUpdateConfig.AttrFile = "testdata/valid_project_domain_task_attribute.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateProjectDomainAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything).Return(fmt.Errorf("failed to update attributes")) - err := updateTaskResourceAttributesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("failed to update attributes"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("successful update workflow attribute", func(t *testing.T) { - s := setup() - updateTaskResourceAttributeSetup() - taskresourceattribute.DefaultUpdateConfig.AttrFile = "testdata/valid_workflow_task_attribute.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateWorkflowAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything).Return(nil) - err := updateTaskResourceAttributesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.Nil(t, err) - s.TearDownAndVerify(t, `Updated attributes from flytesnacks project and domain development and workflow core.control_flow.merge_sort.merge_sort`) - }) - t.Run("failed update workflow attribute", func(t *testing.T) { - s := setup() - updateTaskResourceAttributeSetup() - taskresourceattribute.DefaultUpdateConfig.AttrFile = "testdata/valid_workflow_task_attribute.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateWorkflowAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything).Return(fmt.Errorf("failed to update attributes")) - err := updateTaskResourceAttributesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("failed to update attributes"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("non existent file", func(t *testing.T) { - s := setup() - updateTaskResourceAttributeSetup() - taskresourceattribute.DefaultUpdateConfig.AttrFile = testDataNonExistentFile - err := updateTaskResourceAttributesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("unable to read from testdata/non-existent-file yaml file"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("invalid update file", func(t *testing.T) { - s := setup() - updateTaskResourceAttributeSetup() - taskresourceattribute.DefaultUpdateConfig.AttrFile = testDataInvalidAttrFile - err := updateTaskResourceAttributesFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("error unmarshaling JSON: while decoding JSON: json: unknown field \"InvalidDomain\""), err) - s.TearDownAndVerify(t, ``) +func TestTaskResourceAttributeUpdateIgnoresForceFlagWithDryRun(t *testing.T) { + t.Run("workflow without --force", func(t *testing.T) { + testWorkflowTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowTaskAttributesFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("workflow with --force", func(t *testing.T) { + testWorkflowTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowTaskAttributesFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain without --force", func(t *testing.T) { + testProjectDomainTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainTaskAttributesFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) }) + + t.Run("domain with --force", func(t *testing.T) { + testProjectDomainTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainTaskAttributesFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project without --force", func(t *testing.T) { + testProjectTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectTaskAttributesFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project with --force", func(t *testing.T) { + testProjectTaskResourceAttributeUpdate( + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectTaskAttributesFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func TestTaskResourceAttributeUpdateSucceedsWhenAttributesDoNotExist(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowTaskResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_TASK_RESOURCE). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowTaskAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainTaskResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_TASK_RESOURCE). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainTaskAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectTaskResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_TASK_RESOURCE). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectTaskAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project`) + }) + }) +} + +func TestTaskResourceAttributeUpdateFailsWhenAdminClientFails(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowTaskResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_TASK_RESOURCE). + Return(&admin.WorkflowAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowTaskAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainTaskResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_TASK_RESOURCE). + Return(&admin.ProjectDomainAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainTaskAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectTaskResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_TASK_RESOURCE). + Return(&admin.ProjectAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectTaskAttributesFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func testWorkflowTaskResourceAttributeUpdate( + setup func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testWorkflowTaskResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_TASK_RESOURCE). + Return(&admin.WorkflowAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testWorkflowTaskResourceAttributeUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.WorkflowAttributes), + setup func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.WorkflowAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + taskresourceattribute.DefaultUpdateConfig = &taskresourceattribute.AttrUpdateConfig{} + target := newTestWorkflowTaskResourceAttribute() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, taskresourceattribute.DefaultUpdateConfig, target) + } + + err := updateTaskResourceAttributesFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + taskresourceattribute.DefaultUpdateConfig = &taskresourceattribute.AttrUpdateConfig{} +} + +func newTestWorkflowTaskResourceAttribute() *admin.WorkflowAttributes { + return &admin.WorkflowAttributes{ + // project, domain, and workflow names need to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + Domain: "development", + Workflow: "core.control_flow.merge_sort.merge_sort", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_TaskResourceAttributes{ + TaskResourceAttributes: &admin.TaskResourceAttributes{ + Defaults: &admin.TaskResourceSpec{ + Cpu: testutils.RandomName(2), + Memory: testutils.RandomName(5), + }, + }, + }, + }, + } +} + +func testProjectTaskResourceAttributeUpdate( + setup func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testProjectTaskResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_TASK_RESOURCE). + Return(&admin.ProjectAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testProjectTaskResourceAttributeUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.ProjectAttributes), + setup func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + taskresourceattribute.DefaultUpdateConfig = &taskresourceattribute.AttrUpdateConfig{} + target := newTestProjectTaskResourceAttribute() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, taskresourceattribute.DefaultUpdateConfig, target) + } + + err := updateTaskResourceAttributesFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + taskresourceattribute.DefaultUpdateConfig = &taskresourceattribute.AttrUpdateConfig{} +} + +func newTestProjectTaskResourceAttribute() *admin.ProjectAttributes { + return &admin.ProjectAttributes{ + // project name needs to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_TaskResourceAttributes{ + TaskResourceAttributes: &admin.TaskResourceAttributes{ + Defaults: &admin.TaskResourceSpec{ + Cpu: testutils.RandomName(2), + Memory: testutils.RandomName(5), + }, + }, + }, + }, + } +} + +func testProjectDomainTaskResourceAttributeUpdate( + setup func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testProjectDomainTaskResourceAttributeUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_TASK_RESOURCE). + Return(&admin.ProjectDomainAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testProjectDomainTaskResourceAttributeUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes), + setup func(s *testutils.TestStruct, config *taskresourceattribute.AttrUpdateConfig, target *admin.ProjectDomainAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + taskresourceattribute.DefaultUpdateConfig = &taskresourceattribute.AttrUpdateConfig{} + target := newTestProjectDomainTaskResourceAttribute() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, taskresourceattribute.DefaultUpdateConfig, target) + } + + err := updateTaskResourceAttributesFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + taskresourceattribute.DefaultUpdateConfig = &taskresourceattribute.AttrUpdateConfig{} +} + +func newTestProjectDomainTaskResourceAttribute() *admin.ProjectDomainAttributes { + return &admin.ProjectDomainAttributes{ + // project and domain names need to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + Domain: "development", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_TaskResourceAttributes{ + TaskResourceAttributes: &admin.TaskResourceAttributes{ + Defaults: &admin.TaskResourceSpec{ + Cpu: testutils.RandomName(2), + Memory: testutils.RandomName(5), + }, + }, + }, + }, + } } diff --git a/cmd/update/matchable_workflow_execution_config.go b/cmd/update/matchable_workflow_execution_config.go index b9489d67ff..100ee7e77a 100644 --- a/cmd/update/matchable_workflow_execution_config.go +++ b/cmd/update/matchable_workflow_execution_config.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/flyteorg/flytectl/cmd/config/subcommand/workflowexecutionconfig" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" sconfig "github.com/flyteorg/flytectl/cmd/config/subcommand" cmdCore "github.com/flyteorg/flytectl/cmd/core" @@ -74,9 +75,9 @@ func updateWorkflowExecutionConfigFunc(ctx context.Context, args []string, cmdCt domain := workflowExecutionConfigFileConfig.Domain workflowName := workflowExecutionConfigFileConfig.Workflow - // Updates the admin matchable attribute from workflowExecutionConfigFileConfig - if err := DecorateAndUpdateMatchableAttr(ctx, project, domain, workflowName, cmdCtx.AdminUpdaterExt(), - workflowExecutionConfigFileConfig, updateConfig.DryRun); err != nil { + if err := DecorateAndUpdateMatchableAttr(ctx, cmdCtx, project, domain, workflowName, + admin.MatchableResource_WORKFLOW_EXECUTION_CONFIG, workflowExecutionConfigFileConfig, + updateConfig.DryRun, updateConfig.Force); err != nil { return err } return nil diff --git a/cmd/update/matchable_workflow_execution_config_test.go b/cmd/update/matchable_workflow_execution_config_test.go index 9f5ef81baf..f81f92a883 100644 --- a/cmd/update/matchable_workflow_execution_config_test.go +++ b/cmd/update/matchable_workflow_execution_config_test.go @@ -4,87 +4,577 @@ import ( "fmt" "testing" - "github.com/flyteorg/flytectl/cmd/config/subcommand/workflowexecutionconfig" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + + "github.com/flyteorg/flytectl/cmd/config/subcommand/workflowexecutionconfig" + "github.com/flyteorg/flytectl/cmd/testutils" + "github.com/flyteorg/flytectl/pkg/ext" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" ) -func updateWorkflowExecutionConfigSetup() { - workflowexecutionconfig.DefaultUpdateConfig = &workflowexecutionconfig.AttrUpdateConfig{} +const ( + validProjectWorkflowExecutionConfigFilePath = "testdata/valid_project_workflow_execution_config.yaml" + validProjectDomainWorkflowExecutionConfigFilePath = "testdata/valid_project_domain_workflow_execution_config.yaml" + validWorkflowExecutionConfigFilePath = "testdata/valid_workflow_workflow_execution_config.yaml" +) + +func TestWorkflowExecutionConfigUpdateRequiresAttributeFile(t *testing.T) { + testWorkflowExecutionConfigUpdate( + /* setup */ nil, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "attrFile is mandatory") + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestWorkflowExecutionConfigUpdateFailsWhenAttributeFileDoesNotExist(t *testing.T) { + testWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = testDataNonExistentFile + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "unable to read from testdata/non-existent-file yaml file") + s.UpdaterExt.AssertNotCalled(t, "FetchWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestWorkflowExecutionConfigUpdateFailsWhenAttributeFileIsMalformed(t *testing.T) { + testWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = testDataInvalidAttrFile + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "error unmarshaling JSON: while decoding JSON: json: unknown field \"InvalidDomain\"") + s.UpdaterExt.AssertNotCalled(t, "FetchWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +func TestWorkflowExecutionConfigUpdateHappyPath(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionConfigFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainWorkflowExecutionConfigFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectWorkflowExecutionConfigFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project`) + }) + }) +} + +func TestWorkflowExecutionConfigUpdateFailsWithoutForceFlag(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionConfigFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainWorkflowExecutionConfigFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectWorkflowExecutionConfigFilePath + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func TestWorkflowExecutionConfigUpdateDoesNothingWithDryRunFlag(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionConfigFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainWorkflowExecutionConfigFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectWorkflowExecutionConfigFilePath + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func TestWorkflowExecutionConfigUpdateIgnoresForceFlagWithDryRun(t *testing.T) { + t.Run("workflow without --force", func(t *testing.T) { + testWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionConfigFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("workflow with --force", func(t *testing.T) { + testWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionConfigFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain without --force", func(t *testing.T) { + testProjectDomainWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainWorkflowExecutionConfigFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain with --force", func(t *testing.T) { + testProjectDomainWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainWorkflowExecutionConfigFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project without --force", func(t *testing.T) { + testProjectWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectWorkflowExecutionConfigFilePath + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project with --force", func(t *testing.T) { + testProjectWorkflowExecutionConfigUpdate( + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectWorkflowExecutionConfigFilePath + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertNotCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) } -func TestWorkflowExecutionConfigs(t *testing.T) { - t.Run("no input file for update", func(t *testing.T) { - s := setup() - updateWorkflowExecutionConfigSetup() - err := updateWorkflowExecutionConfigFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("attrFile is mandatory while calling update for workflow execution config"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("successful update project domain attribute", func(t *testing.T) { - s := setup() - updateWorkflowExecutionConfigSetup() - workflowexecutionconfig.DefaultUpdateConfig.AttrFile = "testdata/valid_project_domain_workflow_execution_config.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateProjectDomainAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything).Return(nil) - err := updateWorkflowExecutionConfigFunc(s.Ctx, []string{}, s.CmdCtx) - assert.Nil(t, err) - s.TearDownAndVerify(t, `Updated attributes from flytesnacks project and domain development`) - }) - t.Run("failed update project domain attribute", func(t *testing.T) { - s := setup() - updateWorkflowExecutionConfigSetup() - workflowexecutionconfig.DefaultUpdateConfig.AttrFile = "testdata/valid_project_domain_workflow_execution_config.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateProjectDomainAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything).Return(fmt.Errorf("failed to update attributes")) - err := updateWorkflowExecutionConfigFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("failed to update attributes"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("successful update workflow attribute", func(t *testing.T) { - s := setup() - updateWorkflowExecutionConfigSetup() - workflowexecutionconfig.DefaultUpdateConfig.AttrFile = "testdata/valid_workflow_workflow_execution_config.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateWorkflowAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything).Return(nil) - err := updateWorkflowExecutionConfigFunc(s.Ctx, []string{}, s.CmdCtx) - assert.Nil(t, err) - s.TearDownAndVerify(t, `Updated attributes from flytesnacks project and domain development and workflow core.control_flow.merge_sort.merge_sort`) - }) - t.Run("failed update workflow attribute", func(t *testing.T) { - s := setup() - updateWorkflowExecutionConfigSetup() - workflowexecutionconfig.DefaultUpdateConfig.AttrFile = "testdata/valid_workflow_workflow_execution_config.yaml" - // No args implying project domain attribute deletion - s.UpdaterExt.OnUpdateWorkflowAttributesMatch(mock.Anything, mock.Anything, mock.Anything, - mock.Anything, mock.Anything).Return(fmt.Errorf("failed to update attributes")) - err := updateWorkflowExecutionConfigFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("failed to update attributes"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("non existent file", func(t *testing.T) { - s := setup() - updateWorkflowExecutionConfigSetup() - workflowexecutionconfig.DefaultUpdateConfig.AttrFile = testDataNonExistentFile - err := updateWorkflowExecutionConfigFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("unable to read from testdata/non-existent-file yaml file"), err) - s.TearDownAndVerify(t, ``) - }) - t.Run("invalid update file", func(t *testing.T) { - s := setup() - updateWorkflowExecutionConfigSetup() - workflowexecutionconfig.DefaultUpdateConfig.AttrFile = testDataInvalidAttrFile - err := updateWorkflowExecutionConfigFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("error unmarshaling JSON: while decoding JSON: json: unknown field \"InvalidDomain\""), err) - s.TearDownAndVerify(t, ``) +func TestWorkflowExecutionConfigUpdateSucceedsWhenAttributesDoNotExist(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowExecutionConfigUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_WORKFLOW_EXECUTION_CONFIG). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionConfigFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainWorkflowExecutionConfigUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_WORKFLOW_EXECUTION_CONFIG). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainWorkflowExecutionConfigFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project and domain development`) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectWorkflowExecutionConfigUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_WORKFLOW_EXECUTION_CONFIG). + Return(nil, ext.NewNotFoundError("attribute")) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(nil) + }, + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectWorkflowExecutionConfigFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + s.TearDownAndVerifyContains(t, `Updated attributes from flytesnacks project`) + }) + }) +} + +func TestWorkflowExecutionConfigUpdateFailsWhenAdminClientFails(t *testing.T) { + t.Run("workflow", func(t *testing.T) { + testWorkflowExecutionConfigUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_WORKFLOW_EXECUTION_CONFIG). + Return(&admin.WorkflowAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.WorkflowAttributes) { + config.AttrFile = validWorkflowExecutionConfigFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateWorkflowAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("domain", func(t *testing.T) { + testProjectDomainWorkflowExecutionConfigUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_WORKFLOW_EXECUTION_CONFIG). + Return(&admin.ProjectDomainAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectDomainAttributes) { + config.AttrFile = validProjectDomainWorkflowExecutionConfigFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectDomainAttributes", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + }) + + t.Run("project", func(t *testing.T) { + testProjectWorkflowExecutionConfigUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_WORKFLOW_EXECUTION_CONFIG). + Return(&admin.ProjectAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectAttributes) { + config.AttrFile = validProjectWorkflowExecutionConfigFilePath + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.UpdaterExt.AssertCalled(t, "UpdateProjectAttributes", mock.Anything, mock.Anything, mock.Anything) + }) + }) +} + +func testWorkflowExecutionConfigUpdate( + setup func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.WorkflowAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testWorkflowExecutionConfigUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.WorkflowAttributes) { + s.FetcherExt. + OnFetchWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, admin.MatchableResource_WORKFLOW_EXECUTION_CONFIG). + Return(&admin.WorkflowAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateWorkflowAttributesMatch(s.Ctx, target.Project, target.Domain, target.Workflow, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testWorkflowExecutionConfigUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.WorkflowAttributes), + setup func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.WorkflowAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + workflowexecutionconfig.DefaultUpdateConfig = &workflowexecutionconfig.AttrUpdateConfig{} + target := newTestWorkflowExecutionConfig() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, workflowexecutionconfig.DefaultUpdateConfig, target) + } + + err := updateWorkflowExecutionConfigFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + workflowexecutionconfig.DefaultUpdateConfig = &workflowexecutionconfig.AttrUpdateConfig{} +} + +func newTestWorkflowExecutionConfig() *admin.WorkflowAttributes { + return &admin.WorkflowAttributes{ + // project, domain, and workflow names need to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + Domain: "development", + Workflow: "core.control_flow.merge_sort.merge_sort", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_WorkflowExecutionConfig{ + WorkflowExecutionConfig: &admin.WorkflowExecutionConfig{ + MaxParallelism: 1337, + Annotations: &admin.Annotations{ + Values: map[string]string{ + testutils.RandomName(5): testutils.RandomName(10), + testutils.RandomName(5): testutils.RandomName(10), + testutils.RandomName(5): testutils.RandomName(10), + }, + }, + }, + }, + }, + } +} + +func testProjectWorkflowExecutionConfigUpdate( + setup func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testProjectWorkflowExecutionConfigUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectAttributes) { + s.FetcherExt. + OnFetchProjectAttributesMatch(s.Ctx, target.Project, admin.MatchableResource_WORKFLOW_EXECUTION_CONFIG). + Return(&admin.ProjectAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectAttributesMatch(s.Ctx, target.Project, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testProjectWorkflowExecutionConfigUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.ProjectAttributes), + setup func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + workflowexecutionconfig.DefaultUpdateConfig = &workflowexecutionconfig.AttrUpdateConfig{} + target := newTestProjectWorkflowExecutionConfig() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, workflowexecutionconfig.DefaultUpdateConfig, target) + } + + err := updateWorkflowExecutionConfigFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + workflowexecutionconfig.DefaultUpdateConfig = &workflowexecutionconfig.AttrUpdateConfig{} +} + +func newTestProjectWorkflowExecutionConfig() *admin.ProjectAttributes { + return &admin.ProjectAttributes{ + // project name needs to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_WorkflowExecutionConfig{ + WorkflowExecutionConfig: &admin.WorkflowExecutionConfig{ + MaxParallelism: 1337, + Annotations: &admin.Annotations{ + Values: map[string]string{ + testutils.RandomName(5): testutils.RandomName(10), + testutils.RandomName(5): testutils.RandomName(10), + testutils.RandomName(5): testutils.RandomName(10), + }, + }, + }, + }, + }, + } +} + +func testProjectDomainWorkflowExecutionConfigUpdate( + setup func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectDomainAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + testProjectDomainWorkflowExecutionConfigUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes) { + s.FetcherExt. + OnFetchProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, admin.MatchableResource_WORKFLOW_EXECUTION_CONFIG). + Return(&admin.ProjectDomainAttributesGetResponse{Attributes: target}, nil) + s.UpdaterExt. + OnUpdateProjectDomainAttributesMatch(s.Ctx, target.Project, target.Domain, mock.Anything). + Return(nil) + }, + setup, + asserter, + ) +} + +func testProjectDomainWorkflowExecutionConfigUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, target *admin.ProjectDomainAttributes), + setup func(s *testutils.TestStruct, config *workflowexecutionconfig.AttrUpdateConfig, target *admin.ProjectDomainAttributes), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + workflowexecutionconfig.DefaultUpdateConfig = &workflowexecutionconfig.AttrUpdateConfig{} + target := newTestProjectDomainWorkflowExecutionConfig() + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, workflowexecutionconfig.DefaultUpdateConfig, target) + } + + err := updateWorkflowExecutionConfigFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + workflowexecutionconfig.DefaultUpdateConfig = &workflowexecutionconfig.AttrUpdateConfig{} +} + +func newTestProjectDomainWorkflowExecutionConfig() *admin.ProjectDomainAttributes { + return &admin.ProjectDomainAttributes{ + // project and domain names need to be same as in the tests spec files in testdata folder + Project: "flytesnacks", + Domain: "development", + MatchingAttributes: &admin.MatchingAttributes{ + Target: &admin.MatchingAttributes_WorkflowExecutionConfig{ + WorkflowExecutionConfig: &admin.WorkflowExecutionConfig{ + MaxParallelism: 1337, + Annotations: &admin.Annotations{ + Values: map[string]string{ + testutils.RandomName(5): testutils.RandomName(10), + testutils.RandomName(5): testutils.RandomName(10), + testutils.RandomName(5): testutils.RandomName(10), + }, + }, + }, + }, + }, + } } diff --git a/cmd/update/named_entity.go b/cmd/update/named_entity.go index c7644f4907..3101e29ba2 100644 --- a/cmd/update/named_entity.go +++ b/cmd/update/named_entity.go @@ -3,12 +3,13 @@ package update import ( "context" "fmt" + "os" "github.com/flyteorg/flytectl/clierrors" cmdCore "github.com/flyteorg/flytectl/cmd/core" + cmdUtil "github.com/flyteorg/flytectl/pkg/commandutils" "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/core" - "github.com/flyteorg/flytestdlib/logger" ) //go:generate pflags NamedEntityConfig --default-var namedEntityConfig --bind-default-var @@ -22,38 +23,80 @@ type NamedEntityConfig struct { Activate bool `json:"activate" pflag:",activate the named entity."` Description string `json:"description" pflag:",description of the named entity."` DryRun bool `json:"dryRun" pflag:",execute command without making any modifications."` + Force bool `json:"force" pflag:",do not ask for an acknowledgement during updates."` } func (cfg NamedEntityConfig) UpdateNamedEntity(ctx context.Context, name string, project string, domain string, rsType core.ResourceType, cmdCtx cmdCore.CommandContext) error { - archiveProject := cfg.Archive - activateProject := cfg.Activate - if activateProject == archiveProject && activateProject { + if cfg.Activate && cfg.Archive { return fmt.Errorf(clierrors.ErrInvalidStateUpdate) } - var nameEntityState admin.NamedEntityState - if activateProject { - nameEntityState = admin.NamedEntityState_NAMED_ENTITY_ACTIVE - } else if archiveProject { - nameEntityState = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + + id := &admin.NamedEntityIdentifier{ + Project: project, + Domain: domain, + Name: name, + } + + namedEntity, err := cmdCtx.AdminClient().GetNamedEntity(ctx, &admin.NamedEntityGetRequest{ + ResourceType: rsType, + Id: id, + }) + if err != nil { + return fmt.Errorf("update metadata for %s: could not fetch metadata: %w", name, err) + } + + oldMetadata, newMetadata := composeNamedMetadataEdits(cfg, namedEntity.Metadata) + patch, err := DiffAsYaml(diffPathBefore, diffPathAfter, oldMetadata, newMetadata) + if err != nil { + panic(err) } + if patch == "" { + fmt.Printf("No changes detected. Skipping the update.\n") + return nil + } + + fmt.Printf("The following changes are to be applied.\n%s\n", patch) + if cfg.DryRun { - logger.Infof(ctx, "skipping UpdateNamedEntity request (dryRun)") - } else { - _, err := cmdCtx.AdminClient().UpdateNamedEntity(ctx, &admin.NamedEntityUpdateRequest{ - ResourceType: rsType, - Id: &admin.NamedEntityIdentifier{ - Project: project, - Domain: domain, - Name: name, - }, - Metadata: &admin.NamedEntityMetadata{ - Description: cfg.Description, - State: nameEntityState, - }, - }) - if err != nil { - return err - } + fmt.Printf("skipping UpdateNamedEntity request (dryRun)\n") + return nil + } + + if !cfg.Force && !cmdUtil.AskForConfirmation("Continue?", os.Stdin) { + return fmt.Errorf("update aborted by user") } + + _, err = cmdCtx.AdminClient().UpdateNamedEntity(ctx, &admin.NamedEntityUpdateRequest{ + ResourceType: rsType, + Id: id, + Metadata: newMetadata, + }) + if err != nil { + return fmt.Errorf("update metadata for %s: update failed: %w", name, err) + } + return nil } + +func composeNamedMetadataEdits(config NamedEntityConfig, current *admin.NamedEntityMetadata) (old *admin.NamedEntityMetadata, new *admin.NamedEntityMetadata) { + old = &admin.NamedEntityMetadata{} + new = &admin.NamedEntityMetadata{} + + switch { + case config.Activate && config.Archive: + panic("cannot both activate and archive") + case config.Activate: + old.State = current.State + new.State = admin.NamedEntityState_NAMED_ENTITY_ACTIVE + case config.Archive: + old.State = current.State + new.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + } + + if config.Description != "" { + old.Description = current.Description + new.Description = config.Description + } + + return old, new +} diff --git a/cmd/update/named_entity_test.go b/cmd/update/named_entity_test.go index 4e0086d7c6..732bc9d249 100644 --- a/cmd/update/named_entity_test.go +++ b/cmd/update/named_entity_test.go @@ -1,39 +1,97 @@ package update import ( + "context" "fmt" - "testing" + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/mock" + + "github.com/flyteorg/flytectl/cmd/config" + cmdCore "github.com/flyteorg/flytectl/cmd/core" "github.com/flyteorg/flytectl/cmd/testutils" "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/core" - "github.com/stretchr/testify/mock" - - "github.com/stretchr/testify/assert" ) -func TestNamedEntity(t *testing.T) { - s := testutils.Setup() - s.MockAdminClient.OnUpdateNamedEntityMatch(mock.Anything, mock.Anything).Return(&admin.NamedEntityUpdateResponse{}, nil) - namedEntityConfig = &NamedEntityConfig{Archive: false, Activate: true, Description: "named entity description"} - assert.Nil(t, namedEntityConfig.UpdateNamedEntity(s.Ctx, "namedEntity", "project", "domain", - core.ResourceType_WORKFLOW, s.CmdCtx)) - namedEntityConfig = &NamedEntityConfig{Archive: true, Activate: false, Description: "named entity description"} - assert.Nil(t, namedEntityConfig.UpdateNamedEntity(s.Ctx, "namedEntity", "project", "domain", - core.ResourceType_WORKFLOW, s.CmdCtx)) +func testNamedEntityUpdate( + resourceType core.ResourceType, + setup func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity), + asserter func(s *testutils.TestStruct, err error), +) { + testNamedEntityUpdateWithMockSetup( + resourceType, + /* mockSetup */ func(s *testutils.TestStruct, namedEntity *admin.NamedEntity) { + s.MockAdminClient. + OnGetNamedEntityMatch( + s.Ctx, + mock.MatchedBy(func(r *admin.NamedEntityGetRequest) bool { + return r.ResourceType == namedEntity.ResourceType && + cmp.Equal(r.Id, namedEntity.Id) + })). + Return(namedEntity, nil) + s.MockAdminClient. + OnUpdateNamedEntityMatch(s.Ctx, mock.Anything). + Return(&admin.NamedEntityUpdateResponse{}, nil) + }, + setup, + asserter, + ) } -func TestNamedEntityValidationFailure(t *testing.T) { +func testNamedEntityUpdateWithMockSetup( + resourceType core.ResourceType, + mockSetup func(s *testutils.TestStruct, namedEntity *admin.NamedEntity), + setup func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity), + asserter func(s *testutils.TestStruct, err error), +) { s := testutils.Setup() - namedEntityConfig := &NamedEntityConfig{Archive: true, Activate: true, Description: "named entity description"} - assert.NotNil(t, namedEntityConfig.UpdateNamedEntity(s.Ctx, "namedEntity", "project", "domain", - core.ResourceType_WORKFLOW, s.CmdCtx)) + config := &NamedEntityConfig{} + target := newTestNamedEntity(resourceType) + + if mockSetup != nil { + mockSetup(&s, target) + } + + if setup != nil { + setup(&s, config, target) + } + + updateMetadataFactory := getUpdateMetadataFactory(resourceType) + + args := []string{target.Id.Name} + err := updateMetadataFactory(config)(s.Ctx, args, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } } -func TestNamedEntityFailure(t *testing.T) { - s := testutils.Setup() - namedEntityConfig := &NamedEntityConfig{Archive: true, Activate: true, Description: "named entity description"} - s.MockAdminClient.OnUpdateNamedEntityMatch(mock.Anything, mock.Anything).Return(nil, fmt.Errorf("failed to update")) - assert.NotNil(t, namedEntityConfig.UpdateNamedEntity(s.Ctx, "namedEntity", "project", "domain", - core.ResourceType_WORKFLOW, s.CmdCtx)) +func newTestNamedEntity(resourceType core.ResourceType) *admin.NamedEntity { + return &admin.NamedEntity{ + Id: &admin.NamedEntityIdentifier{ + Name: testutils.RandomName(12), + Project: config.GetConfig().Project, + Domain: config.GetConfig().Domain, + }, + ResourceType: resourceType, + Metadata: &admin.NamedEntityMetadata{ + State: admin.NamedEntityState_NAMED_ENTITY_ACTIVE, + Description: testutils.RandomName(50), + }, + } +} + +func getUpdateMetadataFactory(resourceType core.ResourceType) func(namedEntityConfig *NamedEntityConfig) func(ctx context.Context, args []string, cmdCtx cmdCore.CommandContext) error { + switch resourceType { + case core.ResourceType_LAUNCH_PLAN: + return getUpdateLPMetaFunc + case core.ResourceType_TASK: + return getUpdateTaskFunc + case core.ResourceType_WORKFLOW: + return getUpdateWorkflowFunc + } + + panic(fmt.Sprintf("no known mapping exists between resource type %s and "+ + "corresponding update metadata factory function", resourceType)) } diff --git a/cmd/update/namedentityconfig_flags.go b/cmd/update/namedentityconfig_flags.go index 8d3b7a96b8..2f1345bc98 100755 --- a/cmd/update/namedentityconfig_flags.go +++ b/cmd/update/namedentityconfig_flags.go @@ -54,5 +54,6 @@ func (cfg NamedEntityConfig) GetPFlagSet(prefix string) *pflag.FlagSet { cmdFlags.BoolVar(&namedEntityConfig.Activate, fmt.Sprintf("%v%v", prefix, "activate"), namedEntityConfig.Activate, "activate the named entity.") cmdFlags.StringVar(&namedEntityConfig.Description, fmt.Sprintf("%v%v", prefix, "description"), namedEntityConfig.Description, "description of the named entity.") cmdFlags.BoolVar(&namedEntityConfig.DryRun, fmt.Sprintf("%v%v", prefix, "dryRun"), namedEntityConfig.DryRun, "execute command without making any modifications.") + cmdFlags.BoolVar(&namedEntityConfig.Force, fmt.Sprintf("%v%v", prefix, "force"), namedEntityConfig.Force, "do not ask for an acknowledgement during updates.") return cmdFlags } diff --git a/cmd/update/namedentityconfig_flags_test.go b/cmd/update/namedentityconfig_flags_test.go index 9c85b8be20..43cf00ec2a 100755 --- a/cmd/update/namedentityconfig_flags_test.go +++ b/cmd/update/namedentityconfig_flags_test.go @@ -155,4 +155,18 @@ func TestNamedEntityConfig_SetFlags(t *testing.T) { } }) }) + t.Run("Test_force", func(t *testing.T) { + + t.Run("Override", func(t *testing.T) { + testValue := "1" + + cmdFlags.Set("force", testValue) + if vBool, err := cmdFlags.GetBool("force"); err == nil { + testDecodeJson_NamedEntityConfig(t, fmt.Sprintf("%v", vBool), &actual.Force) + + } else { + assert.FailNow(t, err.Error()) + } + }) + }) } diff --git a/cmd/update/project.go b/cmd/update/project.go index bec3d36b96..881f61fec8 100644 --- a/cmd/update/project.go +++ b/cmd/update/project.go @@ -3,12 +3,14 @@ package update import ( "context" "fmt" + "os" "github.com/flyteorg/flytectl/clierrors" "github.com/flyteorg/flytectl/cmd/config" "github.com/flyteorg/flytectl/cmd/config/subcommand/project" cmdCore "github.com/flyteorg/flytectl/cmd/core" - "github.com/flyteorg/flytestdlib/logger" + cmdUtil "github.com/flyteorg/flytectl/pkg/commandutils" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" ) const ( @@ -30,7 +32,7 @@ Incorrect usage when passing both archive and activate: :: - flytectl update project -p flytesnacks --archiveProject --activate + flytectl update project -p flytesnacks --archive --activate Incorrect usage when passing unknown-project: @@ -42,7 +44,7 @@ project ID is required flag :: - flytectl update project unknown-project --archiveProject + flytectl update project unknown-project --archive Update projects.(project/projects can be used interchangeably in these commands) @@ -83,24 +85,70 @@ Usage ) func updateProjectsFunc(ctx context.Context, args []string, cmdCtx cmdCore.CommandContext) error { - projectSpec, err := project.DefaultProjectConfig.GetProjectSpec(config.GetConfig()) + edits, err := project.DefaultProjectConfig.GetProjectSpec(config.GetConfig()) if err != nil { return err } - if projectSpec.Id == "" { + if edits.Id == "" { return fmt.Errorf(clierrors.ErrProjectNotPassed) } + currentProject, err := cmdCtx.AdminFetcherExt().GetProjectByID(ctx, edits.Id) + if err != nil { + return fmt.Errorf("update project %s: could not fetch project: %w", edits.Id, err) + } + + // We do not compare currentProject against edits directly, because edits does not + // have a complete set of project's fields - it will only contain fields that + // the update command allows updating. (For example, it won't have Domains field + // initialized.) + currentProjectWithEdits := copyProjectWithEdits(currentProject, edits) + patch, err := DiffAsYaml(diffPathBefore, diffPathAfter, currentProject, currentProjectWithEdits) + if err != nil { + panic(err) + } + if patch == "" { + fmt.Printf("No changes detected. Skipping the update.\n") + return nil + } + + fmt.Printf("The following changes are to be applied.\n%s\n", patch) + if project.DefaultProjectConfig.DryRun { - logger.Infof(ctx, "skipping UpdateProject request (dryRun)") - } else { - _, err := cmdCtx.AdminClient().UpdateProject(ctx, projectSpec) - if err != nil { - fmt.Printf(clierrors.ErrFailedProjectUpdate, projectSpec.Id, err) - return err - } + fmt.Printf("skipping UpdateProject request (dryRun)\n") + return nil + } + + if !project.DefaultProjectConfig.Force && !cmdUtil.AskForConfirmation("Continue?", os.Stdin) { + return fmt.Errorf("update aborted by user") } - fmt.Printf("Project %v updated\n", projectSpec.Id) + + _, err = cmdCtx.AdminClient().UpdateProject(ctx, edits) + if err != nil { + return fmt.Errorf(clierrors.ErrFailedProjectUpdate, edits.Id, err) + } + + fmt.Printf("project %s updated\n", edits.Id) return nil } + +// Makes a shallow copy of target and applies certain properties from edited to it. +// The properties applied are only the ones supported by update command: state, name, +// description, labels, etc. +func copyProjectWithEdits(target *admin.Project, edited *admin.Project) *admin.Project { + copy := *target + + copy.State = edited.State + if edited.Name != "" { + copy.Name = edited.Name + } + if edited.Description != "" { + copy.Description = edited.Description + } + if len(edited.GetLabels().GetValues()) != 0 { + copy.Labels = edited.Labels + } + + return © +} diff --git a/cmd/update/project_test.go b/cmd/update/project_test.go index 25fb768f47..a3152127d4 100644 --- a/cmd/update/project_test.go +++ b/cmd/update/project_test.go @@ -1,143 +1,258 @@ package update import ( - "errors" "fmt" "testing" "github.com/flyteorg/flytectl/cmd/config/subcommand/project" + "github.com/flyteorg/flytectl/cmd/testutils" + "github.com/flyteorg/flytectl/pkg/ext" - "github.com/flyteorg/flytectl/clierrors" "github.com/flyteorg/flytectl/cmd/config" "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) -const projectValue = "dummyProject" +func TestProjectCanBeActivated(t *testing.T) { + testProjectUpdate( + /* setup */ func(s *testutils.TestStruct, config *project.ConfigProject, project *admin.Project) { + project.State = admin.Project_ARCHIVED + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertCalled( + t, "UpdateProject", s.Ctx, + mock.MatchedBy( + func(r *admin.Project) bool { + return r.State == admin.Project_ACTIVE + })) + }) +} -var ( - projectUpdateRequest *admin.Project -) +func TestProjectCanBeArchived(t *testing.T) { + testProjectUpdate( + /* setup */ func(s *testutils.TestStruct, config *project.ConfigProject, project *admin.Project) { + project.State = admin.Project_ACTIVE + config.Archive = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertCalled( + t, "UpdateProject", s.Ctx, + mock.MatchedBy( + func(r *admin.Project) bool { + return r.State == admin.Project_ARCHIVED + })) + }) +} -func updateProjectSetup() { - projectUpdateRequest = &admin.Project{ - Id: projectValue, - State: admin.Project_ACTIVE, - } +func TestProjectCannotBeActivatedAndArchivedAtTheSameTime(t *testing.T) { + testProjectUpdate( + /* setup */ func(s *testutils.TestStruct, config *project.ConfigProject, project *admin.Project) { + config.Activate = true + config.Archive = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "Specify either activate or archive") + s.MockAdminClient.AssertNotCalled(t, "UpdateProject", mock.Anything, mock.Anything) + }) } -func modifyProjectFlags(newArchiveVal bool, newActivateVal bool) { - project.DefaultProjectConfig.ArchiveProject = newArchiveVal - project.DefaultProjectConfig.Archive = newArchiveVal - project.DefaultProjectConfig.ActivateProject = newActivateVal - project.DefaultProjectConfig.Activate = newActivateVal +func TestProjectUpdateDoesNothingWhenThereAreNoChanges(t *testing.T) { + testProjectUpdate( + /* setup */ func(s *testutils.TestStruct, config *project.ConfigProject, project *admin.Project) { + project.State = admin.Project_ACTIVE + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateProject", mock.Anything, mock.Anything) + }) } -func TestActivateProjectFunc(t *testing.T) { - s := setup() - updateProjectSetup() - config.GetConfig().Project = projectValue - project.DefaultProjectConfig.Name = projectValue - modifyProjectFlags(false, true) - projectUpdateRequest = &admin.Project{ - Id: projectValue, - Name: projectValue, - Labels: &admin.Labels{ - Values: map[string]string{}, +func TestProjectUpdateWithoutForceFlagFails(t *testing.T) { + testProjectUpdate( + /* setup */ func(s *testutils.TestStruct, config *project.ConfigProject, project *admin.Project) { + project.State = admin.Project_ARCHIVED + config.Activate = true + config.Force = false }, - State: admin.Project_ACTIVE, - } - s.MockAdminClient.OnUpdateProjectMatch(s.Ctx, projectUpdateRequest).Return(nil, nil) - err := updateProjectsFunc(s.Ctx, []string{}, s.CmdCtx) - assert.Nil(t, err) - s.MockAdminClient.AssertCalled(t, "UpdateProject", s.Ctx, projectUpdateRequest) - s.TearDownAndVerify(t, "Project dummyProject updated\n") + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.MockAdminClient.AssertNotCalled(t, "UpdateProject", mock.Anything, mock.Anything) + }) } -func TestActivateProjectFuncWithError(t *testing.T) { - s := setup() - updateProjectSetup() - config.GetConfig().Project = projectValue - project.DefaultProjectConfig.Name = projectValue - modifyProjectFlags(false, true) - projectUpdateRequest = &admin.Project{ - Id: projectValue, - Name: projectValue, - Labels: &admin.Labels{ - Values: map[string]string{}, +func TestProjectUpdateDoesNothingWithDryRunFlag(t *testing.T) { + testProjectUpdate( + /* setup */ func(s *testutils.TestStruct, config *project.ConfigProject, project *admin.Project) { + project.State = admin.Project_ARCHIVED + config.Activate = true + config.DryRun = true }, - State: admin.Project_ACTIVE, - } - s.MockAdminClient.OnUpdateProjectMatch(s.Ctx, projectUpdateRequest).Return(nil, errors.New("Error Updating Project")) - err := updateProjectsFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - s.MockAdminClient.AssertCalled(t, "UpdateProject", s.Ctx, projectUpdateRequest) - s.TearDownAndVerify(t, "Project dummyProject failed to update due to Error Updating Project\n") + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateProject", mock.Anything, mock.Anything) + }) } -func TestArchiveProjectFunc(t *testing.T) { - s := setup() - updateProjectSetup() - config.GetConfig().Project = projectValue - project.DefaultProjectConfig = &project.ConfigProject{} - project.DefaultProjectConfig.Name = projectValue - modifyProjectFlags(true, false) - projectUpdateRequest = &admin.Project{ - Id: projectValue, - Name: projectValue, - Labels: &admin.Labels{ - Values: nil, +func TestForceFlagIsIgnoredWithDryRunDuringProjectUpdate(t *testing.T) { + t.Run("without --force", func(t *testing.T) { + testProjectUpdate( + /* setup */ func(s *testutils.TestStruct, config *project.ConfigProject, project *admin.Project) { + project.State = admin.Project_ARCHIVED + config.Activate = true + + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateProject", mock.Anything, mock.Anything) + }) + }) + + t.Run("with --force", func(t *testing.T) { + testProjectUpdate( + /* setup */ func(s *testutils.TestStruct, config *project.ConfigProject, project *admin.Project) { + project.State = admin.Project_ARCHIVED + config.Activate = true + + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateProject", mock.Anything, mock.Anything) + }) + }) +} + +func TestProjectUpdateFailsWhenProjectDoesNotExist(t *testing.T) { + testProjectUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, project *admin.Project) { + s.FetcherExt. + OnGetProjectByID(s.Ctx, project.Id). + Return(nil, ext.NewNotFoundError("project not found")) + s.MockAdminClient. + OnUpdateProjectMatch(s.Ctx, mock.Anything). + Return(&admin.ProjectUpdateResponse{}, nil) }, - State: admin.Project_ARCHIVED, - } - s.MockAdminClient.OnUpdateProjectMatch(s.Ctx, projectUpdateRequest).Return(nil, nil) - err := updateProjectsFunc(s.Ctx, []string{}, s.CmdCtx) - assert.Nil(t, err) - s.MockAdminClient.AssertCalled(t, "UpdateProject", s.Ctx, projectUpdateRequest) - s.TearDownAndVerify(t, "Project dummyProject updated\n") + /* setup */ nil, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateProject", mock.Anything, mock.Anything) + }, + ) } -func TestArchiveProjectFuncWithError(t *testing.T) { - s := setup() - updateProjectSetup() - project.DefaultProjectConfig.Name = projectValue - project.DefaultProjectConfig.Labels = map[string]string{} - modifyProjectFlags(true, false) - projectUpdateRequest = &admin.Project{ - Id: projectValue, - Name: projectValue, - Labels: &admin.Labels{ - Values: map[string]string{}, +func TestProjectUpdateFailsWhenAdminClientFails(t *testing.T) { + testProjectUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, project *admin.Project) { + s.FetcherExt. + OnGetProjectByID(s.Ctx, project.Id). + Return(project, nil) + s.MockAdminClient. + OnUpdateProjectMatch(s.Ctx, mock.Anything). + Return(nil, fmt.Errorf("network error")) }, - State: admin.Project_ARCHIVED, - } - s.MockAdminClient.OnUpdateProjectMatch(s.Ctx, projectUpdateRequest).Return(nil, errors.New("Error Updating Project")) - err := updateProjectsFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - s.MockAdminClient.AssertCalled(t, "UpdateProject", s.Ctx, projectUpdateRequest) - s.TearDownAndVerify(t, "Project dummyProject failed to update"+ - " due to Error Updating Project\n") + /* setup */ func(s *testutils.TestStruct, config *project.ConfigProject, project *admin.Project) { + project.State = admin.Project_ARCHIVED + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.MockAdminClient.AssertCalled(t, "UpdateProject", mock.Anything, mock.Anything) + }, + ) } -func TestEmptyProjectInput(t *testing.T) { - s := setup() - updateProjectSetup() +func TestProjectUpdateRequiresProjectId(t *testing.T) { + testProjectUpdate( + /* setup */ func(s *testutils.TestStruct, config *project.ConfigProject, project *admin.Project) { + config.ID = "" + }, + func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "project id wasn't passed") + }) +} + +func testProjectUpdate( + setup func(s *testutils.TestStruct, config *project.ConfigProject, project *admin.Project), + asserter func(s *testutils.TestStruct, err error), +) { + testProjectUpdateWithMockSetup( + /* mockSetup */ func(s *testutils.TestStruct, project *admin.Project) { + s.FetcherExt. + OnGetProjectByID(s.Ctx, project.Id). + Return(project, nil) + s.MockAdminClient. + OnUpdateProjectMatch(s.Ctx, mock.Anything). + Return(&admin.ProjectUpdateResponse{}, nil) + }, + setup, + asserter, + ) +} + +func testProjectUpdateWithMockSetup( + mockSetup func(s *testutils.TestStruct, project *admin.Project), + setup func(s *testutils.TestStruct, config *project.ConfigProject, project *admin.Project), + asserter func(s *testutils.TestStruct, err error), +) { + s := testutils.Setup() + target := newTestProject() + + if mockSetup != nil { + mockSetup(&s, target) + } + + project.DefaultProjectConfig = &project.ConfigProject{ + ID: target.Id, + } config.GetConfig().Project = "" - modifyProjectFlags(false, true) - err := updateProjectsFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf(clierrors.ErrProjectNotPassed), err) + config.GetConfig().Domain = "" + if setup != nil { + setup(&s, project.DefaultProjectConfig, target) + } + + err := updateProjectsFunc(s.Ctx, nil, s.CmdCtx) + + if asserter != nil { + asserter(&s, err) + } + + // cleanup + project.DefaultProjectConfig = &project.ConfigProject{} + config.GetConfig().Project = "" + config.GetConfig().Domain = "" } -func TestInvalidInput(t *testing.T) { - s := setup() - updateProjectSetup() - config.GetConfig().Project = projectValue - project.DefaultProjectConfig.Name = projectValue - modifyProjectFlags(true, true) - err := updateProjectsFunc(s.Ctx, []string{}, s.CmdCtx) - assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf(clierrors.ErrInvalidStateUpdate), err) - s.TearDownAndVerify(t, "") +func newTestProject() *admin.Project { + return &admin.Project{ + Id: testutils.RandomName(12), + Name: testutils.RandomName(12), + State: admin.Project_ACTIVE, + Domains: []*admin.Domain{ + { + Id: testutils.RandomName(12), + Name: testutils.RandomName(12), + }, + }, + Description: testutils.RandomName(12), + Labels: &admin.Labels{ + Values: map[string]string{ + testutils.RandomName(5): testutils.RandomName(12), + testutils.RandomName(5): testutils.RandomName(12), + testutils.RandomName(5): testutils.RandomName(12), + }, + }, + } } diff --git a/cmd/update/task_meta_test.go b/cmd/update/task_meta_test.go index e121cbe8bd..e1ffc9a13a 100644 --- a/cmd/update/task_meta_test.go +++ b/cmd/update/task_meta_test.go @@ -4,28 +4,193 @@ import ( "fmt" "testing" + "github.com/google/go-cmp/cmp" + "github.com/flyteorg/flytectl/cmd/testutils" + "github.com/flyteorg/flytectl/pkg/ext" "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) -func TestTaskUpdate(t *testing.T) { - s := testutils.Setup() - args := []string{"task1"} - s.MockAdminClient.OnUpdateNamedEntityMatch(mock.Anything, mock.Anything).Return(&admin.NamedEntityUpdateResponse{}, nil) - assert.Nil(t, getUpdateTaskFunc(&NamedEntityConfig{})(s.Ctx, args, s.CmdCtx)) +func TestTaskMetadataCanBeActivated(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_TASK, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertCalled( + t, "UpdateNamedEntity", s.Ctx, + mock.MatchedBy( + func(r *admin.NamedEntityUpdateRequest) bool { + return r.GetMetadata().GetState() == admin.NamedEntityState_NAMED_ENTITY_ACTIVE + })) + }) } -func TestTaskUpdateFail(t *testing.T) { - s := testutils.Setup() - args := []string{"workflow1"} - s.MockAdminClient.OnUpdateNamedEntityMatch(mock.Anything, mock.Anything).Return(nil, fmt.Errorf("failed to update")) - assert.NotNil(t, getUpdateTaskFunc(&NamedEntityConfig{})(s.Ctx, args, s.CmdCtx)) +func TestTaskMetadataCanBeArchived(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_TASK, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ACTIVE + config.Archive = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertCalled( + t, "UpdateNamedEntity", s.Ctx, + mock.MatchedBy( + func(r *admin.NamedEntityUpdateRequest) bool { + return r.GetMetadata().GetState() == admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + })) + }) +} + +func TestTaskMetadataCannotBeActivatedAndArchivedAtTheSameTime(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_TASK, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + config.Activate = true + config.Archive = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "Specify either activate or archive") + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) +} + +func TestTaskMetadataUpdateDoesNothingWhenThereAreNoChanges(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_TASK, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ACTIVE + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) +} + +func TestTaskMetadataUpdateWithoutForceFlagFails(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_TASK, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) +} + +func TestTaskMetadataUpdateDoesNothingWithDryRunFlag(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_TASK, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) } -func TestTaskUpdateInvalidArgs(t *testing.T) { +func TestForceFlagIsIgnoredWithDryRunDuringTaskMetadataUpdate(t *testing.T) { + t.Run("without --force", func(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_TASK, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) + }) + + t.Run("with --force", func(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_TASK, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) + }) +} + +func TestTaskMetadataUpdateFailsWhenTaskDoesNotExist(t *testing.T) { + testNamedEntityUpdateWithMockSetup( + core.ResourceType_TASK, + /* mockSetup */ func(s *testutils.TestStruct, namedEntity *admin.NamedEntity) { + s.MockAdminClient. + OnGetNamedEntityMatch( + s.Ctx, + mock.MatchedBy(func(r *admin.NamedEntityGetRequest) bool { + return r.ResourceType == namedEntity.ResourceType && + cmp.Equal(r.Id, namedEntity.Id) + })). + Return(nil, ext.NewNotFoundError("named entity not found")) + s.MockAdminClient. + OnUpdateNamedEntityMatch(s.Ctx, mock.Anything). + Return(&admin.NamedEntityUpdateResponse{}, nil) + }, + /* setup */ nil, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }, + ) +} + +func TestTaskMetadataUpdateFailsWhenAdminClientFails(t *testing.T) { + testNamedEntityUpdateWithMockSetup( + core.ResourceType_TASK, + /* mockSetup */ func(s *testutils.TestStruct, namedEntity *admin.NamedEntity) { + s.MockAdminClient. + OnGetNamedEntityMatch( + s.Ctx, + mock.MatchedBy(func(r *admin.NamedEntityGetRequest) bool { + return r.ResourceType == namedEntity.ResourceType && + cmp.Equal(r.Id, namedEntity.Id) + })). + Return(namedEntity, nil) + s.MockAdminClient. + OnUpdateNamedEntityMatch(s.Ctx, mock.Anything). + Return(nil, fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.MockAdminClient.AssertCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }, + ) +} + +func TestTaskMetadataUpdateRequiresTaskName(t *testing.T) { s := testutils.Setup() - assert.NotNil(t, getUpdateTaskFunc(&NamedEntityConfig{})(s.Ctx, []string{}, s.CmdCtx)) + config := &NamedEntityConfig{} + + err := getUpdateTaskFunc(config)(s.Ctx, nil, s.CmdCtx) + + assert.ErrorContains(t, err, "task name wasn't passed") } diff --git a/cmd/update/testdata/valid_project_cluster_attribute.yaml b/cmd/update/testdata/valid_project_cluster_attribute.yaml new file mode 100644 index 0000000000..27dc7e2f3c --- /dev/null +++ b/cmd/update/testdata/valid_project_cluster_attribute.yaml @@ -0,0 +1,4 @@ +project: flytesnacks +attributes: + "foo": "bar" + "buzz": "lightyear" \ No newline at end of file diff --git a/cmd/update/testdata/valid_project_execution_cluster_label.yaml b/cmd/update/testdata/valid_project_execution_cluster_label.yaml new file mode 100644 index 0000000000..7d9e207ba7 --- /dev/null +++ b/cmd/update/testdata/valid_project_execution_cluster_label.yaml @@ -0,0 +1,2 @@ +project: flytesnacks +value: foo \ No newline at end of file diff --git a/cmd/update/testdata/valid_project_execution_queue_attribute.yaml b/cmd/update/testdata/valid_project_execution_queue_attribute.yaml new file mode 100644 index 0000000000..7ddb5f135d --- /dev/null +++ b/cmd/update/testdata/valid_project_execution_queue_attribute.yaml @@ -0,0 +1,6 @@ +project: flytesnacks +tags: + - foo + - bar + - buzz + - lightyear \ No newline at end of file diff --git a/cmd/update/testdata/valid_project_plugin_override.yaml b/cmd/update/testdata/valid_project_plugin_override.yaml new file mode 100644 index 0000000000..1ad8e5cd01 --- /dev/null +++ b/cmd/update/testdata/valid_project_plugin_override.yaml @@ -0,0 +1,7 @@ +project: flytesnacks +overrides: + - task_type: python_task + plugin_id: + - plugin_override1 + - plugin_override2 + missing_plugin_behavior: 1 # 0 : FAIL , 1: DEFAULT diff --git a/cmd/update/testdata/valid_project_task_attribute.yaml b/cmd/update/testdata/valid_project_task_attribute.yaml new file mode 100644 index 0000000000..77281d5a22 --- /dev/null +++ b/cmd/update/testdata/valid_project_task_attribute.yaml @@ -0,0 +1,7 @@ +project: flytesnacks +defaults: + cpu: "1" + memory: 150Mi +limits: + cpu: "2" + memory: 450Mi \ No newline at end of file diff --git a/cmd/update/testdata/valid_project_workflow_execution_config.yaml b/cmd/update/testdata/valid_project_workflow_execution_config.yaml new file mode 100644 index 0000000000..414e3ecbb4 --- /dev/null +++ b/cmd/update/testdata/valid_project_workflow_execution_config.yaml @@ -0,0 +1,2 @@ +project: flytesnacks +max_parallelism: 5 \ No newline at end of file diff --git a/cmd/update/update.go b/cmd/update/update.go index 23c69ac608..9677ee897e 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -20,12 +20,11 @@ const ( updateUse = "update" updateShort = `Update Flyte resources e.g., project.` updatecmdLong = ` -Currently, this command only provides subcommands to update project. -Take input project that needs to be archived or unarchived. Name of the project to be updated is a mandatory field. -Update Flyte resources; if a project: +Provides subcommands to update Flyte resources, such as tasks, workflows, launch plans, executions, and projects. +Update Flyte resource; e.g., to activate a project: :: - flytectl update project -p flytesnacks --activateProject + flytectl update project -p flytesnacks --activate ` ) diff --git a/cmd/update/update_test.go b/cmd/update/update_test.go index d4a256e06c..23ec7d3495 100644 --- a/cmd/update/update_test.go +++ b/cmd/update/update_test.go @@ -5,8 +5,6 @@ import ( "testing" "github.com/stretchr/testify/assert" - - "github.com/flyteorg/flytectl/cmd/testutils" ) const ( @@ -14,8 +12,6 @@ const ( testDataInvalidAttrFile = "testdata/invalid_attribute.yaml" ) -var setup = testutils.Setup - func TestUpdateCommand(t *testing.T) { updateCommand := CreateUpdateCommand() assert.Equal(t, updateCommand.Use, updateUse) diff --git a/cmd/update/workflow_meta_test.go b/cmd/update/workflow_meta_test.go index 7c7a1fc78e..2d49de2b25 100644 --- a/cmd/update/workflow_meta_test.go +++ b/cmd/update/workflow_meta_test.go @@ -4,28 +4,193 @@ import ( "fmt" "testing" + "github.com/google/go-cmp/cmp" + "github.com/flyteorg/flytectl/cmd/testutils" + "github.com/flyteorg/flytectl/pkg/ext" "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" + "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) -func TestWorkflowUpdate(t *testing.T) { - s := testutils.Setup() - args := []string{"workflow1"} - s.MockAdminClient.OnUpdateNamedEntityMatch(mock.Anything, mock.Anything).Return(&admin.NamedEntityUpdateResponse{}, nil) - assert.Nil(t, getUpdateWorkflowFunc(&NamedEntityConfig{})(s.Ctx, args, s.CmdCtx)) +func TestWorkflowMetadataCanBeActivated(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_WORKFLOW, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertCalled( + t, "UpdateNamedEntity", s.Ctx, + mock.MatchedBy( + func(r *admin.NamedEntityUpdateRequest) bool { + return r.GetMetadata().GetState() == admin.NamedEntityState_NAMED_ENTITY_ACTIVE + })) + }) } -func TestWorkflowUpdateFail(t *testing.T) { - s := testutils.Setup() - args := []string{"workflow1"} - s.MockAdminClient.OnUpdateNamedEntityMatch(mock.Anything, mock.Anything).Return(nil, fmt.Errorf("failed to update")) - assert.NotNil(t, getUpdateWorkflowFunc(&NamedEntityConfig{})(s.Ctx, args, s.CmdCtx)) +func TestWorkflowMetadataCanBeArchived(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_WORKFLOW, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ACTIVE + config.Archive = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertCalled( + t, "UpdateNamedEntity", s.Ctx, + mock.MatchedBy( + func(r *admin.NamedEntityUpdateRequest) bool { + return r.GetMetadata().GetState() == admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + })) + }) +} + +func TestWorkflowMetadataCannotBeActivatedAndArchivedAtTheSameTime(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_WORKFLOW, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + config.Activate = true + config.Archive = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "Specify either activate or archive") + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) +} + +func TestWorkflowMetadataUpdateDoesNothingWhenThereAreNoChanges(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_WORKFLOW, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ACTIVE + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) +} + +func TestWorkflowMetadataUpdateWithoutForceFlagFails(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_WORKFLOW, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + config.Force = false + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.ErrorContains(t, err, "update aborted by user") + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) +} + +func TestWorkflowMetadataUpdateDoesNothingWithDryRunFlag(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_WORKFLOW, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) } -func TestWorkflowUpdateInvalidArgs(t *testing.T) { +func TestForceFlagIsIgnoredWithDryRunDuringWorkflowMetadataUpdate(t *testing.T) { + t.Run("without --force", func(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_WORKFLOW, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + + config.Force = false + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) + }) + + t.Run("with --force", func(t *testing.T) { + testNamedEntityUpdate(core.ResourceType_WORKFLOW, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + + config.Force = true + config.DryRun = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Nil(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }) + }) +} + +func TestWorkflowMetadataUpdateFailsWhenWorkflowDoesNotExist(t *testing.T) { + testNamedEntityUpdateWithMockSetup( + core.ResourceType_WORKFLOW, + /* mockSetup */ func(s *testutils.TestStruct, namedEntity *admin.NamedEntity) { + s.MockAdminClient. + OnGetNamedEntityMatch( + s.Ctx, + mock.MatchedBy(func(r *admin.NamedEntityGetRequest) bool { + return r.ResourceType == namedEntity.ResourceType && + cmp.Equal(r.Id, namedEntity.Id) + })). + Return(nil, ext.NewNotFoundError("named entity not found")) + s.MockAdminClient. + OnUpdateNamedEntityMatch(s.Ctx, mock.Anything). + Return(&admin.NamedEntityUpdateResponse{}, nil) + }, + /* setup */ nil, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.MockAdminClient.AssertNotCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }, + ) +} + +func TestWorkflowMetadataUpdateFailsWhenAdminClientFails(t *testing.T) { + testNamedEntityUpdateWithMockSetup( + core.ResourceType_WORKFLOW, + /* mockSetup */ func(s *testutils.TestStruct, namedEntity *admin.NamedEntity) { + s.MockAdminClient. + OnGetNamedEntityMatch( + s.Ctx, + mock.MatchedBy(func(r *admin.NamedEntityGetRequest) bool { + return r.ResourceType == namedEntity.ResourceType && + cmp.Equal(r.Id, namedEntity.Id) + })). + Return(namedEntity, nil) + s.MockAdminClient. + OnUpdateNamedEntityMatch(s.Ctx, mock.Anything). + Return(nil, fmt.Errorf("network error")) + }, + /* setup */ func(s *testutils.TestStruct, config *NamedEntityConfig, namedEntity *admin.NamedEntity) { + namedEntity.Metadata.State = admin.NamedEntityState_NAMED_ENTITY_ARCHIVED + config.Activate = true + config.Force = true + }, + /* assert */ func(s *testutils.TestStruct, err error) { + assert.Error(t, err) + s.MockAdminClient.AssertCalled(t, "UpdateNamedEntity", mock.Anything, mock.Anything) + }, + ) +} + +func TestWorkflowMetadataUpdateRequiresWorkflowName(t *testing.T) { s := testutils.Setup() - assert.NotNil(t, getUpdateWorkflowFunc(&NamedEntityConfig{})(s.Ctx, []string{}, s.CmdCtx)) + config := &NamedEntityConfig{} + + err := getUpdateWorkflowFunc(config)(s.Ctx, nil, s.CmdCtx) + + assert.ErrorContains(t, err, "workflow name wasn't passed") } diff --git a/go.mod b/go.mod index a3587885f0..534c99baaa 100644 --- a/go.mod +++ b/go.mod @@ -14,9 +14,11 @@ require ( github.com/flyteorg/flytestdlib v1.0.13 github.com/go-ozzo/ozzo-validation/v4 v4.3.0 github.com/golang/protobuf v1.5.2 + github.com/google/go-cmp v0.5.8 github.com/google/go-github/v42 v42.0.0 github.com/google/uuid v1.2.0 github.com/hashicorp/go-version v1.3.0 + github.com/hexops/gotextdiff v1.0.3 github.com/kataras/tablewriter v0.0.0-20180708051242-e063d29b7c23 github.com/landoop/tableprinter v0.0.0-20180806200924-8bd8c2576d27 github.com/mitchellh/mapstructure v1.4.3 @@ -83,7 +85,6 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.4.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/google/go-cmp v0.5.8 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/googleapis/gax-go/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 95855b56ca..38be461844 100644 --- a/go.sum +++ b/go.sum @@ -732,6 +732,8 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= diff --git a/pkg/ext/attribute_match_fetcher.go b/pkg/ext/attribute_match_fetcher.go index 3e33609b3b..5106a170a1 100644 --- a/pkg/ext/attribute_match_fetcher.go +++ b/pkg/ext/attribute_match_fetcher.go @@ -2,57 +2,65 @@ package ext import ( "context" - "fmt" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/flyteorg/flyteidl/gen/pb-go/flyteidl/admin" ) func (a *AdminFetcherExtClient) FetchWorkflowAttributes(ctx context.Context, project, domain, name string, rsType admin.MatchableResource) (*admin.WorkflowAttributesGetResponse, error) { - workflowAttr, err := a.AdminServiceClient().GetWorkflowAttributes(ctx, &admin.WorkflowAttributesGetRequest{ + response, err := a.AdminServiceClient().GetWorkflowAttributes(ctx, &admin.WorkflowAttributesGetRequest{ Project: project, Domain: domain, Workflow: name, ResourceType: rsType, }) - if err != nil { + if err != nil && status.Code(err) != codes.NotFound { return nil, err } - if workflowAttr.GetAttributes() == nil || workflowAttr.GetAttributes().GetMatchingAttributes() == nil { - return nil, fmt.Errorf("attribute doesn't exist") + if status.Code(err) == codes.NotFound || + response.GetAttributes() == nil || + response.GetAttributes().GetMatchingAttributes() == nil { + return nil, NewNotFoundError("attribute") } - return workflowAttr, nil + return response, nil } func (a *AdminFetcherExtClient) FetchProjectDomainAttributes(ctx context.Context, project, domain string, rsType admin.MatchableResource) (*admin.ProjectDomainAttributesGetResponse, error) { - projectDomainAttr, err := a.AdminServiceClient().GetProjectDomainAttributes(ctx, + response, err := a.AdminServiceClient().GetProjectDomainAttributes(ctx, &admin.ProjectDomainAttributesGetRequest{ Project: project, Domain: domain, ResourceType: rsType, }) - if err != nil { + if err != nil && status.Code(err) != codes.NotFound { return nil, err } - if projectDomainAttr.GetAttributes() == nil || projectDomainAttr.GetAttributes().GetMatchingAttributes() == nil { - return nil, fmt.Errorf("attribute doesn't exist") + if status.Code(err) == codes.NotFound || + response.GetAttributes() == nil || + response.GetAttributes().GetMatchingAttributes() == nil { + return nil, NewNotFoundError("attribute") } - return projectDomainAttr, nil + return response, nil } func (a *AdminFetcherExtClient) FetchProjectAttributes(ctx context.Context, project string, rsType admin.MatchableResource) (*admin.ProjectAttributesGetResponse, error) { - projectDomainAttr, err := a.AdminServiceClient().GetProjectAttributes(ctx, + response, err := a.AdminServiceClient().GetProjectAttributes(ctx, &admin.ProjectAttributesGetRequest{ Project: project, ResourceType: rsType, }) - if err != nil { + if err != nil && status.Code(err) != codes.NotFound { return nil, err } - if projectDomainAttr.GetAttributes() == nil || projectDomainAttr.GetAttributes().GetMatchingAttributes() == nil { - return nil, fmt.Errorf("attribute doesn't exist") + if status.Code(err) == codes.NotFound || + response.GetAttributes() == nil || + response.GetAttributes().GetMatchingAttributes() == nil { + return nil, NewNotFoundError("attribute") } - return projectDomainAttr, nil + return response, nil } diff --git a/pkg/ext/attribute_match_fetcher_test.go b/pkg/ext/attribute_match_fetcher_test.go index 81352d6bc2..663e6e620b 100644 --- a/pkg/ext/attribute_match_fetcher_test.go +++ b/pkg/ext/attribute_match_fetcher_test.go @@ -52,7 +52,8 @@ func TestFetchWorkflowAttributesError(t *testing.T) { adminClient.OnGetWorkflowAttributesMatch(mock.Anything, mock.Anything).Return(wResp, nil) _, err := adminFetcherExt.FetchWorkflowAttributes(ctx, "dummyProject", "domainValue", "workflowName", admin.MatchableResource_TASK_RESOURCE) assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("attribute doesn't exist"), err) + assert.True(t, IsNotFoundError(err)) + assert.EqualError(t, err, "attribute not found") }) } @@ -76,7 +77,8 @@ func TestFetchProjectDomainAttributesError(t *testing.T) { adminClient.OnGetProjectDomainAttributesMatch(mock.Anything, mock.Anything).Return(pResp, nil) _, err := adminFetcherExt.FetchProjectDomainAttributes(ctx, "dummyProject", "domainValue", admin.MatchableResource_TASK_RESOURCE) assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("attribute doesn't exist"), err) + assert.True(t, IsNotFoundError(err)) + assert.EqualError(t, err, "attribute not found") }) } @@ -93,6 +95,7 @@ func TestFetchProjectAttributesError(t *testing.T) { adminClient.OnGetProjectAttributesMatch(mock.Anything, mock.Anything).Return(pResp, nil) _, err := adminFetcherExt.FetchProjectAttributes(ctx, "dummyProject", admin.MatchableResource_TASK_RESOURCE) assert.NotNil(t, err) - assert.Equal(t, fmt.Errorf("attribute doesn't exist"), err) + assert.True(t, IsNotFoundError(err)) + assert.EqualError(t, err, "attribute not found") }) } diff --git a/pkg/ext/errors.go b/pkg/ext/errors.go new file mode 100644 index 0000000000..4f51601a28 --- /dev/null +++ b/pkg/ext/errors.go @@ -0,0 +1,24 @@ +package ext + +import ( + "errors" + "fmt" +) + +type NotFoundError struct { + Target string +} + +func (err *NotFoundError) Error() string { + return fmt.Sprintf("%s not found", err.Target) +} + +func NewNotFoundError(targetFormat string, formatArgs ...any) *NotFoundError { + target := fmt.Sprintf(targetFormat, formatArgs...) + return &NotFoundError{target} +} + +func IsNotFoundError(err error) bool { + var notFoundErr *NotFoundError + return errors.As(err, ¬FoundErr) +} diff --git a/pkg/ext/fetcher.go b/pkg/ext/fetcher.go index b706bf8a53..790eebde0e 100644 --- a/pkg/ext/fetcher.go +++ b/pkg/ext/fetcher.go @@ -72,6 +72,9 @@ type AdminFetcherExtInterface interface { // ListProjects fetches all projects ListProjects(ctx context.Context, filter filters.Filters) (*admin.Projects, error) + + // GetProjectByID fetches a single project by its identifier. If project does not exist, an error will be returned + GetProjectByID(ctx context.Context, projectID string) (*admin.Project, error) } // AdminFetcherExtClient is used for interacting with extended features used for fetching data from admin service diff --git a/pkg/ext/mocks/admin_fetcher_ext_interface.go b/pkg/ext/mocks/admin_fetcher_ext_interface.go index 3162a6b278..7d8e1ee284 100644 --- a/pkg/ext/mocks/admin_fetcher_ext_interface.go +++ b/pkg/ext/mocks/admin_fetcher_ext_interface.go @@ -750,6 +750,47 @@ func (_m *AdminFetcherExtInterface) FetchWorkflowVersion(ctx context.Context, na return r0, r1 } +type AdminFetcherExtInterface_GetProjectByID struct { + *mock.Call +} + +func (_m AdminFetcherExtInterface_GetProjectByID) Return(_a0 *admin.Project, _a1 error) *AdminFetcherExtInterface_GetProjectByID { + return &AdminFetcherExtInterface_GetProjectByID{Call: _m.Call.Return(_a0, _a1)} +} + +func (_m *AdminFetcherExtInterface) OnGetProjectByID(ctx context.Context, projectID string) *AdminFetcherExtInterface_GetProjectByID { + c_call := _m.On("GetProjectByID", ctx, projectID) + return &AdminFetcherExtInterface_GetProjectByID{Call: c_call} +} + +func (_m *AdminFetcherExtInterface) OnGetProjectByIDMatch(matchers ...interface{}) *AdminFetcherExtInterface_GetProjectByID { + c_call := _m.On("GetProjectByID", matchers...) + return &AdminFetcherExtInterface_GetProjectByID{Call: c_call} +} + +// GetProjectByID provides a mock function with given fields: ctx, projectID +func (_m *AdminFetcherExtInterface) GetProjectByID(ctx context.Context, projectID string) (*admin.Project, error) { + ret := _m.Called(ctx, projectID) + + var r0 *admin.Project + if rf, ok := ret.Get(0).(func(context.Context, string) *admin.Project); ok { + r0 = rf(ctx, projectID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*admin.Project) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, projectID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + type AdminFetcherExtInterface_ListExecution struct { *mock.Call } diff --git a/pkg/ext/project_fetcher.go b/pkg/ext/project_fetcher.go index 4367bef9a6..3834952877 100644 --- a/pkg/ext/project_fetcher.go +++ b/pkg/ext/project_fetcher.go @@ -2,6 +2,7 @@ package ext import ( "context" + "fmt" "github.com/flyteorg/flytectl/pkg/filters" @@ -19,3 +20,27 @@ func (a *AdminFetcherExtClient) ListProjects(ctx context.Context, filter filters } return e, nil } + +func (a *AdminFetcherExtClient) GetProjectByID(ctx context.Context, projectID string) (*admin.Project, error) { + if projectID == "" { + return nil, fmt.Errorf("GetProjectByID: projectId is empty") + } + + response, err := a.AdminServiceClient().ListProjects(ctx, &admin.ProjectListRequest{ + Limit: 1, + Filters: fmt.Sprintf("eq(identifier,%s)", filters.EscapeValue(projectID)), + }) + if err != nil { + return nil, err + } + + if len(response.Projects) == 0 { + return nil, NewNotFoundError("project %s", projectID) + } + + if len(response.Projects) > 1 { + panic(fmt.Sprintf("unexpected number of projects in ListProjects response: %d - 0 or 1 expected", len(response.Projects))) + } + + return response.Projects[0], nil +} diff --git a/pkg/filters/filters.go b/pkg/filters/filters.go index 629b6a25c8..836dc50eba 100644 --- a/pkg/filters/filters.go +++ b/pkg/filters/filters.go @@ -65,6 +65,16 @@ func (i InvalidEscapeSequence) Error() string { return fmt.Sprintf("invalid field selector: invalid escape sequence: %s", i.sequence) } +// EscapeValue escapes strings to be used as values in filter queries. +func EscapeValue(s string) string { + replacer := strings.NewReplacer( + `\`, `\\`, + `,`, `\,`, + `=`, `\=`, + ) + return replacer.Replace(s) +} + // UnescapeValue unescapes a fieldSelector value and returns the original literal value. // May return the original string if it contains no escaped or special characters. func UnescapeValue(s string) (string, error) { diff --git a/pkg/filters/filters_test.go b/pkg/filters/filters_test.go index cd988f0c58..43cfb52d3b 100644 --- a/pkg/filters/filters_test.go +++ b/pkg/filters/filters_test.go @@ -71,3 +71,14 @@ func TestParseFailed(t *testing.T) { assert.Equal(t, "", op) } } + +func TestEscapeValue(t *testing.T) { + assert.Equal(t, "", EscapeValue("")) + assert.Equal(t, "abc", EscapeValue("abc")) + assert.Equal(t, `\\`, EscapeValue(`\`)) + assert.Equal(t, `\\\\`, EscapeValue(`\\`)) + assert.Equal(t, `\,`, EscapeValue(`,`)) + assert.Equal(t, `\,\,`, EscapeValue(`,,`)) + assert.Equal(t, `\=`, EscapeValue(`=`)) + assert.Equal(t, `\=\=`, EscapeValue(`==`)) +}