diff --git a/.gitignore b/.gitignore index fc0f5a7..5d3ba96 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ ### vscode ### .vscode/* *.code-workspace +/.devcontainer ### MacOS ### .DS_Store diff --git a/create/create.go b/create/create.go index 3f2d45b..719f18a 100644 --- a/create/create.go +++ b/create/create.go @@ -28,6 +28,7 @@ type Cmd struct { Config configCmd `cmd:"" group:"deplo.io" name:"config" help:"Create a new deplo.io Project Configuration. (Beta - requires access)"` Application applicationCmd `cmd:"" group:"deplo.io" name:"application" aliases:"app" help:"Create a new deplo.io Application. (Beta - requires access)"` MySQL mySQLCmd `cmd:"" group:"storage.nine.ch" name:"mysql" help:"Create a new MySQL instance."` + KeyValueStore keyValueStoreCmd `cmd:"" group:"storage.nine.ch" name:"keyvaluestore" aliases:"kvs" help:"Create a new KeyValueStore instance"` } // resultFunc is the function called on a watch event during creation. It diff --git a/create/keyvaluestore.go b/create/keyvaluestore.go new file mode 100644 index 0000000..e5a33ec --- /dev/null +++ b/create/keyvaluestore.go @@ -0,0 +1,90 @@ +package create + +import ( + "context" + "fmt" + "time" + + runtimev1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + meta "github.com/ninech/apis/meta/v1alpha1" + storage "github.com/ninech/apis/storage/v1alpha1" + "github.com/ninech/nctl/api" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" +) + +type keyValueStoreCmd struct { + Name string `arg:"" default:"" help:"Name of the KeyValueStore instance. A random name is generated if omitted."` + Location string `default:"nine-es34" help:"Location where the KeyValueStore instance is created."` + MemorySize string `help:"MemorySize configures KeyValueStore to use a specified amount of memory for the data set." placeholder:"1Gi"` + MaxMemoryPolicy storage.KeyValueStoreMaxMemoryPolicy `help:"MaxMemoryPolicy specifies the exact behavior KeyValueStore follows when the maxmemory limit is reached." placeholder:"allkeys-lru"` + AllowedCidrs []storage.IPv4CIDR `help:"AllowedCIDRs specify the allowed IP addresses, connecting to the instance." placeholder:"0.0.0.0/0"` + Wait bool `default:"true" help:"Wait until KeyValueStore is created."` + WaitTimeout time.Duration `default:"600s" help:"Duration to wait for KeyValueStore getting ready. Only relevant if --wait is set."` +} + +func (cmd *keyValueStoreCmd) Run(ctx context.Context, client *api.Client) error { + keyValueStore, err := cmd.newKeyValueStore(client.Project) + if err != nil { + return err + } + + c := newCreator(client, keyValueStore, "keyvaluestore") + ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) + defer cancel() + + if err := c.createResource(ctx); err != nil { + return err + } + + if !cmd.Wait { + return nil + } + + return c.wait(ctx, waitStage{ + objectList: &storage.KeyValueStoreList{}, + onResult: func(event watch.Event) (bool, error) { + if c, ok := event.Object.(*storage.KeyValueStore); ok { + return isAvailable(c), nil + } + return false, nil + }, + }, + ) +} + +func (cmd *keyValueStoreCmd) newKeyValueStore(namespace string) (*storage.KeyValueStore, error) { + name := getName(cmd.Name) + + keyValueStore := &storage.KeyValueStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: storage.KeyValueStoreSpec{ + ResourceSpec: runtimev1.ResourceSpec{ + WriteConnectionSecretToReference: &runtimev1.SecretReference{ + Name: "keyvaluestore-" + name, + Namespace: namespace, + }, + }, + ForProvider: storage.KeyValueStoreParameters{ + Location: meta.LocationName(cmd.Location), + MaxMemoryPolicy: cmd.MaxMemoryPolicy, + AllowedCIDRs: cmd.AllowedCidrs, + }, + }, + } + + if cmd.MemorySize != "" { + q, err := resource.ParseQuantity(cmd.MemorySize) + if err != nil { + return keyValueStore, fmt.Errorf("error parsing memory size %q: %w", cmd.MemorySize, err) + } + + keyValueStore.Spec.ForProvider.MemorySize = &storage.KeyValueStoreMemorySize{Quantity: q} + } + + return keyValueStore, nil +} diff --git a/create/keyvaluestore_test.go b/create/keyvaluestore_test.go new file mode 100644 index 0000000..cb28c6e --- /dev/null +++ b/create/keyvaluestore_test.go @@ -0,0 +1,80 @@ +package create + +import ( + "context" + "reflect" + "testing" + "time" + + storage "github.com/ninech/apis/storage/v1alpha1" + "github.com/ninech/nctl/api" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestKeyValueStore(t *testing.T) { + tests := []struct { + name string + create keyValueStoreCmd + want storage.KeyValueStoreParameters + wantErr bool + }{ + {"simple", keyValueStoreCmd{}, storage.KeyValueStoreParameters{}, false}, + { + "memorySize", + keyValueStoreCmd{MemorySize: "1G"}, + storage.KeyValueStoreParameters{MemorySize: &storage.KeyValueStoreMemorySize{Quantity: resource.MustParse("1G")}}, + false, + }, + { + "maxMemoryPolicy", + keyValueStoreCmd{MaxMemoryPolicy: storage.KeyValueStoreMaxMemoryPolicy("noeviction")}, + storage.KeyValueStoreParameters{MaxMemoryPolicy: storage.KeyValueStoreMaxMemoryPolicy("noeviction")}, + false, + }, + { + "allowedCIDRs", + keyValueStoreCmd{AllowedCidrs: []storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}}, + storage.KeyValueStoreParameters{AllowedCIDRs: []storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}}, + false, + }, + { + "invalid", + keyValueStoreCmd{MemorySize: "invalid"}, + storage.KeyValueStoreParameters{}, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.create.Name = "test-" + t.Name() + tt.create.Wait = false + tt.create.WaitTimeout = time.Second + + scheme, err := api.NewScheme() + if err != nil { + t.Fatal(err) + } + client := fake.NewClientBuilder().WithScheme(scheme).Build() + apiClient := &api.Client{WithWatch: client, Project: "default"} + ctx := context.Background() + + if err := tt.create.Run(ctx, apiClient); (err != nil) != tt.wantErr { + t.Errorf("keyValueStoreCmd.Run() error = %v, wantErr %v", err, tt.wantErr) + } + + created := &storage.KeyValueStore{ObjectMeta: metav1.ObjectMeta{Name: tt.create.Name, Namespace: apiClient.Project}} + if err := apiClient.Get(ctx, api.ObjectName(created), created); (err != nil) != tt.wantErr { + t.Fatalf("expected keyvaluestore to exist, got: %s", err) + } + if tt.wantErr { + return + } + + if !reflect.DeepEqual(created.Spec.ForProvider, tt.want) { + t.Fatalf("expected KeyValueStore.Spec.ForProvider = %v, got: %v", created.Spec.ForProvider, tt.want) + } + }) + } +} diff --git a/delete/delete.go b/delete/delete.go index 7554396..9b71412 100644 --- a/delete/delete.go +++ b/delete/delete.go @@ -20,6 +20,7 @@ type Cmd struct { Config configCmd `cmd:"" group:"deplo.io" name:"config" help:"Delete a deplo.io Project Configuration. (Beta - requires access)"` Application applicationCmd `cmd:"" group:"deplo.io" name:"application" aliases:"app" help:"Delete a deplo.io Application. (Beta - requires access)"` MySQL mySQLCmd `cmd:"" group:"storage.nine.ch" name:"mysql" help:"Delete a MySQL instance."` + KeyValueStore keyValueStoreCmd `cmd:"" group:"storage.nine.ch" name:"keyvaluestore" aliases:"kvs" help:"Delete a KeyValueStore instance."` } // cleanupFunc is called after the resource has been deleted in order to do diff --git a/delete/keyvaluestore.go b/delete/keyvaluestore.go new file mode 100644 index 0000000..1d9a578 --- /dev/null +++ b/delete/keyvaluestore.go @@ -0,0 +1,32 @@ +package delete + +import ( + "context" + "fmt" + "time" + + "k8s.io/apimachinery/pkg/types" + + storage "github.com/ninech/apis/storage/v1alpha1" + "github.com/ninech/nctl/api" +) + +type keyValueStoreCmd struct { + Name string `arg:"" help:"Name of the KeyValueStore resource."` + Force bool `default:"false" help:"Do not ask for confirmation of deletion."` + Wait bool `default:"true" help:"Wait until KeyValueStore is fully deleted."` + WaitTimeout time.Duration `default:"300s" help:"Duration to wait for the deletion. Only relevant if wait is set."` +} + +func (cmd *keyValueStoreCmd) Run(ctx context.Context, client *api.Client) error { + ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) + defer cancel() + + keyValueStore := &storage.KeyValueStore{} + keyValueStoreName := types.NamespacedName{Name: cmd.Name, Namespace: client.Project} + if err := client.Get(ctx, keyValueStoreName, keyValueStore); err != nil { + return fmt.Errorf("unable to get keyvaluestore %q: %w", keyValueStore.Name, err) + } + + return newDeleter(keyValueStore, storage.KeyValueStoreKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) +} diff --git a/delete/keyvaluestore_test.go b/delete/keyvaluestore_test.go new file mode 100644 index 0000000..070bf5d --- /dev/null +++ b/delete/keyvaluestore_test.go @@ -0,0 +1,48 @@ +package delete + +import ( + "context" + "testing" + "time" + + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/test" + "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestKeyValueStore(t *testing.T) { + cmd := keyValueStoreCmd{ + Name: "test", + Force: true, + Wait: false, + WaitTimeout: time.Second, + } + + keyValueStore := test.KeyValueStore("test", "default", "nine-es34") + + scheme, err := api.NewScheme() + if err != nil { + t.Fatal(err) + } + client := fake.NewClientBuilder().WithScheme(scheme).Build() + apiClient := &api.Client{WithWatch: client, Project: "default"} + ctx := context.Background() + + if err := apiClient.Create(ctx, keyValueStore); err != nil { + t.Fatalf("keyvaluestore create error, got: %s", err) + } + if err := apiClient.Get(ctx, api.ObjectName(keyValueStore), keyValueStore); err != nil { + t.Fatalf("expected keyvaluestore to exist, got: %s", err) + } + if err := cmd.Run(ctx, apiClient); err != nil { + t.Fatal(err) + } + err = apiClient.Get(ctx, api.ObjectName(keyValueStore), keyValueStore) + if err == nil { + t.Fatalf("expected keyvaluestore to be deleted, but exists") + } + if !errors.IsNotFound(err) { + t.Fatalf("expected keyvaluestore to be deleted, got: %s", err.Error()) + } +} diff --git a/get/apiserviceaccount.go b/get/apiserviceaccount.go index 3c630d4..fef8858 100644 --- a/get/apiserviceaccount.go +++ b/get/apiserviceaccount.go @@ -75,18 +75,12 @@ func (asa *apiServiceAccountsCmd) print(sas []iam.APIServiceAccount, get *Cmd, h } func (asa *apiServiceAccountsCmd) printToken(ctx context.Context, client *api.Client, sa *iam.APIServiceAccount) error { - secret, err := client.GetConnectionSecret(ctx, sa) + token, err := getConnectionSecret(ctx, client, tokenKey, sa) if err != nil { - return fmt.Errorf("unable to get connection secret: %w", err) - } - - token, ok := secret.Data[tokenKey] - if !ok { - return fmt.Errorf("secret of API Service Account %s has no token", sa.Name) + return err } fmt.Printf("%s\n", token) - return nil } diff --git a/get/get.go b/get/get.go index acb4f7a..578d13b 100644 --- a/get/get.go +++ b/get/get.go @@ -25,6 +25,7 @@ type Cmd struct { Releases releasesCmd `cmd:"" group:"deplo.io" name:"releases" aliases:"release" help:"Get deplo.io Releases. (Beta - requires access)"` Configs configsCmd `cmd:"" group:"deplo.io" name:"configs" aliases:"config" help:"Get deplo.io Project Configuration. (Beta - requires access)"` MySQL mySQLCmd `cmd:"" group:"storage.nine.ch" name:"mysql" help:"Get MySQL instances."` + KeyValueStore keyValueStoreCmd `cmd:"" group:"storage.nine.ch" name:"keyvaluestore" aliases:"kvs" help:"Get KeyValueStore instances."` All allCmd `cmd:"" name:"all" help:"Get project content"` opts []runtimeclient.ListOption @@ -49,6 +50,7 @@ func matchName(name string) listOpt { cmd.opts = append(cmd.opts, runtimeclient.MatchingFields{"metadata.name": name}) } } + func matchLabel(k, v string) listOpt { return func(cmd *Cmd) { cmd.opts = append(cmd.opts, runtimeclient.MatchingLabels{k: v}) diff --git a/get/keyvaluestore.go b/get/keyvaluestore.go new file mode 100644 index 0000000..58ae04b --- /dev/null +++ b/get/keyvaluestore.go @@ -0,0 +1,73 @@ +package get + +import ( + "context" + "fmt" + "io" + "text/tabwriter" + + storage "github.com/ninech/apis/storage/v1alpha1" + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/format" +) + +type keyValueStoreCmd struct { + Name string `arg:"" help:"Name of the KeyValueStore Instance to get. If omitted all in the project will be listed." default:""` + PrintToken bool `help:"Print the bearer token of the Account. Requires name to be set." default:"false"` + + out io.Writer +} + +func (cmd *keyValueStoreCmd) Run(ctx context.Context, client *api.Client, get *Cmd) error { + cmd.out = defaultOut(cmd.out) + + keyValueStoreList := &storage.KeyValueStoreList{} + + if err := get.list(ctx, client, keyValueStoreList, matchName(cmd.Name)); err != nil { + return err + } + + if len(keyValueStoreList.Items) == 0 { + printEmptyMessage(cmd.out, storage.KeyValueStoreKind, client.Project) + return nil + } + + if cmd.Name != "" && cmd.PrintToken { + return cmd.printPassword(ctx, client, &keyValueStoreList.Items[0]) + } + + switch get.Output { + case full: + return cmd.printKeyValueStoreInstances(keyValueStoreList.Items, get, true) + case noHeader: + return cmd.printKeyValueStoreInstances(keyValueStoreList.Items, get, false) + case yamlOut: + return format.PrettyPrintObjects(keyValueStoreList.GetItems(), format.PrintOpts{}) + } + + return nil +} + +func (cmd *keyValueStoreCmd) printKeyValueStoreInstances(list []storage.KeyValueStore, get *Cmd, header bool) error { + w := tabwriter.NewWriter(cmd.out, 0, 0, 4, ' ', 0) + + if header { + get.writeHeader(w, "NAME", "FQDN", "TLS", "MEMORY SIZE") + } + + for _, keyValueStore := range list { + get.writeTabRow(w, keyValueStore.Namespace, keyValueStore.Name, keyValueStore.Status.AtProvider.FQDN, "true", keyValueStore.Spec.ForProvider.MemorySize.String()) + } + + return w.Flush() +} + +func (cmd *keyValueStoreCmd) printPassword(ctx context.Context, client *api.Client, keyValueStore *storage.KeyValueStore) error { + pw, err := getConnectionSecret(ctx, client, storage.KeyValueStoreUser, keyValueStore) + if err != nil { + return err + } + + fmt.Fprintln(cmd.out, pw) + return nil +} diff --git a/get/keyvaluestore_test.go b/get/keyvaluestore_test.go new file mode 100644 index 0000000..88c1426 --- /dev/null +++ b/get/keyvaluestore_test.go @@ -0,0 +1,119 @@ +package get + +import ( + "bytes" + "context" + "strings" + "testing" + + storage "github.com/ninech/apis/storage/v1alpha1" + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/test" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestKeyValueStore(t *testing.T) { + tests := []struct { + name string + instances map[string]storage.KeyValueStoreParameters + get keyValueStoreCmd + out output + wantContain []string + wantErr bool + }{ + {"simple", map[string]storage.KeyValueStoreParameters{}, keyValueStoreCmd{}, full, []string{"no KeyValueStores found"}, false}, + { + "single", + map[string]storage.KeyValueStoreParameters{"test": {MemorySize: &storage.KeyValueStoreMemorySize{Quantity: resource.MustParse("1G")}}}, + keyValueStoreCmd{}, + full, + []string{"1G"}, + false, + }, + { + "multiple", + map[string]storage.KeyValueStoreParameters{ + "test1": {MemorySize: &storage.KeyValueStoreMemorySize{Quantity: resource.MustParse("1G")}}, + "test2": {MemorySize: &storage.KeyValueStoreMemorySize{Quantity: resource.MustParse("2G")}}, + "test3": {MemorySize: &storage.KeyValueStoreMemorySize{Quantity: resource.MustParse("3G")}}, + }, + keyValueStoreCmd{}, + full, + []string{"1G", "2G", "test3"}, + false, + }, + { + "name", + map[string]storage.KeyValueStoreParameters{ + "test1": {MemorySize: &storage.KeyValueStoreMemorySize{Quantity: resource.MustParse("1G")}}, + "test2": {MemorySize: &storage.KeyValueStoreMemorySize{Quantity: resource.MustParse("2G")}}, + }, + keyValueStoreCmd{Name: "test1"}, + full, + []string{"test1", "1G"}, + false, + }, + { + "password", + map[string]storage.KeyValueStoreParameters{ + "test1": {MemorySize: &storage.KeyValueStoreMemorySize{Quantity: resource.MustParse("1G")}}, + "test2": {MemorySize: &storage.KeyValueStoreMemorySize{Quantity: resource.MustParse("2G")}}, + }, + keyValueStoreCmd{Name: "test2", PrintToken: true}, + full, + []string{"test2-topsecret"}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := &bytes.Buffer{} + tt.get.out = buf + + scheme, err := api.NewScheme() + if err != nil { + t.Fatal(err) + } + + objects := []client.Object{} + for name, instance := range tt.instances { + created := test.KeyValueStore(name, "default", "nine-es34") + created.Spec.ForProvider = instance + objects = append(objects, created) + objects = append(objects, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: created.GetWriteConnectionSecretToReference().Name, + Namespace: created.GetWriteConnectionSecretToReference().Namespace, + }, + Data: map[string][]byte{"default": []byte(created.GetWriteConnectionSecretToReference().Name + "-topsecret")}, + }) + } + + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithIndex(&storage.KeyValueStore{}, "metadata.name", func(o client.Object) []string { + return []string{o.GetName()} + }). + WithObjects(objects...).Build() + apiClient := &api.Client{WithWatch: client, Project: "default"} + ctx := context.Background() + + if err := tt.get.Run(ctx, apiClient, &Cmd{Output: tt.out}); (err != nil) != tt.wantErr { + t.Errorf("keyValueStoreCmd.Run() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + + for _, substr := range tt.wantContain { + if !strings.Contains(buf.String(), substr) { + t.Errorf("keyValueStoreCmd.Run() did not contain %q, out = %q", tt.wantContain, buf.String()) + } + } + }) + } +} diff --git a/go.mod b/go.mod index 532b7d9..b15c2c2 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/mattn/go-isatty v0.0.20 github.com/moby/moby v26.0.0+incompatible github.com/moby/term v0.5.0 - github.com/ninech/apis v0.0.0-20240422124542-cf572784fd6a + github.com/ninech/apis v0.0.0-20240424152525-e2d75ea488d7 github.com/posener/complete v1.2.3 github.com/prometheus/common v0.52.2 github.com/stretchr/testify v1.9.0 diff --git a/go.sum b/go.sum index cd60aad..d728ec7 100644 --- a/go.sum +++ b/go.sum @@ -582,20 +582,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/ninech/apis v0.0.0-20240404091234-b8ea3d333f9d h1:qcJA3fdugTlYrx4RmitVOwysGKeaT4TyrcY6hq/IgDE= -github.com/ninech/apis v0.0.0-20240404091234-b8ea3d333f9d/go.mod h1:6lFCwHqvcTFZvJ6zY0rxaPIoKc0CX9sHhtH/nyo/5is= -github.com/ninech/apis v0.0.0-20240412101426-56fd756c7445 h1:hqUaUcd8qNmEn/KYVE2oXM8RxjkY2XSeciDNvK+Tuxo= -github.com/ninech/apis v0.0.0-20240412101426-56fd756c7445/go.mod h1:6lFCwHqvcTFZvJ6zY0rxaPIoKc0CX9sHhtH/nyo/5is= -github.com/ninech/apis v0.0.0-20240415094850-e5c1ffdfaebc h1:nsDzgqsA7k3umT1j6ltSImzqvvX1Carug8nMn36l3EI= -github.com/ninech/apis v0.0.0-20240415094850-e5c1ffdfaebc/go.mod h1:6lFCwHqvcTFZvJ6zY0rxaPIoKc0CX9sHhtH/nyo/5is= -github.com/ninech/apis v0.0.0-20240417150103-d7d5bf5ca446 h1:Hzqrx0AzrYKIM9DH1vPlNSSOm3NGIUg9QXGm0P9S1Bs= -github.com/ninech/apis v0.0.0-20240417150103-d7d5bf5ca446/go.mod h1:6lFCwHqvcTFZvJ6zY0rxaPIoKc0CX9sHhtH/nyo/5is= -github.com/ninech/apis v0.0.0-20240418132238-3fd74f5b0ebf h1:+/Y7rKMzxm7KIGHa8/wZpqI/bGandUY3KpEsigpJIoo= -github.com/ninech/apis v0.0.0-20240418132238-3fd74f5b0ebf/go.mod h1:6lFCwHqvcTFZvJ6zY0rxaPIoKc0CX9sHhtH/nyo/5is= -github.com/ninech/apis v0.0.0-20240418155256-94a608198745 h1:Oz5E/uiGwYVYPsFHohyCAFwzLbe92sqW+MHf+cNx1Fk= -github.com/ninech/apis v0.0.0-20240418155256-94a608198745/go.mod h1:6lFCwHqvcTFZvJ6zY0rxaPIoKc0CX9sHhtH/nyo/5is= -github.com/ninech/apis v0.0.0-20240422124542-cf572784fd6a h1:e2e5rZsBNdxxsKYZRKHFQVe/dYvJai7cGSBycN8OSTs= -github.com/ninech/apis v0.0.0-20240422124542-cf572784fd6a/go.mod h1:6lFCwHqvcTFZvJ6zY0rxaPIoKc0CX9sHhtH/nyo/5is= +github.com/ninech/apis v0.0.0-20240424152525-e2d75ea488d7 h1:lRZGujpQRKfasUTsq4YXr0qxLMod+q/tboa9MrMa1C0= +github.com/ninech/apis v0.0.0-20240424152525-e2d75ea488d7/go.mod h1:6lFCwHqvcTFZvJ6zY0rxaPIoKc0CX9sHhtH/nyo/5is= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= diff --git a/internal/test/redis.go b/internal/test/redis.go new file mode 100644 index 0000000..cf591c5 --- /dev/null +++ b/internal/test/redis.go @@ -0,0 +1,28 @@ +package test + +import ( + runtimev1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + meta "github.com/ninech/apis/meta/v1alpha1" + storage "github.com/ninech/apis/storage/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func KeyValueStore(name, project, location string) *storage.KeyValueStore { + return &storage.KeyValueStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: project, + }, + Spec: storage.KeyValueStoreSpec{ + ResourceSpec: runtimev1.ResourceSpec{ + WriteConnectionSecretToReference: &runtimev1.SecretReference{ + Name: name, + Namespace: project, + }, + }, + ForProvider: storage.KeyValueStoreParameters{ + Location: meta.LocationName(location), + }, + }, + } +} diff --git a/update/keyvaluestore.go b/update/keyvaluestore.go new file mode 100644 index 0000000..313e8d7 --- /dev/null +++ b/update/keyvaluestore.go @@ -0,0 +1,56 @@ +package update + +import ( + "context" + "fmt" + + "github.com/crossplane/crossplane-runtime/pkg/resource" + storage "github.com/ninech/apis/storage/v1alpha1" + "github.com/ninech/nctl/api" + kresource "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type keyValueStoreCmd struct { + Name string `arg:"" default:"" help:"Name of the KeyValueStore instance to update."` + MemorySize *string `help:"MemorySize configures KeyValueStore to use a specified amount of memory for the data set." placeholder:"1Gi"` + MaxMemoryPolicy *storage.KeyValueStoreMaxMemoryPolicy `help:"MaxMemoryPolicy specifies the exact behavior KeyValueStore follows when the maxmemory limit is reached." placeholder:"allkeys-lru"` + AllowedCidrs *[]storage.IPv4CIDR `help:"AllowedCIDRs specify the allowed IP addresses, connecting to the instance." placeholder:"0.0.0.0/0"` +} + +func (cmd *keyValueStoreCmd) Run(ctx context.Context, client *api.Client) error { + keyValueStore := &storage.KeyValueStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: cmd.Name, + Namespace: client.Project, + }, + } + + return newUpdater(client, keyValueStore, storage.KeyValueStoreKind, func(current resource.Managed) error { + keyValueStore, ok := current.(*storage.KeyValueStore) + if !ok { + return fmt.Errorf("resource is of type %T, expected %T", current, storage.KeyValueStore{}) + } + + return cmd.applyUpdates(keyValueStore) + }).Update(ctx) +} + +func (cmd *keyValueStoreCmd) applyUpdates(keyValueStore *storage.KeyValueStore) error { + if cmd.MemorySize != nil { + q, err := kresource.ParseQuantity(*cmd.MemorySize) + if err != nil { + return fmt.Errorf("error parsing memory size %q: %w", *cmd.MemorySize, err) + } + + keyValueStore.Spec.ForProvider.MemorySize = &storage.KeyValueStoreMemorySize{Quantity: q} + } + if cmd.MaxMemoryPolicy != nil { + keyValueStore.Spec.ForProvider.MaxMemoryPolicy = *cmd.MaxMemoryPolicy + } + if cmd.AllowedCidrs != nil { + keyValueStore.Spec.ForProvider.AllowedCIDRs = *cmd.AllowedCidrs + } + + return nil +} diff --git a/update/keyvaluestore_test.go b/update/keyvaluestore_test.go new file mode 100644 index 0000000..4958f2e --- /dev/null +++ b/update/keyvaluestore_test.go @@ -0,0 +1,120 @@ +package update + +import ( + "context" + "reflect" + "testing" + + storage "github.com/ninech/apis/storage/v1alpha1" + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/test" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestKeyValueStore(t *testing.T) { + tests := []struct { + name string + create storage.KeyValueStoreParameters + update keyValueStoreCmd + want storage.KeyValueStoreParameters + wantErr bool + }{ + {"simple", storage.KeyValueStoreParameters{}, keyValueStoreCmd{}, storage.KeyValueStoreParameters{}, false}, + { + "memorySize", + storage.KeyValueStoreParameters{}, + keyValueStoreCmd{MemorySize: ptr.To("1G")}, + storage.KeyValueStoreParameters{MemorySize: memorySize("1G")}, + false, + }, + { + "memorySize", + storage.KeyValueStoreParameters{MemorySize: memorySize("2G")}, + keyValueStoreCmd{MemorySize: ptr.To("1G")}, + storage.KeyValueStoreParameters{MemorySize: memorySize("1G")}, + false, + }, + { + "invalid", + storage.KeyValueStoreParameters{MemorySize: memorySize("2G")}, + keyValueStoreCmd{MemorySize: ptr.To("invalid")}, + storage.KeyValueStoreParameters{MemorySize: memorySize("2G")}, + true, + }, + { + "maxMemoryPolicy", + storage.KeyValueStoreParameters{}, + keyValueStoreCmd{MaxMemoryPolicy: ptr.To(storage.KeyValueStoreMaxMemoryPolicy("noeviction"))}, + storage.KeyValueStoreParameters{MaxMemoryPolicy: storage.KeyValueStoreMaxMemoryPolicy("noeviction")}, + false, + }, + { + "maxMemoryPolicy", + storage.KeyValueStoreParameters{MaxMemoryPolicy: storage.KeyValueStoreMaxMemoryPolicy("allkeys-lfu")}, + keyValueStoreCmd{MaxMemoryPolicy: ptr.To(storage.KeyValueStoreMaxMemoryPolicy("noeviction"))}, + storage.KeyValueStoreParameters{MaxMemoryPolicy: storage.KeyValueStoreMaxMemoryPolicy("noeviction")}, + false, + }, + { + "allowedCIDRs", + storage.KeyValueStoreParameters{}, + keyValueStoreCmd{AllowedCidrs: &[]storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}}, + storage.KeyValueStoreParameters{AllowedCIDRs: []storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}}, + false, + }, + { + "allowedCIDRs", + storage.KeyValueStoreParameters{AllowedCIDRs: []storage.IPv4CIDR{"192.168.0.1/24"}}, + keyValueStoreCmd{AllowedCidrs: &[]storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}}, + storage.KeyValueStoreParameters{AllowedCIDRs: []storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}}, + false, + }, + { + "allowedCIDRs", + storage.KeyValueStoreParameters{AllowedCIDRs: []storage.IPv4CIDR{"0.0.0.0/0"}}, + keyValueStoreCmd{MemorySize: ptr.To("1G")}, + storage.KeyValueStoreParameters{MemorySize: memorySize("1G"), AllowedCIDRs: []storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.update.Name = "test-" + t.Name() + + scheme, err := api.NewScheme() + if err != nil { + t.Fatal(err) + } + apiClient := &api.Client{WithWatch: fake.NewClientBuilder().WithScheme(scheme).Build(), Project: "default"} + ctx := context.Background() + + created := test.KeyValueStore(tt.update.Name, apiClient.Project, "nine-es34") + created.Spec.ForProvider = tt.create + if err := apiClient.Create(ctx, created); err != nil { + t.Fatalf("keyvaluestore create error, got: %s", err) + } + if err := apiClient.Get(ctx, api.ObjectName(created), created); err != nil { + t.Fatalf("expected keyvaluestore to exist, got: %s", err) + } + + updated := &storage.KeyValueStore{ObjectMeta: metav1.ObjectMeta{Name: created.Name, Namespace: created.Namespace}} + if err := tt.update.Run(ctx, apiClient); (err != nil) != tt.wantErr { + t.Errorf("keyValueStoreCmd.Run() error = %v, wantErr %v", err, tt.wantErr) + } + if err := apiClient.Get(ctx, api.ObjectName(updated), updated); err != nil { + t.Fatalf("expected keyvaluestore to exist, got: %s", err) + } + + if !reflect.DeepEqual(updated.Spec.ForProvider, tt.want) { + t.Fatalf("expected KeyValueStore.Spec.ForProvider = %v, got: %v", updated.Spec.ForProvider, tt.want) + } + }) + } +} + +func memorySize(s string) *storage.KeyValueStoreMemorySize { + return &storage.KeyValueStoreMemorySize{Quantity: resource.MustParse(s)} +} diff --git a/update/update.go b/update/update.go index 9738119..6c25cc9 100644 --- a/update/update.go +++ b/update/update.go @@ -9,10 +9,11 @@ import ( ) type Cmd struct { - Application applicationCmd `cmd:"" group:"deplo.io" name:"application" aliases:"app" help:"Update an existing deplo.io Application. (Beta - requires access)"` - Config configCmd `cmd:"" group:"deplo.io" name:"config" help:"Update an existing deplo.io Project Configuration. (Beta - requires access)"` - Project projectCmd `cmd:"" group:"management.nine.ch" name:"project" help:"Update an existing Project"` - MySQL mySQLCmd `cmd:"" group:"storage.nine.ch" name:"mysql" help:"Update an existing MySQL instance."` + Application applicationCmd `cmd:"" group:"deplo.io" name:"application" aliases:"app" help:"Update an existing deplo.io Application. (Beta - requires access)"` + Config configCmd `cmd:"" group:"deplo.io" name:"config" help:"Update an existing deplo.io Project Configuration. (Beta - requires access)"` + Project projectCmd `cmd:"" group:"management.nine.ch" name:"project" help:"Update an existing Project"` + MySQL mySQLCmd `cmd:"" group:"storage.nine.ch" name:"mysql" help:"Update an existing MySQL instance."` + KeyValueStore keyValueStoreCmd `cmd:"" group:"storage.nine.ch" name:"keyvaluestore" aliases:"kvs" help:"Update an existing KeyValueStore instance"` } type updater struct { @@ -24,8 +25,8 @@ type updater struct { type updateFunc func(current resource.Managed) error -func newUpdater(client *api.Client, mg resource.Managed, kind string, f updateFunc) updater { - return updater{client: client, mg: mg, kind: kind, updateFunc: f} +func newUpdater(client *api.Client, mg resource.Managed, kind string, f updateFunc) *updater { + return &updater{client: client, mg: mg, kind: kind, updateFunc: f} } func (u *updater) Update(ctx context.Context) error {