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

feat(mysql): add resource #85

Merged
merged 32 commits into from
Apr 22, 2024
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type Cmd struct {
Project projectCmd `cmd:"" group:"management.nine.ch" name:"project" help:"Create a new project."`
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."`
}

// resultFunc is the function called on a watch event during creation. It
Expand Down
134 changes: 134 additions & 0 deletions create/mysql.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
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 mySQLCmd struct {
Name string `arg:"" default:"" help:"Name of the MySQL instance. A random name is generated if omitted."`
Location string `placeholder:"${mysql_location_default}" help:"Location where the MySQL instance is created. Available locations are: ${mysql_location_options}"`
MachineType infra.MachineType `placeholder:"${mysql_machine_default}" help:"Defines the sizing for a particular MySQL instance. Available types: ${mysql_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."`
olknegate marked this conversation as resolved.
Show resolved Hide resolved
SSHKeysFile string `help:"Path to a file containing a list of SSH public keys (see above), separated by newlines."`
SQLMode *[]storage.MySQLMode `placeholder:"\"MODE1, MODE2, ...\"" help:"Configures the sql_mode setting. Modes affect the SQL syntax MySQL supports and the data validation checks it performs. Defaults to: ${mysql_mode}"`
CharacterSetName string `placeholder:"${mysql_charset}" help:"Configures the character_set_server variable."`
CharacterSetCollation string `placeholder:"${mysql_collation}" help:"Configures the collation_server variable."`
LongQueryTime storage.LongQueryTime `placeholder:"${mysql_long_query_time}" help:"Configures the long_query_time variable. If a query takes longer than this duration, the query is logged to the slow query log file."`
MinWordLength *int `placeholder:"${mysql_min_word_length}" help:"Configures the ft_min_word_len and innodb_ft_min_token_size variables."`
TransactionIsolation storage.MySQLTransactionCharacteristic `placeholder:"${mysql_transaction_isolation}" help:"Configures the transaction_isolation variable."`
KeepDailyBackups *int `placeholder:"${mysql_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 MySQL instance is created."`
WaitTimeout time.Duration `default:"900s" help:"Duration to wait for MySQL getting ready. Only relevant if --wait is set."`
}

func (cmd *mySQLCmd) 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)
}
if sshkeys != nil {
cmd.SSHKeys = append(cmd.SSHKeys, sshkeys...)
}

fmt.Println("Creating new mysql. This can take up to 15 minutes.")
mysql := cmd.newMySQL(client.Project)

c := newCreator(client, mysql, "mysql")
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.MySQLList{},
onResult: func(event watch.Event) (bool, error) {
if c, ok := event.Object.(*storage.MySQL); ok {
return isAvailable(c), nil
}
return false, nil
},
},
)
}

func (cmd *mySQLCmd) newMySQL(namespace string) *storage.MySQL {
name := getName(cmd.Name)

mySQL := &storage.MySQL{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Spec: storage.MySQLSpec{
ResourceSpec: runtimev1.ResourceSpec{
WriteConnectionSecretToReference: &runtimev1.SecretReference{
Name: "mysql-" + name,
Namespace: namespace,
},
},
ForProvider: storage.MySQLParameters{
Location: meta.LocationName(cmd.Location),
MachineType: cmd.MachineType,
AllowedCIDRs: cmd.AllowedCidrs,
SSHKeys: cmd.SSHKeys,
SQLMode: cmd.SQLMode,
CharacterSet: storage.MySQLCharacterSet{
Name: cmd.CharacterSetName,
Collation: cmd.CharacterSetCollation,
},
LongQueryTime: cmd.LongQueryTime,
MinWordLength: cmd.MinWordLength,
TransactionIsolation: cmd.TransactionIsolation,
KeepDailyBackups: cmd.KeepDailyBackups,
},
},
}

return mySQL
}

// ApplicationKongVars returns all variables which are used in the application
// create command
func MySQLKongVars() (kong.Vars, error) {
vmTypes := make([]string, len(infra.MachineTypes))
for i, machineType := range infra.MachineTypes {
vmTypes[i] = string(machineType)
}

result := make(kong.Vars)
result["mysql_machine_types"] = strings.Join(vmTypes, ", ")
result["mysql_machine_default"] = string(infra.MachineTypes[0])
result["mysql_location_options"] = strings.Join(storage.MySQLLocationOptions, ", ")
result["mysql_location_default"] = string(storage.MySQLLocationDefault)
result["mysql_user"] = string(storage.MySQLUser)
result["mysql_mode"] = strings.Join(storage.MySQLModeDefault, ", ")
result["mysql_long_query_time"] = string(storage.MySQLLongQueryTimeDefault)
result["mysql_charset"] = string(storage.MySQLCharsetDefault)
result["mysql_collation"] = string(storage.MySQLCollationDefault)
result["mysql_min_word_length"] = fmt.Sprintf("%d", storage.MySQLMinWordLengthDefault)
result["mysql_transaction_isolation"] = string(storage.MySQLTransactionIsolationDefault)
result["mysql_backup_retention_days"] = fmt.Sprintf("%d", storage.MySQLBackupRetentionDaysDefault)
return result, nil
}
124 changes: 124 additions & 0 deletions create/mysql_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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 TestMySQL(t *testing.T) {
tests := []struct {
name string
create mySQLCmd
want storage.MySQLParameters
wantErr bool
interceptorFuncs *interceptor.Funcs
}{
{
name: "simple",
create: mySQLCmd{},
want: storage.MySQLParameters{},
},
{
name: "simpleErrorOnCreation",
create: mySQLCmd{},
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: mySQLCmd{MachineType: infra.MachineType("nine-standard-1")},
want: storage.MySQLParameters{MachineType: infra.MachineType("nine-standard-1")},
},
{
name: "sshKeys",
create: mySQLCmd{SSHKeys: []storage.SSHKey{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJGG5/nnivrW4zLD4ANLclVT3y68GAg6NOA3HpzFLo5e test@test"}},
want: storage.MySQLParameters{SSHKeys: []storage.SSHKey{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJGG5/nnivrW4zLD4ANLclVT3y68GAg6NOA3HpzFLo5e test@test"}},
},
{
name: "sqlMode",
create: mySQLCmd{SQLMode: &[]storage.MySQLMode{"ONLY_FULL_GROUP_BY"}},
want: storage.MySQLParameters{SQLMode: &[]storage.MySQLMode{"ONLY_FULL_GROUP_BY"}},
},
{
name: "allowedCIDRs",
create: mySQLCmd{AllowedCidrs: []storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}},
want: storage.MySQLParameters{AllowedCIDRs: []storage.IPv4CIDR{storage.IPv4CIDR("0.0.0.0/0")}},
},
{
name: "characterSet",
create: mySQLCmd{CharacterSetName: "utf8mb4", CharacterSetCollation: "utf8mb4_unicode_ci"},
want: storage.MySQLParameters{CharacterSet: storage.MySQLCharacterSet{Name: "utf8mb4", Collation: "utf8mb4_unicode_ci"}},
},
{
name: "longQueryTime",
create: mySQLCmd{LongQueryTime: storage.LongQueryTime("300")},
want: storage.MySQLParameters{LongQueryTime: storage.LongQueryTime("300")},
},
{
name: "minWordLength",
create: mySQLCmd{MinWordLength: ptr.To(5)},
want: storage.MySQLParameters{MinWordLength: ptr.To(5)},
},
{
name: "transactionIsolation",
create: mySQLCmd{TransactionIsolation: storage.MySQLTransactionCharacteristic("READ-UNCOMMITTED")},
want: storage.MySQLParameters{TransactionIsolation: storage.MySQLTransactionCharacteristic("READ-UNCOMMITTED")},
},
{
name: "keepDailyBackups",
create: mySQLCmd{KeepDailyBackups: ptr.To(5)},
want: storage.MySQLParameters{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)
}
client := builder.Build()
apiClient := &api.Client{WithWatch: client, Project: "default"}
ctx := context.Background()

if err := tt.create.Run(ctx, apiClient); (err != nil) != tt.wantErr {
t.Errorf("mySQLCmd.Run() error = %v, wantErr %v", err, tt.wantErr)
}

created := &storage.MySQL{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 mysql to exist, got: %s", err)
}
if tt.wantErr {
return
}

if !reflect.DeepEqual(created.Spec.ForProvider, tt.want) {
t.Fatalf("expected mysql.Spec.ForProvider = %+v, got: %+v", created.Spec.ForProvider, tt.want)
}
})
}
}
1 change: 1 addition & 0 deletions delete/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type Cmd struct {
Project projectCmd `cmd:"" group:"management.nine.ch" name:"project" aliases:"proj" help:"Delete a Project."`
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."`
}

// cleanupFunc is called after the resource has been deleted in order to do
Expand Down
32 changes: 32 additions & 0 deletions delete/mysql.go
Original file line number Diff line number Diff line change
@@ -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 mySQLCmd struct {
Name string `arg:"" help:"Name of the MySQL resource."`
Force bool `default:"false" help:"Do not ask for confirmation of deletion."`
Wait bool `default:"true" help:"Wait until MySQL is fully deleted."`
WaitTimeout time.Duration `default:"300s" help:"Duration to wait for the deletion. Only relevant if wait is set."`
}

func (cmd *mySQLCmd) Run(ctx context.Context, client *api.Client) error {
ctx, cancel := context.WithTimeout(ctx, cmd.WaitTimeout)
defer cancel()

mysql := &storage.MySQL{}
mysqlName := types.NamespacedName{Name: cmd.Name, Namespace: client.Project}
if err := client.Get(ctx, mysqlName, mysql); err != nil {
return fmt.Errorf("unable to get mysql %q: %w", mysql.Name, err)
}

return newDeleter(mysql, storage.MySQLKind).deleteResource(ctx, client, cmd.WaitTimeout, cmd.Wait, cmd.Force)
}
48 changes: 48 additions & 0 deletions delete/mysql_test.go
Original file line number Diff line number Diff line change
@@ -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 TestMySQL(t *testing.T) {
cmd := mySQLCmd{
Name: "test",
Force: true,
Wait: false,
WaitTimeout: time.Second,
}

mysql := test.MySQL("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, mysql); err != nil {
t.Fatalf("mysql create error, got: %s", err)
}
if err := apiClient.Get(ctx, api.ObjectName(mysql), mysql); err != nil {
t.Fatalf("expected mysql to exist, got: %s", err)
}
if err := cmd.Run(ctx, apiClient); err != nil {
t.Fatal(err)
}
err = apiClient.Get(ctx, api.ObjectName(mysql), mysql)
if err == nil {
t.Fatalf("expected mysql to be deleted, but exists")
}
if !errors.IsNotFound(err) {
t.Fatalf("expected mysql to be deleted, got: %s", err.Error())
}
}
16 changes: 16 additions & 0 deletions get/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"os"

"github.com/crossplane/crossplane-runtime/pkg/resource"
"github.com/gobuffalo/flect"
management "github.com/ninech/apis/management/v1alpha1"
"github.com/ninech/nctl/api"
Expand All @@ -23,6 +24,7 @@ type Cmd struct {
Builds buildCmd `cmd:"" group:"deplo.io" name:"builds" aliases:"build" help:"Get deplo.io Builds. (Beta - requires access)"`
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."`
All allCmd `cmd:"" name:"all" help:"Get project content"`

opts []runtimeclient.ListOption
Expand Down Expand Up @@ -140,3 +142,17 @@ func projects(ctx context.Context, client *api.Client, onlyName string) ([]manag
}
return projectList.Items, nil
}

func getConnectionSecret(ctx context.Context, client *api.Client, key string, mg resource.Managed) (string, error) {
secret, err := client.GetConnectionSecret(ctx, mg)
if err != nil {
return "", fmt.Errorf("unable to get connection secret: %w", err)
}

content, ok := secret.Data[key]
if !ok {
return "", fmt.Errorf("secret %s has no key %s", mg.GetName(), key)
}

return string(content), nil
}
Loading
Loading