diff --git a/create/create.go b/create/create.go index c25ab1b..c26f748 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."` + Postgres postgresCmd `cmd:"" group:"storage.nine.ch" name:"postgres" help:"Create a new PostgreSQL instance."` KeyValueStore keyValueStoreCmd `cmd:"" group:"storage.nine.ch" name:"keyvaluestore" aliases:"kvs" help:"Create a new KeyValueStore instance"` CloudVirtualMachine cloudVMCmd `cmd:"" group:"infrastructure.nine.ch" name:"cloudvirtualmachine" aliases:"cloudvm" help:"Create a new CloudVM."` } diff --git a/create/postgres.go b/create/postgres.go new file mode 100644 index 0000000..4b07742 --- /dev/null +++ b/create/postgres.go @@ -0,0 +1,123 @@ +package create + +import ( + "context" + "fmt" + "strings" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + + "github.com/alecthomas/kong" + runtimev1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + infra "github.com/ninech/apis/infrastructure/v1alpha1" + meta "github.com/ninech/apis/meta/v1alpha1" + storage "github.com/ninech/apis/storage/v1alpha1" + + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/file" +) + +type postgresCmd struct { + Name string `arg:"" default:"" help:"Name of the PostgreSQL instance. A random name is generated if omitted."` + Location string `placeholder:"${postgres_location_default}" help:"Location where the PostgreSQL instance is created. Available locations are: ${postgres_location_options}"` + MachineType infra.MachineType `placeholder:"${postgres_machine_default}" help:"Defines the sizing for a particular PostgreSQL instance. Available types: ${postgres_machine_types}"` + AllowedCidrs []storage.IPv4CIDR `placeholder:"0.0.0.0/0" help:"Specifies the IP addresses allowed to connect to the instance." ` + SSHKeys []storage.SSHKey `help:"Contains a list of SSH public keys, allowed to connect to the db server, in order to up-/download and directly restore database backups."` + SSHKeysFile string `help:"Path to a file containing a list of SSH public keys (see above), separated by newlines."` + PostgresVersion storage.PostgresVersion `placeholder:"${postgres_version_default}" help:"Release version with which the PostgreSQL instance is created"` + KeepDailyBackups *int `placeholder:"${postgres_backup_retention_days}" help:"Number of daily database backups to keep. Note that setting this to 0, backup will be disabled and existing dumps deleted immediately."` + Wait bool `default:"true" help:"Wait until PostgreSQL instance is created."` + WaitTimeout time.Duration `default:"25m" help:"Duration to wait for PostgreSQL getting ready. Only relevant if --wait is set."` +} + +func (cmd *postgresCmd) Run(ctx context.Context, client *api.Client) error { + sshkeys, err := file.ReadSSHKeys(cmd.SSHKeysFile) + if err != nil { + return fmt.Errorf("error when reading SSH keys file: %w", err) + } + cmd.SSHKeys = append(cmd.SSHKeys, sshkeys...) + + fmt.Printf("Creating new postgres. This might take some time (waiting up to %s).\n", cmd.WaitTimeout) + postgres := cmd.newPostgres(client.Project) + + c := newCreator(client, postgres, "postgres") + 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.PostgresList{}, + onResult: func(event watch.Event) (bool, error) { + if c, ok := event.Object.(*storage.Postgres); ok { + return isAvailable(c), nil + } + return false, nil + }, + }, + ) +} + +func (cmd *postgresCmd) newPostgres(namespace string) *storage.Postgres { + name := getName(cmd.Name) + + postgres := &storage.Postgres{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: storage.PostgresSpec{ + ResourceSpec: runtimev1.ResourceSpec{ + WriteConnectionSecretToReference: &runtimev1.SecretReference{ + Name: "postgres-" + name, + Namespace: namespace, + }, + }, + ForProvider: storage.PostgresParameters{ + Location: meta.LocationName(cmd.Location), + MachineType: cmd.MachineType, + AllowedCIDRs: []storage.IPv4CIDR{}, // avoid missing parameter error + SSHKeys: []storage.SSHKey{}, // avoid missing parameter error + Version: cmd.PostgresVersion, + KeepDailyBackups: cmd.KeepDailyBackups, + }, + }, + } + + if cmd.AllowedCidrs != nil { + postgres.Spec.ForProvider.AllowedCIDRs = cmd.AllowedCidrs + } + if cmd.SSHKeys != nil { + postgres.Spec.ForProvider.SSHKeys = cmd.SSHKeys + } + + return postgres +} + +// ApplicationKongVars returns all variables which are used in the application +// create command +func PostgresKongVars() kong.Vars { + vmTypes := make([]string, len(infra.MachineTypes)) + for i, machineType := range infra.MachineTypes { + vmTypes[i] = string(machineType) + } + + result := make(kong.Vars) + result["postgres_machine_types"] = strings.Join(vmTypes, ", ") + result["postgres_machine_default"] = string(infra.MachineTypes[0]) + result["postgres_location_options"] = strings.Join(storage.PostgresLocationOptions, ", ") + result["postgres_location_default"] = string(storage.PostgresLocationDefault) + result["postgres_version_default"] = string(storage.PostgresVersionDefault) + result["postgres_user"] = storage.PostgresUser + result["postgres_backup_retention_days"] = fmt.Sprintf("%d", storage.PostgresBackupRetentionDaysDefault) + + return result +} diff --git a/create/postgres_test.go b/create/postgres_test.go new file mode 100644 index 0000000..1e6224c --- /dev/null +++ b/create/postgres_test.go @@ -0,0 +1,112 @@ +package create + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + infra "github.com/ninech/apis/infrastructure/v1alpha1" + storage "github.com/ninech/apis/storage/v1alpha1" + "github.com/ninech/nctl/api" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func TestPostgres(t *testing.T) { + tests := []struct { + name string + create postgresCmd + want storage.PostgresParameters + wantErr bool + interceptorFuncs *interceptor.Funcs + }{ + { + name: "simple", + create: postgresCmd{}, + want: storage.PostgresParameters{}, + }, + { + name: "simpleErrorOnCreation", + create: postgresCmd{}, + wantErr: true, + interceptorFuncs: &interceptor.Funcs{ + Create: func(_ context.Context, _ client.WithWatch, _ client.Object, _ ...client.CreateOption) error { + return errors.New("error on creation") + }, + }, + }, + { + name: "machineType", + create: postgresCmd{MachineType: infra.MachineType("nine-standard-1")}, + want: storage.PostgresParameters{MachineType: infra.MachineType("nine-standard-1")}, + }, + { + name: "sshKeys", + create: postgresCmd{SSHKeys: []storage.SSHKey{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJGG5/nnivrW4zLD4ANLclVT3y68GAg6NOA3HpzFLo5e test@test"}}, + want: storage.PostgresParameters{SSHKeys: []storage.SSHKey{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJGG5/nnivrW4zLD4ANLclVT3y68GAg6NOA3HpzFLo5e test@test"}}, + }, + { + name: "allowedCIDRs", + create: postgresCmd{AllowedCidrs: []storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}}, + want: storage.PostgresParameters{AllowedCIDRs: []storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}}, + }, + { + name: "version", + create: postgresCmd{PostgresVersion: storage.PostgresVersionDefault}, + want: storage.PostgresParameters{Version: storage.PostgresVersionDefault}, + }, + { + name: "keepDailyBackups", + create: postgresCmd{KeepDailyBackups: ptr.To(5)}, + want: storage.PostgresParameters{KeepDailyBackups: ptr.To(5)}, + }, + } + 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) + } + builder := fake.NewClientBuilder().WithScheme(scheme) + if tt.interceptorFuncs != nil { + builder = builder.WithInterceptorFuncs(*tt.interceptorFuncs) + } + postgresClient := builder.Build() + apiClient := &api.Client{WithWatch: postgresClient, Project: "default"} + ctx := context.Background() + + if err := tt.create.Run(ctx, apiClient); (err != nil) != tt.wantErr { + t.Errorf("postgresCmd.Run() error = %v, wantErr %v", err, tt.wantErr) + } + + created := &storage.Postgres{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 postgres to exist, got: %s", err) + } + if tt.wantErr { + return + } + + // we set defaults for the slices + if tt.want.AllowedCIDRs == nil { + tt.want.AllowedCIDRs = []storage.IPv4CIDR{} + } + if tt.want.SSHKeys == nil { + tt.want.SSHKeys = []storage.SSHKey{} + } + + if !reflect.DeepEqual(created.Spec.ForProvider, tt.want) { + t.Fatalf("expected postgres.Spec.ForProvider = %+v, got: %+v", created.Spec.ForProvider, tt.want) + } + }) + } +} diff --git a/delete/delete.go b/delete/delete.go index a3a86c5..cd86e2b 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."` + Postgres postgresCmd `cmd:"" group:"storage.nine.ch" name:"postgres" help:"Delete a PostgreSQL instance."` KeyValueStore keyValueStoreCmd `cmd:"" group:"storage.nine.ch" name:"keyvaluestore" aliases:"kvs" help:"Delete a KeyValueStore instance."` CloudVirtualMachine cloudVMCmd `cmd:"" group:"infrastructure.nine.ch" name:"cloudvirtualmachine" aliases:"cloudvm" help:"Delete a CloudVM."` } diff --git a/delete/postgres.go b/delete/postgres.go new file mode 100644 index 0000000..e7c3792 --- /dev/null +++ b/delete/postgres.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 postgresCmd struct { + Name string `arg:"" help:"Name of the PostgreSQL resource."` + Force bool `default:"false" help:"Do not ask for confirmation of deletion."` + Wait bool `default:"true" help:"Wait until PostgreSQL is fully deleted."` + WaitTimeout time.Duration `default:"300s" help:"Duration to wait for the deletion. Only relevant if wait is set."` +} + +func (cmd *postgresCmd) Run(ctx context.Context, client *api.Client) error { + ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout) + defer cancel() + + postgres := &storage.Postgres{} + postgresName := types.NamespacedName{Name: cmd.Name, Namespace: client.Project} + if err := client.Get(ctx, postgresName, postgres); err != nil { + return fmt.Errorf("unable to get postgres %q: %w", postgres.Name, err) + } + + return newDeleter(postgres, storage.PostgresKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force) +} diff --git a/delete/postgres_test.go b/delete/postgres_test.go new file mode 100644 index 0000000..0039461 --- /dev/null +++ b/delete/postgres_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 TestPostgres(t *testing.T) { + cmd := postgresCmd{ + Name: "test", + Force: true, + Wait: false, + WaitTimeout: time.Second, + } + + postgres := test.Postgres("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, postgres); err != nil { + t.Fatalf("postgres create error, got: %s", err) + } + if err := apiClient.Get(ctx, api.ObjectName(postgres), postgres); err != nil { + t.Fatalf("expected postgres to exist, got: %s", err) + } + if err := cmd.Run(ctx, apiClient); err != nil { + t.Fatal(err) + } + err = apiClient.Get(ctx, api.ObjectName(postgres), postgres) + if err == nil { + t.Fatalf("expected postgres to be deleted, but exists") + } + if !errors.IsNotFound(err) { + t.Fatalf("expected postgres to be deleted, got: %s", err.Error()) + } +} diff --git a/get/get.go b/get/get.go index d331a4e..62de031 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."` + Postgres postgresCmd `cmd:"" group:"storage.nine.ch" name:"postgres" help:"Get PostgreSQL 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"` CloudVirtualMachine cloudVMCmd `cmd:"" group:"infrastructure.nine.ch" name:"cloudvirtualmachine" aliases:"cloudvm" help:"Get a CloudVM."` diff --git a/get/postgres.go b/get/postgres.go new file mode 100644 index 0000000..a0fb2c4 --- /dev/null +++ b/get/postgres.go @@ -0,0 +1,79 @@ +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 postgresCmd struct { + Name string `arg:"" help:"Name of the PostgreSQL instance to get. If omitted all in the project will be listed." default:""` + PrintPassword bool `help:"Print the password of the PostgreSQL User. Requires name to be set." xor:"print"` + PrintUser bool `help:"Print the name of the PostgreSQL User. Requires name to be set." xor:"print"` + + out io.Writer +} + +func (cmd *postgresCmd) Run(ctx context.Context, client *api.Client, get *Cmd) error { + cmd.out = defaultOut(cmd.out) + + if cmd.Name != "" && cmd.PrintUser { + fmt.Fprintln(cmd.out, storage.PostgresUser) + return nil + } + + postgresList := &storage.PostgresList{} + + if err := get.list(ctx, client, postgresList, matchName(cmd.Name)); err != nil { + return err + } + + if len(postgresList.Items) == 0 { + printEmptyMessage(cmd.out, storage.PostgresKind, client.Project) + return nil + } + + if cmd.Name != "" && cmd.PrintPassword { + return cmd.printPassword(ctx, client, &postgresList.Items[0]) + } + + switch get.Output { + case full: + return cmd.printPostgresInstances(postgresList.Items, get, true) + case noHeader: + return cmd.printPostgresInstances(postgresList.Items, get, false) + case yamlOut: + return format.PrettyPrintObjects(postgresList.GetItems(), format.PrintOpts{}) + } + + return nil +} + +func (cmd *postgresCmd) printPostgresInstances(list []storage.Postgres, get *Cmd, header bool) error { + w := tabwriter.NewWriter(cmd.out, 0, 0, 4, ' ', 0) + + if header { + get.writeHeader(w, "NAME", "FQDN", "LOCATION", "MACHINE TYPE") + } + + for _, postgres := range list { + get.writeTabRow(w, postgres.Namespace, postgres.Name, postgres.Status.AtProvider.FQDN, string(postgres.Spec.ForProvider.Location), string(postgres.Spec.ForProvider.MachineType)) + } + + return w.Flush() +} + +func (cmd *postgresCmd) printPassword(ctx context.Context, client *api.Client, postgres *storage.Postgres) error { + pw, err := getConnectionSecret(ctx, client, storage.PostgresUser, postgres) + if err != nil { + return err + } + + fmt.Fprintln(cmd.out, pw) + return nil +} diff --git a/get/postgres_test.go b/get/postgres_test.go new file mode 100644 index 0000000..769f17f --- /dev/null +++ b/get/postgres_test.go @@ -0,0 +1,116 @@ +package get + +import ( + "bytes" + "context" + "strings" + "testing" + + infra "github.com/ninech/apis/infrastructure/v1alpha1" + storage "github.com/ninech/apis/storage/v1alpha1" + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/test" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestPostgres(t *testing.T) { + tests := []struct { + name string + instances map[string]storage.PostgresParameters + get postgresCmd + // out defines the output format and will bet set to "full" if + // not given + out output + wantContain []string + wantErr bool + }{ + { + name: "simple", + wantContain: []string{"no Postgres found"}, + }, + { + name: "single", + instances: map[string]storage.PostgresParameters{"test": {MachineType: infra.MachineType("nine-standard-1")}}, + wantContain: []string{"nine-standard-1"}, + }, + { + name: "multiple", + instances: map[string]storage.PostgresParameters{ + "test1": {MachineType: infra.MachineType("nine-standard-1")}, + "test2": {MachineType: infra.MachineType("nine-standard-2")}, + "test3": {MachineType: infra.MachineType("nine-standard-4")}, + }, + wantContain: []string{"nine-standard-1", "nine-standard-2", "test3"}, + }, + { + name: "get-by-name", + instances: map[string]storage.PostgresParameters{ + "test1": {MachineType: infra.MachineType("nine-standard-1")}, + "test2": {MachineType: infra.MachineType("nine-standard-2")}, + }, + get: postgresCmd{Name: "test1"}, + wantContain: []string{"test1", "nine-standard-1"}, + }, + { + name: "show-password", + instances: map[string]storage.PostgresParameters{ + "test1": {MachineType: infra.MachineType("nine-standard-1")}, + "test2": {MachineType: infra.MachineType("nine-standard-2")}, + }, + get: postgresCmd{Name: "test2", PrintPassword: true}, + wantContain: []string{"test2-topsecret"}, + }, + } + 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.Postgres(name, "default", "nine-es34") + created.Spec.ForProvider = instance + objects = append(objects, created, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: created.GetWriteConnectionSecretToReference().Name, + Namespace: created.GetWriteConnectionSecretToReference().Namespace, + }, + Data: map[string][]byte{storage.PostgresUser: []byte(created.GetWriteConnectionSecretToReference().Name + "-topsecret")}, + }) + } + + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithIndex(&storage.Postgres{}, "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 tt.out == "" { + tt.out = full + } + if err := tt.get.Run(ctx, apiClient, &Cmd{Output: tt.out}); (err != nil) != tt.wantErr { + t.Errorf("postgresCmd.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("postgresCmd.Run() did not contain %q, out = %q", tt.wantContain, buf.String()) + } + } + }) + } +} diff --git a/internal/test/postgres.go b/internal/test/postgres.go new file mode 100644 index 0000000..58454f8 --- /dev/null +++ b/internal/test/postgres.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 Postgres(name, project, location string) *storage.Postgres { + return &storage.Postgres{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: project, + }, + Spec: storage.PostgresSpec{ + ResourceSpec: runtimev1.ResourceSpec{ + WriteConnectionSecretToReference: &runtimev1.SecretReference{ + Name: name, + Namespace: project, + }, + }, + ForProvider: storage.PostgresParameters{ + Location: meta.LocationName(location), + }, + }, + } +} diff --git a/main.go b/main.go index 55fa34a..33a6bd2 100644 --- a/main.go +++ b/main.go @@ -149,7 +149,7 @@ func kongVariables() (kong.Vars, error) { if err != nil { return nil, fmt.Errorf("error on application create kong vars: %w", err) } - if err := merge(result, appCreateKongVars, create.MySQLKongVars()); err != nil { + if err := merge(result, appCreateKongVars, create.MySQLKongVars(), create.PostgresKongVars()); err != nil { return nil, fmt.Errorf("error when merging kong variables: %w", err) } diff --git a/update/postgres.go b/update/postgres.go new file mode 100644 index 0000000..74815d1 --- /dev/null +++ b/update/postgres.go @@ -0,0 +1,64 @@ +package update + +import ( + "context" + "fmt" + + "github.com/crossplane/crossplane-runtime/pkg/resource" + infra "github.com/ninech/apis/infrastructure/v1alpha1" + storage "github.com/ninech/apis/storage/v1alpha1" + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/file" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type postgresCmd struct { + Name string `arg:"" default:"" help:"Name of the PostgreSQL instance to update."` + MachineType *infra.MachineType `placeholder:"${postgres_machine_default}" help:"Defines the sizing for a particular PostgreSQL instance. Available types: ${postgres_machine_types}"` + AllowedCidrs *[]storage.IPv4CIDR `placeholder:"0.0.0.0/0" help:"Specifies the IP addresses allowed to connect to the instance." ` + SSHKeys []storage.SSHKey `help:"Contains a list of SSH public keys, allowed to connect to the db server, in order to up-/download and directly restore database backups."` + SSHKeysFile string `help:"Path to a file containing a list of SSH public keys (see above), separated by newlines."` + KeepDailyBackups *int `placeholder:"${postgres_backup_retention_days}" help:"Number of daily database backups to keep. Note that setting this to 0, backup will be disabled and existing dumps deleted immediately."` +} + +func (cmd *postgresCmd) Run(ctx context.Context, client *api.Client) error { + postgres := &storage.Postgres{ + ObjectMeta: metav1.ObjectMeta{ + Name: cmd.Name, + Namespace: client.Project, + }, + } + + upd := newUpdater(client, postgres, storage.PostgresKind, func(current resource.Managed) error { + postgres, ok := current.(*storage.Postgres) + if !ok { + return fmt.Errorf("resource is of type %T, expected %T", current, storage.Postgres{}) + } + + sshkeys, err := file.ReadSSHKeys(cmd.SSHKeysFile) + if err != nil { + return fmt.Errorf("error when reading SSH keys file: %w", err) + } + cmd.SSHKeys = append(cmd.SSHKeys, sshkeys...) + + cmd.applyUpdates(postgres) + return nil + }) + + return upd.Update(ctx) +} + +func (cmd *postgresCmd) applyUpdates(postgres *storage.Postgres) { + if cmd.MachineType != nil { + postgres.Spec.ForProvider.MachineType = *cmd.MachineType + } + if cmd.AllowedCidrs != nil { + postgres.Spec.ForProvider.AllowedCIDRs = *cmd.AllowedCidrs + } + if cmd.SSHKeys != nil { + postgres.Spec.ForProvider.SSHKeys = cmd.SSHKeys + } + if cmd.KeepDailyBackups != nil { + postgres.Spec.ForProvider.KeepDailyBackups = cmd.KeepDailyBackups + } +} diff --git a/update/postgres_test.go b/update/postgres_test.go new file mode 100644 index 0000000..1a24e91 --- /dev/null +++ b/update/postgres_test.go @@ -0,0 +1,99 @@ +package update + +import ( + "context" + "reflect" + "testing" + + infra "github.com/ninech/apis/infrastructure/v1alpha1" + storage "github.com/ninech/apis/storage/v1alpha1" + "github.com/ninech/nctl/api" + "github.com/ninech/nctl/internal/test" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestPostgres(t *testing.T) { + tests := []struct { + name string + create storage.PostgresParameters + update postgresCmd + want storage.PostgresParameters + wantErr bool + }{ + { + name: "simple", + }, + { + name: "increase-machineType", + update: postgresCmd{MachineType: ptr.To(infra.MachineType("nine-standard-1"))}, + want: storage.PostgresParameters{MachineType: infra.MachineType("nine-standard-1")}, + }, + { + name: "decrease-machineType", + create: storage.PostgresParameters{MachineType: infra.MachineType("nine-standard-2")}, + update: postgresCmd{MachineType: ptr.To(infra.MachineType("nine-standard-1"))}, + want: storage.PostgresParameters{MachineType: infra.MachineType("nine-standard-1")}, + }, + { + name: "sshKeys", + update: postgresCmd{SSHKeys: []storage.SSHKey{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJGG5/nnivrW4zLD4ANLclVT3y68GAg6NOA3HpzFLo5e test@test"}}, + want: storage.PostgresParameters{SSHKeys: []storage.SSHKey{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJGG5/nnivrW4zLD4ANLclVT3y68GAg6NOA3HpzFLo5e test@test"}}, + }, + { + name: "keepDailyBackups", + update: postgresCmd{KeepDailyBackups: ptr.To(5)}, + want: storage.PostgresParameters{KeepDailyBackups: ptr.To(5)}, + }, + { + name: "allowedCIDRs-nothing-set-initially", + update: postgresCmd{AllowedCidrs: &[]storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}}, + want: storage.PostgresParameters{AllowedCIDRs: []storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}}, + }, + { + name: "allowedCIDRs-set-initially", + create: storage.PostgresParameters{AllowedCIDRs: []storage.IPv4CIDR{"192.168.0.1/24"}}, + update: postgresCmd{AllowedCidrs: &[]storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}}, + want: storage.PostgresParameters{AllowedCIDRs: []storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}}, + }, + { + name: "multi-update", + create: storage.PostgresParameters{AllowedCIDRs: []storage.IPv4CIDR{"0.0.0.0/0"}}, + update: postgresCmd{MachineType: ptr.To(infra.MachineType("nine-standard-1"))}, + want: storage.PostgresParameters{MachineType: infra.MachineType("nine-standard-1"), AllowedCIDRs: []storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}}, + }, + } + 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.Postgres(tt.update.Name, apiClient.Project, "nine-es34") + created.Spec.ForProvider = tt.create + if err := apiClient.Create(ctx, created); err != nil { + t.Fatalf("postgres create error, got: %s", err) + } + if err := apiClient.Get(ctx, api.ObjectName(created), created); err != nil { + t.Fatalf("expected postgres to exist, got: %s", err) + } + + updated := &storage.Postgres{} + if err := tt.update.Run(ctx, apiClient); (err != nil) != tt.wantErr { + t.Errorf("postgresCmd.Run() error = %v, wantErr %v", err, tt.wantErr) + } + if err := apiClient.Get(ctx, api.ObjectName(created), updated); err != nil { + t.Fatalf("expected postgres to exist, got: %s", err) + } + + if !reflect.DeepEqual(updated.Spec.ForProvider, tt.want) { + t.Fatalf("expected postgres.Spec.ForProvider = %v, got: %v", updated.Spec.ForProvider, tt.want) + } + }) + } +} diff --git a/update/update.go b/update/update.go index ceb2d7b..cc203f0 100644 --- a/update/update.go +++ b/update/update.go @@ -13,6 +13,7 @@ type Cmd struct { 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."` + Postgres postgresCmd `cmd:"" group:"storage.nine.ch" name:"postgres" help:"Update an existing PostgreSQL instance."` KeyValueStore keyValueStoreCmd `cmd:"" group:"storage.nine.ch" name:"keyvaluestore" aliases:"kvs" help:"Update an existing KeyValueStore instance"` CloudVirtualMachine cloudVMCmd `cmd:"" group:"infrastructure.nine.ch" name:"cloudvirtualmachine" aliases:"cloudvm" help:"Update a CloudVM."` }