Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add enable-feature command #44

Merged
merged 3 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/kubesaw/ksctl
go 1.20

require (
github.com/codeready-toolchain/api v0.0.0-20240530120602-c11598ccffb7
github.com/codeready-toolchain/api v0.0.0-20240627084210-f4a765461e75
github.com/codeready-toolchain/toolchain-common v0.0.0-20240530121312-98aad712838f
github.com/ghodss/yaml v1.0.0
github.com/mitchellh/go-homedir v1.1.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:z
github.com/cockroachdb/datadriven v0.0.0-20200714090401-bf6692d28da5/go.mod h1:h6jFvWxBdQXxjopDMZyH2UVceIRfR84bdzbkoKrsWNo=
github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA=
github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI=
github.com/codeready-toolchain/api v0.0.0-20240530120602-c11598ccffb7 h1:o5JLcHCVS1BlZevw2mh1mH+iKwn9fLUrT1Ek8NFjvPY=
github.com/codeready-toolchain/api v0.0.0-20240530120602-c11598ccffb7/go.mod h1:ie9p4LenCCS0LsnbWp6/xwpFDdCWYE0KWzUO6Sk1g0E=
github.com/codeready-toolchain/api v0.0.0-20240627084210-f4a765461e75 h1:LOrOMTw3bplM+VwzSxi3j9GNseujhcebO/LGr8b95GE=
github.com/codeready-toolchain/api v0.0.0-20240627084210-f4a765461e75/go.mod h1:ie9p4LenCCS0LsnbWp6/xwpFDdCWYE0KWzUO6Sk1g0E=
github.com/codeready-toolchain/toolchain-common v0.0.0-20240530121312-98aad712838f h1:2qfRfyh7wfEnnfxrUtQeQrvhzWlkBCN0B/UXv1YUMiA=
github.com/codeready-toolchain/toolchain-common v0.0.0-20240530121312-98aad712838f/go.mod h1:cyHrUfvBYEtsf+FbqQYmR9y0AQi9QAVtM3SUWLA5bd4=
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
Expand Down
103 changes: 103 additions & 0 deletions pkg/cmd/enable-feature.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package cmd

import (
"fmt"
"strings"

toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1"
"github.com/kubesaw/ksctl/pkg/client"
"github.com/kubesaw/ksctl/pkg/configuration"
clicontext "github.com/kubesaw/ksctl/pkg/context"
"github.com/kubesaw/ksctl/pkg/ioutils"
"k8s.io/apimachinery/pkg/types"
"k8s.io/utils/strings/slices"

"github.com/spf13/cobra"
)

func NewEnableFeatureCmd() *cobra.Command {
return &cobra.Command{
Use: "enable-feature <space-name> <feature-name>",
Short: "Enable a feature for the given Space",
Long: `Enable a feature toggle for the given Space. There are two expected
parameters - the first one is the Space name and the second is the name of the feature toggle that should be enabled for the Space.`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
term := ioutils.NewTerminal(cmd.InOrStdin, cmd.OutOrStdout)
ctx := clicontext.NewCommandContext(term, client.DefaultNewClient)
return EnableFeature(ctx, args[0], args[1])
},

Check warning on line 29 in pkg/cmd/enable-feature.go

View check run for this annotation

Codecov / codecov/patch

pkg/cmd/enable-feature.go#L26-L29

Added lines #L26 - L29 were not covered by tests
}
}

func EnableFeature(ctx *clicontext.CommandContext, spaceName, featureToggleName string) error {
return client.PatchSpace(ctx, spaceName, func(space *toolchainv1alpha1.Space) (bool, error) {
cfg, err := configuration.LoadClusterConfig(ctx, configuration.HostName)
if err != nil {
return false, err

Check warning on line 37 in pkg/cmd/enable-feature.go

View check run for this annotation

Codecov / codecov/patch

pkg/cmd/enable-feature.go#L37

Added line #L37 was not covered by tests
}
cl, err := ctx.NewClient(cfg.Token, cfg.ServerAPI)
if err != nil {
return false, err

Check warning on line 41 in pkg/cmd/enable-feature.go

View check run for this annotation

Codecov / codecov/patch

pkg/cmd/enable-feature.go#L41

Added line #L41 was not covered by tests
}

// get ToolchainConfig to check if the feature toggle is supported or not
config := &toolchainv1alpha1.ToolchainConfig{}
namespacedName := types.NamespacedName{Namespace: cfg.OperatorNamespace, Name: "config"}
if err := cl.Get(ctx, namespacedName, config); err != nil {
return false, fmt.Errorf("unable to get ToolchainConfig: %w", err)
}
// if no feature toggle is supported then return an error
if len(config.Spec.Host.Tiers.FeatureToggles) == 0 {
return false, fmt.Errorf("the feature toggle is not supported - the list of supported toggles is empty")
}

supportedFeatureToggles := make([]string, len(config.Spec.Host.Tiers.FeatureToggles))
for i, fToggle := range config.Spec.Host.Tiers.FeatureToggles {
supportedFeatureToggles[i] = fToggle.Name
}

// if the requested feature is not in the list of supported toggles, then print the list of supported ones and return an error
if !slices.Contains(supportedFeatureToggles, featureToggleName) {
ctx.Printlnf("The feature toggle '%s' is not listed as a supported feature toggle in ToolchainConfig CR.", featureToggleName)
fToggleNamesList := "\n"
for _, fToggleName := range supportedFeatureToggles {
fToggleNamesList += fmt.Sprintf("%s\n", fToggleName)
}
ctx.PrintContextSeparatorWithBodyf(fToggleNamesList, "The supported feature toggles are:")
return false, fmt.Errorf("the feature toggle is not supported")
}

// get already enabled features for the space
currentFeatures := strings.TrimSpace(space.Annotations[toolchainv1alpha1.FeatureToggleNameAnnotationKey])
var enabledFeatures []string
if currentFeatures != "" {
enabledFeatures = strings.Split(currentFeatures, ",")
}
mfrancisc marked this conversation as resolved.
Show resolved Hide resolved
if err := ctx.PrintObject(space, "The current Space"); err != nil {
return false, err

Check warning on line 78 in pkg/cmd/enable-feature.go

View check run for this annotation

Codecov / codecov/patch

pkg/cmd/enable-feature.go#L78

Added line #L78 was not covered by tests
}

// check if it's already enabled or not
if slices.Contains(enabledFeatures, featureToggleName) {
ctx.Println("")
ctx.Println("The space has the feature toggle already enabled. There is nothing to do.")
ctx.Println("")
return false, nil
}

confirmation := ctx.AskForConfirmation(ioutils.WithMessagef(
"enable the feature toggle '%s' for the Space '%s'? The already enabled feature toggles are '%s'.",
featureToggleName, spaceName, currentFeatures))

if confirmation {
enabledFeatures = append(enabledFeatures, featureToggleName)
if space.Annotations == nil {
space.Annotations = map[string]string{}
}
space.Annotations[toolchainv1alpha1.FeatureToggleNameAnnotationKey] = strings.Join(enabledFeatures, ",")
return true, nil
}
return false, nil
}, "Successfully enabled feature toggle for the Space")
}
262 changes: 262 additions & 0 deletions pkg/cmd/enable_feature_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
package cmd_test

import (
"context"
"fmt"
"testing"

toolchainv1alpha1 "github.com/codeready-toolchain/api/api/v1alpha1"
"github.com/codeready-toolchain/toolchain-common/pkg/test"
"github.com/kubesaw/ksctl/pkg/cmd"
clicontext "github.com/kubesaw/ksctl/pkg/context"
. "github.com/kubesaw/ksctl/pkg/test"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestEnableFeatureCmd(t *testing.T) {
// given
SetFileConfig(t, Host())
config := configWithFeatures([]toolchainv1alpha1.FeatureToggle{
{
Name: "feature-x",
},
})

var combinations = []struct {
alreadyEnabled string
afterEnable map[string]string
}{
{
alreadyEnabled: "",
afterEnable: map[string]string{
toolchainv1alpha1.FeatureToggleNameAnnotationKey: "feature-x",
},
},
{
alreadyEnabled: "feature0",
afterEnable: map[string]string{
toolchainv1alpha1.FeatureToggleNameAnnotationKey: "feature0,feature-x",
},
},
{
alreadyEnabled: "feature1,feature2,feature3",
afterEnable: map[string]string{
toolchainv1alpha1.FeatureToggleNameAnnotationKey: "feature1,feature2,feature3,feature-x",
},
},
}

for _, data := range combinations {
t.Run("with the already enabled features: "+data.alreadyEnabled, func(t *testing.T) {
// given
space := newSpace()
if data.alreadyEnabled != "" {
space.Annotations = map[string]string{
toolchainv1alpha1.FeatureToggleNameAnnotationKey: data.alreadyEnabled,
}
}
Comment on lines +56 to +60
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can always update the annotations, the result shouldn't change

Suggested change
if data.alreadyEnabled != "" {
space.Annotations = map[string]string{
toolchainv1alpha1.FeatureToggleNameAnnotationKey: data.alreadyEnabled,
}
}
space.Annotations = map[string]string{
toolchainv1alpha1.FeatureToggleNameAnnotationKey: data.alreadyEnabled,
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would keep it as is. Just to make sure we do not touch the annotation map at all if the initial feature list is not set. There is still a difference between an empty map and nil.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, as Alexey mentioned, it's not the same. We need to make sure that the logic will work when annotations are no initialized (the map is nil). If we don't handle such a case properly, then we could end up with panic trying to modify a map that is nil.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, but applying my suggestion the tests are still passing, so I'm wondering if we are missing some specific case that sets the annotation to nil to cover this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the other way around. Your version puts lower requirements on the code than the current test.
Imagine that we don't have these 3 lines of the code

if space.Annotations == nil {
space.Annotations = map[string]string{}
}

Your version of the tests will pass, but the current version will fail

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I see now, as you explained in our 1:1 this check makes so that we test the nil scenario by not setting any annotation in the setup part of the test and leaving it as nil in the first iterartion. Thanks for explaining and sorry fo the confusion.


for _, answer := range []string{"Y", "n"} {

t.Run("when answer is "+answer, func(t *testing.T) {
// given
newClient, fakeClient := NewFakeClients(t, space, config)
term := NewFakeTerminalWithResponse(answer)
ctx := clicontext.NewCommandContext(term, newClient)

// when
err := cmd.EnableFeature(ctx, space.Name, "feature-x")

// then
require.NoError(t, err)

output := term.Output()
assert.Contains(t, output, fmt.Sprintf("enable the feature toggle 'feature-x' for the Space 'testspace'? The already enabled feature toggles are '%s'.", data.alreadyEnabled))
assert.NotContains(t, output, "cool-token")
expectedSpace := newSpace()

if answer == "Y" {
expectedSpace.Annotations = data.afterEnable
assert.Contains(t, output, "Successfully enabled feature toggle for the Space")

} else {
expectedSpace.Annotations = space.Annotations
assert.NotContains(t, output, "Successfully enabled feature toggle for the Space")
}
assertSpaceAnnotations(t, fakeClient, expectedSpace)

})
}
})
}
}

func TestEnableFeatureCmdWhenFeatureIsAlreadyEnabled(t *testing.T) {
// given
SetFileConfig(t, Host())
config := configWithFeatures([]toolchainv1alpha1.FeatureToggle{
{
Name: "feature-x",
},
})

for _, alreadyEnabled := range []string{"feature-x", "feature-x,feature0", "feature1,feature2,feature-x,feature3"} {
t.Run("with the already enabled features: "+alreadyEnabled, func(t *testing.T) {
// given
space := newSpace()
if alreadyEnabled != "" {
space.Annotations = map[string]string{
toolchainv1alpha1.FeatureToggleNameAnnotationKey: alreadyEnabled,
}
}
Comment on lines +110 to +114
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, we could always update the annotations I believe

Suggested change
if alreadyEnabled != "" {
space.Annotations = map[string]string{
toolchainv1alpha1.FeatureToggleNameAnnotationKey: alreadyEnabled,
}
}
space.Annotations = map[string]string{
toolchainv1alpha1.FeatureToggleNameAnnotationKey: alreadyEnabled,
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same - we need to verify the case when there is no annotation provided at all (the map is nil).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, when I try this change the tests are still passing

// given
newClient, fakeClient := NewFakeClients(t, space, config)
term := NewFakeTerminalWithResponse("Y")
ctx := clicontext.NewCommandContext(term, newClient)

// when
err := cmd.EnableFeature(ctx, space.Name, "feature-x")

// then
require.NoError(t, err)
assertSpaceAnnotations(t, fakeClient, space) // no change

output := term.Output()
assert.Contains(t, output, "The space has the feature toggle already enabled. There is nothing to do.")
assert.NotContains(t, output, "enable the feature toggle 'feature-x' for the Space 'testspace'?")
assert.NotContains(t, output, "Successfully enabled feature toggle for the Space")
assert.NotContains(t, output, "cool-token")

})
}
}

func TestEnableFeatureCmdWhenFeatureIsNotSupported(t *testing.T) {
// given
SetFileConfig(t, Host())

var combinations = []struct {
nameList string
supportedFeatures []toolchainv1alpha1.FeatureToggle
}{
{
nameList: "",
supportedFeatures: nil,
},
{
nameList: "feature-0",
supportedFeatures: []toolchainv1alpha1.FeatureToggle{
{
Name: "feature-0",
},
},
},
{
nameList: "feature1\nfeature2\nfeature3",
supportedFeatures: []toolchainv1alpha1.FeatureToggle{
{
Name: "feature1",
},
{
Name: "feature2",
},
{
Name: "feature3",
},
},
},
}

for _, data := range combinations {
t.Run("with the supported features: "+data.nameList, func(t *testing.T) {
// given
space := newSpace()
config := configWithFeatures(data.supportedFeatures)
// given
newClient, fakeClient := NewFakeClients(t, space, config)
term := NewFakeTerminalWithResponse("Y")
ctx := clicontext.NewCommandContext(term, newClient)

// when
err := cmd.EnableFeature(ctx, space.Name, "feature-x")

// then
require.Error(t, err)
output := term.Output()
if data.supportedFeatures == nil {
require.EqualError(t, err, "the feature toggle is not supported - the list of supported toggles is empty")
} else {
require.EqualError(t, err, "the feature toggle is not supported")
assert.Contains(t, output, "The feature toggle 'feature-x' is not listed as a supported feature toggle in ToolchainConfig CR.")
assert.Contains(t, output, "The supported feature toggles are:")
assert.Contains(t, output, data.nameList)
}

assert.NotContains(t, output, "Successfully enabled feature toggle for the Space")
assert.NotContains(t, output, "cool-token")
assertSpaceAnnotations(t, fakeClient, space) // no change
})
}
}

func TestEnableFeatureCmdWhenSpaceNotFound(t *testing.T) {
// given
config := configWithFeatures([]toolchainv1alpha1.FeatureToggle{
{
Name: "feature-x",
},
})
space := newSpace()
newClient, fakeClient := NewFakeClients(t, space, config)
SetFileConfig(t, Host())
term := NewFakeTerminalWithResponse("Y")
ctx := clicontext.NewCommandContext(term, newClient)

// when
err := cmd.EnableFeature(ctx, "another", "feature-x")

// then
require.EqualError(t, err, "spaces.toolchain.dev.openshift.com \"another\" not found")
assertSpaceAnnotations(t, fakeClient, space) // unrelated space should be unchanged
output := term.Output()
assert.NotContains(t, output, "enable the feature toggle 'feature-x' for the Space 'testspace'?")
assert.NotContains(t, output, "Successfully enabled feature toggle for the Space")
assert.NotContains(t, output, "cool-token")
}

func TestEnableFeatureCmdWhenConfigNotFound(t *testing.T) {
// given
space := newSpace()
newClient, fakeClient := NewFakeClients(t, space)
SetFileConfig(t, Host())
term := NewFakeTerminalWithResponse("Y")
ctx := clicontext.NewCommandContext(term, newClient)

// when
err := cmd.EnableFeature(ctx, space.Name, "feature-x")

// then
require.EqualError(t, err, "unable to get ToolchainConfig: toolchainconfigs.toolchain.dev.openshift.com \"config\" not found")
assertSpaceAnnotations(t, fakeClient, space) // no change
}

func assertSpaceAnnotations(t *testing.T, fakeClient *test.FakeClient, expectedSpace *toolchainv1alpha1.Space) {
updatedSpace := &toolchainv1alpha1.Space{}
err := fakeClient.Get(context.TODO(), test.NamespacedName(expectedSpace.Namespace, expectedSpace.Name), updatedSpace)
require.NoError(t, err)
assert.Equal(t, expectedSpace.Annotations, updatedSpace.Annotations)
}

func configWithFeatures(toggles []toolchainv1alpha1.FeatureToggle) *toolchainv1alpha1.ToolchainConfig {
toolchainConfig := &toolchainv1alpha1.ToolchainConfig{
ObjectMeta: metav1.ObjectMeta{
Namespace: test.HostOperatorNs,
Name: "config",
},
}
toolchainConfig.Spec.Host.Tiers.FeatureToggles = toggles
return toolchainConfig
}
1 change: 1 addition & 0 deletions pkg/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ func init() {
rootCmd.AddCommand(NewLogsCmd())
rootCmd.AddCommand(NewDescribeCmd())
rootCmd.AddCommand(NewDisableUserCmd())
rootCmd.AddCommand(NewEnableFeatureCmd())

// administrative commands
rootCmd.AddCommand(adm.NewAdmCmd())
Expand Down
Loading