From db2a9e3795f5f7997e54a60ce098df833307c8af Mon Sep 17 00:00:00 2001 From: "Giau. Tran Minh" <12751435+giautm@users.noreply.github.com> Date: Sat, 12 Oct 2024 15:42:35 +0700 Subject: [PATCH] devdb: render manifest using Go API (#203) --- api/v1alpha1/target.go | 53 ++- .../atlasmigration_controller_test.go | 127 ++++--- .../controller/atlasschema_controller_test.go | 11 +- internal/controller/devdb.go | 347 +++++++++++------- internal/controller/devdb_test.go | 46 --- internal/controller/templates/devdb.tmpl | 89 ----- test/e2e/testscript/schema-mariadb.txtar | 227 ++++++++++++ 7 files changed, 545 insertions(+), 355 deletions(-) delete mode 100644 internal/controller/devdb_test.go delete mode 100644 internal/controller/templates/devdb.tmpl create mode 100644 test/e2e/testscript/schema-mariadb.txtar diff --git a/api/v1alpha1/target.go b/api/v1alpha1/target.go index 9d94dae..5016d29 100644 --- a/api/v1alpha1/target.go +++ b/api/v1alpha1/target.go @@ -145,23 +145,58 @@ func getSecrectValue( return string(val.Data[ref.Key]), nil } +// Driver defines the database driver. +type Driver string + +const ( + DriverPostgres Driver = "postgres" + DriverMySQL Driver = "mysql" + DriverMariaDB Driver = "mariadb" + DriverSQLite Driver = "sqlite" + DriverSQLServer Driver = "sqlserver" +) + // DriverBySchema returns the driver from the given schema. // it remove the schema modifier if present. // e.g. mysql+unix -> mysql // it also handles aliases. // e.g. mariadb -> mysql -func DriverBySchema(schema string) string { +func DriverBySchema(schema string) Driver { p := strings.SplitN(schema, "+", 2) switch drv := strings.ToLower(p[0]); drv { - case "libsql": - return "sqlite" - case "maria", "mariadb": - return "mysql" - case "postgresql": - return "postgres" + case "sqlite", "libsql": + return DriverSQLite + case "mysql": + return DriverMySQL + case "mariadb", "maria": + return DriverMariaDB + case "postgres", "postgresql": + return DriverPostgres case "sqlserver", "azuresql", "mssql": - return "sqlserver" + return DriverSQLServer + default: + panic(fmt.Sprintf("unsupported driver %q", drv)) + } +} + +// String returns the string representation of the driver. +func (d Driver) String() string { + return string(d) +} + +// SchemaBound returns true if the driver requires a schema. +func (d Driver) SchemaBound(u url.URL) bool { + switch d { + case DriverSQLite: + return true + case DriverPostgres: + return u.Query().Get("search_path") != "" + case DriverMySQL, DriverMariaDB: + return u.Path != "" + case DriverSQLServer: + m := u.Query().Get("mode") + return m == "" || strings.ToLower(m) == "schema" default: - return drv + panic(fmt.Sprintf("unsupported driver %q", d)) } } diff --git a/internal/controller/atlasmigration_controller_test.go b/internal/controller/atlasmigration_controller_test.go index d00ef3e..8f7b3f3 100644 --- a/internal/controller/atlasmigration_controller_test.go +++ b/internal/controller/atlasmigration_controller_test.go @@ -44,7 +44,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/ariga/atlas-operator/api/v1alpha1" dbv1alpha1 "github.com/ariga/atlas-operator/api/v1alpha1" "github.com/ariga/atlas-operator/internal/controller/watch" ) @@ -66,8 +65,8 @@ func TestMigration_ConfigMap(t *testing.T) { obj := &dbv1alpha1.AtlasMigration{ ObjectMeta: meta, Spec: dbv1alpha1.AtlasMigrationSpec{ - TargetSpec: v1alpha1.TargetSpec{URL: "sqlite://file2/?mode=memory"}, - Dir: v1alpha1.Dir{ + TargetSpec: dbv1alpha1.TargetSpec{URL: "sqlite://file2/?mode=memory"}, + Dir: dbv1alpha1.Dir{ ConfigMapRef: &corev1.LocalObjectReference{Name: "migrations-dir"}, }, }, @@ -156,8 +155,8 @@ func TestMigration_Local(t *testing.T) { obj := &dbv1alpha1.AtlasMigration{ ObjectMeta: meta, Spec: dbv1alpha1.AtlasMigrationSpec{ - TargetSpec: v1alpha1.TargetSpec{URL: "sqlite://file2/?mode=memory"}, - Dir: v1alpha1.Dir{ + TargetSpec: dbv1alpha1.TargetSpec{URL: "sqlite://file2/?mode=memory"}, + Dir: dbv1alpha1.Dir{ Local: map[string]string{ "20230412003626_create_foo.sql": "CREATE TABLE foo (id INT PRIMARY KEY);", "atlas.sum": `h1:i2OZ2waAoNC0T8LDtu90qFTpbiYcwTNLOrr5YUrq8+g= @@ -189,7 +188,7 @@ func TestMigration_Local(t *testing.T) { h.patch(t, &dbv1alpha1.AtlasMigration{ ObjectMeta: meta, Spec: dbv1alpha1.AtlasMigrationSpec{ - Dir: v1alpha1.Dir{Local: dir}, + Dir: dbv1alpha1.Dir{Local: dir}, }, }) } @@ -265,11 +264,11 @@ func TestMigration_MigrateDown_Remote_Protected(t *testing.T) { obj = &dbv1alpha1.AtlasMigration{ ObjectMeta: meta, Spec: dbv1alpha1.AtlasMigrationSpec{ - TargetSpec: v1alpha1.TargetSpec{ + TargetSpec: dbv1alpha1.TargetSpec{ URL: "sqlite://file?mode=memory", }, - Cloud: v1alpha1.CloudV0{ - TokenFrom: v1alpha1.TokenFrom{ + Cloud: dbv1alpha1.CloudV0{ + TokenFrom: dbv1alpha1.TokenFrom{ SecretKeyRef: &corev1.SecretKeySelector{ Key: "token", LocalObjectReference: corev1.LocalObjectReference{ @@ -278,14 +277,14 @@ func TestMigration_MigrateDown_Remote_Protected(t *testing.T) { }, }, }, - Dir: v1alpha1.Dir{ - Remote: v1alpha1.Remote{ + Dir: dbv1alpha1.Dir{ + Remote: dbv1alpha1.Remote{ Name: "my-dir", Tag: "v1", }, }, }, - Status: v1alpha1.AtlasMigrationStatus{ + Status: dbv1alpha1.AtlasMigrationStatus{ Conditions: []metav1.Condition{ {Type: "Ready", Status: metav1.ConditionFalse}, }, @@ -392,10 +391,10 @@ func TestMigration_MigrateDown_Local(t *testing.T) { obj = &dbv1alpha1.AtlasMigration{ ObjectMeta: meta, Spec: dbv1alpha1.AtlasMigrationSpec{ - TargetSpec: v1alpha1.TargetSpec{ + TargetSpec: dbv1alpha1.TargetSpec{ URL: "sqlite://file?mode=memory", }, - Dir: v1alpha1.Dir{ + Dir: dbv1alpha1.Dir{ Local: map[string]string{ "1.sql": "CREATE TABLE t1 (id INT);", "atlas.sum": `h1:NIfJIuMahN58AEbN26mlFN1UfIH5YYAPLVish2vrYA0= @@ -404,7 +403,7 @@ func TestMigration_MigrateDown_Local(t *testing.T) { }, }, }, - Status: v1alpha1.AtlasMigrationStatus{ + Status: dbv1alpha1.AtlasMigrationStatus{ Conditions: []metav1.Condition{ {Type: "Ready", Status: metav1.ConditionFalse}, }, @@ -631,8 +630,8 @@ func TestReconcile_Transient(t *testing.T) { tt.k8s.put(&dbv1alpha1.AtlasMigration{ ObjectMeta: migrationObjmeta(), Spec: dbv1alpha1.AtlasMigrationSpec{ - TargetSpec: v1alpha1.TargetSpec{ - URLFrom: v1alpha1.Secret{ + TargetSpec: dbv1alpha1.TargetSpec{ + URLFrom: dbv1alpha1.Secret{ SecretKeyRef: &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ Name: "other-secret", @@ -642,7 +641,7 @@ func TestReconcile_Transient(t *testing.T) { }, }, }, - Status: v1alpha1.AtlasMigrationStatus{ + Status: dbv1alpha1.AtlasMigrationStatus{ Conditions: []metav1.Condition{ { Type: "Ready", @@ -677,11 +676,11 @@ func TestReconcile_reconcile(t *testing.T) { tt := migrationCliTest(t) tt.initDefaultMigrationDir() - res := &v1alpha1.AtlasMigration{ + res := &dbv1alpha1.AtlasMigration{ ObjectMeta: migrationObjmeta(), - Spec: v1alpha1.AtlasMigrationSpec{ - TargetSpec: v1alpha1.TargetSpec{URL: tt.dburl}, - Dir: v1alpha1.Dir{ + Spec: dbv1alpha1.AtlasMigrationSpec{ + TargetSpec: dbv1alpha1.TargetSpec{URL: tt.dburl}, + Dir: dbv1alpha1.Dir{ ConfigMapRef: &corev1.LocalObjectReference{Name: "my-configmap"}, }, }, @@ -697,7 +696,7 @@ func TestReconcile_reconciling(t *testing.T) { tt := migrationCliTest(t) am := &dbv1alpha1.AtlasMigration{ ObjectMeta: migrationObjmeta(), - Status: v1alpha1.AtlasMigrationStatus{ + Status: dbv1alpha1.AtlasMigrationStatus{ Conditions: []metav1.Condition{ { Type: "Ready", @@ -705,10 +704,10 @@ func TestReconcile_reconciling(t *testing.T) { }, }, }, - Spec: v1alpha1.AtlasMigrationSpec{ - TargetSpec: v1alpha1.TargetSpec{URL: tt.dburl}, + Spec: dbv1alpha1.AtlasMigrationSpec{ + TargetSpec: dbv1alpha1.TargetSpec{URL: tt.dburl}, EnvName: "test", - Dir: v1alpha1.Dir{ + Dir: dbv1alpha1.Dir{ Local: map[string]string{ "1.sql": "bar", }, @@ -736,12 +735,12 @@ func TestReconcile_reconcile_upToDate(t *testing.T) { }, } tt.k8s.put(res) - md, err := tt.r.extractData(context.Background(), &v1alpha1.AtlasMigration{ + md, err := tt.r.extractData(context.Background(), &dbv1alpha1.AtlasMigration{ ObjectMeta: migrationObjmeta(), - Spec: v1alpha1.AtlasMigrationSpec{ - TargetSpec: v1alpha1.TargetSpec{URL: tt.dburl}, + Spec: dbv1alpha1.AtlasMigrationSpec{ + TargetSpec: dbv1alpha1.TargetSpec{URL: tt.dburl}, EnvName: "test", - Dir: v1alpha1.Dir{ + Dir: dbv1alpha1.Dir{ ConfigMapRef: &corev1.LocalObjectReference{Name: "my-configmap"}, }, }, @@ -758,12 +757,12 @@ func TestReconcile_reconcile_baseline(t *testing.T) { tt.addMigrationScript("20230412003627_create_bar.sql", "CREATE TABLE bar (id INT PRIMARY KEY);") tt.addMigrationScript("20230412003628_create_baz.sql", "CREATE TABLE baz (id INT PRIMARY KEY);") - res := &v1alpha1.AtlasMigration{ + res := &dbv1alpha1.AtlasMigration{ ObjectMeta: migrationObjmeta(), - Spec: v1alpha1.AtlasMigrationSpec{ - TargetSpec: v1alpha1.TargetSpec{URL: tt.dburl}, + Spec: dbv1alpha1.AtlasMigrationSpec{ + TargetSpec: dbv1alpha1.TargetSpec{URL: tt.dburl}, EnvName: "test", - Dir: v1alpha1.Dir{ + Dir: dbv1alpha1.Dir{ ConfigMapRef: &corev1.LocalObjectReference{Name: "my-configmap"}, }, Baseline: "20230412003627", @@ -838,11 +837,11 @@ func TestReconcile_extractMigrationData(t *testing.T) { tt := migrationCliTest(t) tt.initDefaultMigrationDir() - amd, err := tt.r.extractData(context.Background(), &v1alpha1.AtlasMigration{ + amd, err := tt.r.extractData(context.Background(), &dbv1alpha1.AtlasMigration{ ObjectMeta: migrationObjmeta(), - Spec: v1alpha1.AtlasMigrationSpec{ - TargetSpec: v1alpha1.TargetSpec{URL: tt.dburl}, - Dir: v1alpha1.Dir{ + Spec: dbv1alpha1.AtlasMigrationSpec{ + TargetSpec: dbv1alpha1.TargetSpec{URL: tt.dburl}, + Dir: dbv1alpha1.Dir{ ConfigMapRef: &corev1.LocalObjectReference{Name: "my-configmap"}, }, }, @@ -856,14 +855,14 @@ func TestReconcile_extractCloudMigrationData(t *testing.T) { tt := migrationCliTest(t) tt.initDefaultTokenSecret() - amd, err := tt.r.extractData(context.Background(), &v1alpha1.AtlasMigration{ + amd, err := tt.r.extractData(context.Background(), &dbv1alpha1.AtlasMigration{ ObjectMeta: migrationObjmeta(), - Spec: v1alpha1.AtlasMigrationSpec{ - TargetSpec: v1alpha1.TargetSpec{URL: tt.dburl}, - Cloud: v1alpha1.CloudV0{ + Spec: dbv1alpha1.AtlasMigrationSpec{ + TargetSpec: dbv1alpha1.TargetSpec{URL: tt.dburl}, + Cloud: dbv1alpha1.CloudV0{ URL: "https://atlasgo.io/", Project: "my-project", - TokenFrom: v1alpha1.TokenFrom{ + TokenFrom: dbv1alpha1.TokenFrom{ SecretKeyRef: &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ Name: "my-secret", @@ -872,8 +871,8 @@ func TestReconcile_extractCloudMigrationData(t *testing.T) { }, }, }, - Dir: v1alpha1.Dir{ - Remote: v1alpha1.Remote{ + Dir: dbv1alpha1.Dir{ + Remote: dbv1alpha1.Remote{ Name: "my-remote-dir", Tag: "my-remote-tag", }, @@ -895,8 +894,8 @@ func TestReconciler_watch(t *testing.T) { tt.r.watchRefs(&dbv1alpha1.AtlasMigration{ ObjectMeta: migrationObjmeta(), Spec: dbv1alpha1.AtlasMigrationSpec{ - TargetSpec: v1alpha1.TargetSpec{ - URLFrom: v1alpha1.Secret{ + TargetSpec: dbv1alpha1.TargetSpec{ + URLFrom: dbv1alpha1.Secret{ SecretKeyRef: &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ Name: "database-connection", @@ -904,8 +903,8 @@ func TestReconciler_watch(t *testing.T) { }, }, }, - Cloud: v1alpha1.CloudV0{ - TokenFrom: v1alpha1.TokenFrom{ + Cloud: dbv1alpha1.CloudV0{ + TokenFrom: dbv1alpha1.TokenFrom{ SecretKeyRef: &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ Name: "atlas-token", @@ -913,7 +912,7 @@ func TestReconciler_watch(t *testing.T) { }, }, }, - Dir: v1alpha1.Dir{ + Dir: dbv1alpha1.Dir{ ConfigMapRef: &corev1.LocalObjectReference{Name: "migration-directory"}, }, }, @@ -939,7 +938,7 @@ func TestAtlasMigrationReconciler_Credentials(t *testing.T) { tt.k8s.put(&dbv1alpha1.AtlasMigration{ ObjectMeta: migrationObjmeta(), Spec: dbv1alpha1.AtlasMigrationSpec{ - TargetSpec: v1alpha1.TargetSpec{ + TargetSpec: dbv1alpha1.TargetSpec{ Credentials: dbv1alpha1.Credentials{ Scheme: "sqlite", Host: "localhost", @@ -956,7 +955,7 @@ func TestAtlasMigrationReconciler_Credentials(t *testing.T) { }, }, }, - Status: v1alpha1.AtlasMigrationStatus{ + Status: dbv1alpha1.AtlasMigrationStatus{ Conditions: []metav1.Condition{ { Type: "Ready", @@ -1040,7 +1039,7 @@ func TestCloudTemplate(t *testing.T) { Repo: "my-project", Token: "my-token", }, - RemoteDir: &v1alpha1.Remote{ + RemoteDir: &dbv1alpha1.Remote{ Name: "my-remote-dir", Tag: "my-remote-tag", }, @@ -1103,7 +1102,7 @@ func TestMigrationWithDeploymentContext(t *testing.T) { am.Spec.Cloud.URL = srv.URL am.Spec.Dir.Remote.Name = "my-remote-dir" am.Spec.Cloud.Project = "my-project" - am.Spec.Cloud.TokenFrom = v1alpha1.TokenFrom{ + am.Spec.Cloud.TokenFrom = dbv1alpha1.TokenFrom{ SecretKeyRef: &corev1.SecretKeySelector{ LocalObjectReference: corev1.LocalObjectReference{ Name: "my-secret", @@ -1209,15 +1208,15 @@ func (t *migrationTest) initDefaultAtlasMigration() { t.initDefaultMigrationDir() t.initDefaultTokenSecret() t.k8s.put( - &v1alpha1.AtlasMigration{ + &dbv1alpha1.AtlasMigration{ ObjectMeta: migrationObjmeta(), - Spec: v1alpha1.AtlasMigrationSpec{ - TargetSpec: v1alpha1.TargetSpec{URL: t.dburl}, - Dir: v1alpha1.Dir{ + Spec: dbv1alpha1.AtlasMigrationSpec{ + TargetSpec: dbv1alpha1.TargetSpec{URL: t.dburl}, + Dir: dbv1alpha1.Dir{ ConfigMapRef: &corev1.LocalObjectReference{Name: "my-configmap"}, }, }, - Status: v1alpha1.AtlasMigrationStatus{ + Status: dbv1alpha1.AtlasMigrationStatus{ Conditions: []metav1.Condition{ { Type: "Ready", @@ -1245,13 +1244,13 @@ func (t *migrationTest) initDefaultMigrationDir() { ) } -func (t *migrationTest) getAtlasMigration() *v1alpha1.AtlasMigration { - return &v1alpha1.AtlasMigration{ +func (t *migrationTest) getAtlasMigration() *dbv1alpha1.AtlasMigration { + return &dbv1alpha1.AtlasMigration{ ObjectMeta: migrationObjmeta(), - Spec: v1alpha1.AtlasMigrationSpec{ - TargetSpec: v1alpha1.TargetSpec{URL: t.dburl}, + Spec: dbv1alpha1.AtlasMigrationSpec{ + TargetSpec: dbv1alpha1.TargetSpec{URL: t.dburl}, }, - Status: v1alpha1.AtlasMigrationStatus{ + Status: dbv1alpha1.AtlasMigrationStatus{ Conditions: []metav1.Condition{ { Type: "Ready", diff --git a/internal/controller/atlasschema_controller_test.go b/internal/controller/atlasschema_controller_test.go index 21f7d1e..3bbbb3f 100644 --- a/internal/controller/atlasschema_controller_test.go +++ b/internal/controller/atlasschema_controller_test.go @@ -40,7 +40,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - "github.com/ariga/atlas-operator/api/v1alpha1" dbv1alpha1 "github.com/ariga/atlas-operator/api/v1alpha1" "github.com/ariga/atlas-operator/internal/controller/watch" ) @@ -78,7 +77,7 @@ func TestReconcile_ReadyButDiff(t *testing.T) { ObjectMeta: objmeta(), Spec: dbv1alpha1.AtlasSchemaSpec{ Schema: dbv1alpha1.Schema{SQL: "create table foo (id int primary key);"}, - TargetSpec: v1alpha1.TargetSpec{ + TargetSpec: dbv1alpha1.TargetSpec{ URL: "mysql://root:password@localhost:3306/test", }, }, @@ -132,7 +131,7 @@ func TestReconcile_Reconcile(t *testing.T) { h.patch(t, &dbv1alpha1.AtlasSchema{ ObjectMeta: meta, Spec: dbv1alpha1.AtlasSchemaSpec{ - TargetSpec: v1alpha1.TargetSpec{URL: "sqlite://file2/?mode=memory"}, + TargetSpec: dbv1alpha1.TargetSpec{URL: "sqlite://file2/?mode=memory"}, }, }) // Third reconcile, return error for missing schema @@ -141,7 +140,7 @@ func TestReconcile_Reconcile(t *testing.T) { h.patch(t, &dbv1alpha1.AtlasSchema{ ObjectMeta: meta, Spec: dbv1alpha1.AtlasSchemaSpec{ - TargetSpec: v1alpha1.TargetSpec{URL: "sqlite://file2/?mode=memory"}, + TargetSpec: dbv1alpha1.TargetSpec{URL: "sqlite://file2/?mode=memory"}, Schema: dbv1alpha1.Schema{SQL: "CREATE TABLE foo(id INT PRIMARY KEY);"}, }, }) @@ -151,7 +150,7 @@ func TestReconcile_Reconcile(t *testing.T) { h.patch(t, &dbv1alpha1.AtlasSchema{ ObjectMeta: meta, Spec: dbv1alpha1.AtlasSchemaSpec{ - TargetSpec: v1alpha1.TargetSpec{URL: "sqlite://file2/?mode=memory"}, + TargetSpec: dbv1alpha1.TargetSpec{URL: "sqlite://file2/?mode=memory"}, Schema: dbv1alpha1.Schema{SQL: "CREATE TABLE foo(id INT PRIMARY KEY, c1 INT NULL);"}, }, }) @@ -489,7 +488,7 @@ func conditionReconciling() *dbv1alpha1.AtlasSchema { return &dbv1alpha1.AtlasSchema{ ObjectMeta: objmeta(), Spec: dbv1alpha1.AtlasSchemaSpec{ - TargetSpec: v1alpha1.TargetSpec{ + TargetSpec: dbv1alpha1.TargetSpec{ URL: "sqlite://file?mode=memory", }, Schema: dbv1alpha1.Schema{SQL: "CREATE TABLE foo (id INT PRIMARY KEY);"}, diff --git a/internal/controller/devdb.go b/internal/controller/devdb.go index 6bc4142..3fe24a9 100644 --- a/internal/controller/devdb.go +++ b/internal/controller/devdb.go @@ -15,15 +15,12 @@ package controller import ( - "bytes" "context" "errors" "fmt" - "io" "net/url" "os" "slices" - "strings" "time" appsv1 "k8s.io/api/apps/v1" @@ -32,7 +29,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/client-go/tools/record" "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" @@ -47,6 +43,12 @@ const ( labelInstance = "app.kubernetes.io/instance" ) +const ( + ReasonCreatedDevDB = "CreatedDevDB" + ReasonCleanUpDevDB = "CleanUpDevDB" + ReasonScaledUpDevDB = "ScaledUpDevDB" +) + type ( // TODO: Refactor this to a separate controller devDBReconciler struct { @@ -57,6 +59,8 @@ type ( } ) +var errWaitDevDB = transientAfter(errors.New("waiting for dev database to be ready"), 15*time.Second) + func newDevDB(mgr Manager, r record.EventRecorder, prewarm bool) *devDBReconciler { if r == nil { // Only create a new recorder if it is not provided. @@ -79,12 +83,12 @@ func (r *devDBReconciler) cleanUp(ctx context.Context, sc client.Object) { deploy := &appsv1.Deployment{} err := r.Get(ctx, key, deploy) if err != nil { - r.recorder.Eventf(sc, corev1.EventTypeWarning, "CleanUpDevDB", "Error getting devDB deployment: %v", err) + r.recorder.Eventf(sc, corev1.EventTypeWarning, ReasonCleanUpDevDB, "Error getting devDB deployment: %v", err) return } - deploy.Spec.Replicas = new(int32) + deploy.Spec.Replicas = ptr.To[int32](0) if err := r.Update(ctx, deploy); err != nil { - r.recorder.Eventf(sc, corev1.EventTypeWarning, "CleanUpDevDB", "Error scaling down devDB deployment: %v", err) + r.recorder.Eventf(sc, corev1.EventTypeWarning, ReasonCleanUpDevDB, "Error scaling down devDB deployment: %v", err) } return } @@ -96,12 +100,12 @@ func (r *devDBReconciler) cleanUp(ctx context.Context, sc client.Object) { labelInstance: key.Name, }), ); err != nil { - r.recorder.Eventf(sc, corev1.EventTypeWarning, "CleanUpDevDB", "Error listing devDB pods: %v", err) + r.recorder.Eventf(sc, corev1.EventTypeWarning, ReasonCleanUpDevDB, "Error listing devDB pods: %v", err) return } for _, p := range pods.Items { if err := r.Delete(ctx, &p); err != nil { - r.recorder.Eventf(sc, corev1.EventTypeWarning, "CleanUpDevDB", "Error deleting devDB pod %s: %v", p.Name, err) + r.recorder.Eventf(sc, corev1.EventTypeWarning, ReasonCleanUpDevDB, "Error deleting devDB pod %s: %v", p.Name, err) } } } @@ -110,27 +114,25 @@ func (r *devDBReconciler) cleanUp(ctx context.Context, sc client.Object) { // It creates a dev database if it does not exist. func (r *devDBReconciler) devURL(ctx context.Context, sc client.Object, targetURL url.URL) (string, error) { drv := dbv1alpha1.DriverBySchema(targetURL.Scheme) - if drv == "sqlite" { + if drv == dbv1alpha1.DriverSQLite { return "sqlite://db?mode=memory", nil } // make sure we have a dev db running key := nameDevDB(sc) deploy := &appsv1.Deployment{} switch err := r.Get(ctx, key, deploy); { - case err == nil: - // The dev database already exists, + // The dev database already exists, + case err == nil && (deploy.Spec.Replicas == nil || *deploy.Spec.Replicas == 0): // If it is scaled down, scale it up. - if deploy.Spec.Replicas == nil || *deploy.Spec.Replicas == 0 { - deploy.Spec.Replicas = ptr.To[int32](1) - if err := r.Update(ctx, deploy); err != nil { - return "", transient(err) - } - r.recorder.Eventf(sc, corev1.EventTypeNormal, "ScaledUpDevDB", "Scaled up dev database deployment: %s", deploy.Name) - return "", transientAfter(errors.New("waiting for dev database to be ready"), 15*time.Second) + deploy.Spec.Replicas = ptr.To[int32](1) + if err := r.Update(ctx, deploy); err != nil { + return "", transient(err) } + r.recorder.Eventf(sc, corev1.EventTypeNormal, ReasonScaledUpDevDB, "Scaled up dev database deployment: %s", deploy.Name) + return "", errWaitDevDB + // The dev database does not exist, create it. case apierrors.IsNotFound(err): - // The dev database does not exist, create it. - deploy, err := deploymentDevDB(key, drv, isSchemaBound(drv, &targetURL)) + deploy, err := deploymentDevDB(key, targetURL) if err != nil { return "", err } @@ -142,30 +144,26 @@ func (r *devDBReconciler) devURL(ctx context.Context, sc client.Object, targetUR if err := r.Create(ctx, deploy); err != nil { return "", transient(err) } - r.recorder.Eventf(sc, corev1.EventTypeNormal, "CreatedDevDB", "Created dev database deployment: %s", deploy.Name) - return "", transientAfter(errors.New("waiting for dev database to be ready"), 15*time.Second) - default: - // An error occurred while getting the dev database, - return "", err + r.recorder.Eventf(sc, corev1.EventTypeNormal, ReasonCreatedDevDB, "Created dev database deployment: %s", key.Name) + return "", errWaitDevDB + // An error occurred while getting the dev database, + case err != nil: + return "", transient(err) } pods := &corev1.PodList{} - switch err := r.List(ctx, pods, + if err := r.List(ctx, pods, client.InNamespace(key.Namespace), client.MatchingLabels(map[string]string{ - labelEngine: drv, + labelEngine: drv.String(), labelInstance: key.Name, }), - ); { - case err != nil: + ); err != nil { return "", transient(err) - case len(pods.Items) == 0: - return "", transient(errors.New("no pods found")) } - idx := slices.IndexFunc(pods.Items, isPodReady) - if idx == -1 { - return "", transient(errors.New("no running pods found")) + pod, err := readyPod(pods.Items) + if err != nil { + return "", transient(err) } - pod := pods.Items[idx] if conn, ok := pod.Annotations[annoConnTmpl]; ok { u, err := url.Parse(conn) if err != nil { @@ -183,106 +181,190 @@ func (r *devDBReconciler) devURL(ctx context.Context, sc client.Object, targetUR return "", errors.New("no connection template annotation found") } -func isPodReady(pod corev1.Pod) bool { - return slices.ContainsFunc(pod.Status.Conditions, func(c corev1.PodCondition) bool { - return c.Type == corev1.PodReady && c.Status == corev1.ConditionTrue - }) -} - -// devDB contains values used to render a devDB pod template. -type devDB struct { - types.NamespacedName - Driver string - User string - Pass string - Port int - UID int - DB string - SchemaBound bool -} - -// connTmpl returns a connection template for the devDB. -func (d *devDB) connTmpl() string { - u := url.URL{ - Scheme: d.Driver, - User: url.UserPassword(d.User, d.Pass), - Host: fmt.Sprintf("localhost:%d", d.Port), - Path: d.DB, +// deploymentDevDB returns a deployment for a dev database. +func deploymentDevDB(key types.NamespacedName, targetURL url.URL) (*appsv1.Deployment, error) { + drv := dbv1alpha1.DriverBySchema(targetURL.Scheme) + var ( + user string + pass string + path string + q = url.Values{} + ) + c := corev1.Container{ + Name: drv.String(), + ReadinessProbe: &corev1.Probe{ + InitialDelaySeconds: 10, + PeriodSeconds: 5, + TimeoutSeconds: 5, + }, + SecurityContext: &corev1.SecurityContext{ + RunAsNonRoot: ptr.To(true), + AllowPrivilegeEscalation: ptr.To(false), + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + }, + }, } - q := u.Query() - switch { - case d.Driver == "postgres": + switch drv { + case dbv1alpha1.DriverPostgres: + // URLs + user, pass, path = "root", "pass", "postgres" q.Set("sslmode", "disable") - case d.Driver == "sqlserver": - q.Set("database", d.DB) - if !d.SchemaBound { + // Containers + c.Image = "postgres:latest" + c.Ports = []corev1.ContainerPort{ + {Name: drv.String(), ContainerPort: 5432}, + } + c.ReadinessProbe.Exec = &corev1.ExecAction{ + Command: []string{"pg_isready"}, + } + c.Env = []corev1.EnvVar{ + {Name: "POSTGRES_DB", Value: path}, + {Name: "POSTGRES_USER", Value: user}, + {Name: "POSTGRES_PASSWORD", Value: pass}, + } + c.SecurityContext.RunAsUser = ptr.To[int64](999) + case dbv1alpha1.DriverSQLServer: + // URLs + user, pass, path = "sa", "P@ssw0rd0995", "" + q.Set("database", "master") + if !drv.SchemaBound(targetURL) { q.Set("mode", "DATABASE") } - } - u.RawQuery = q.Encode() - - return u.String() -} - -func (d *devDB) render(w io.Writer) error { - return tmpl.ExecuteTemplate(w, "devdb.tmpl", d) -} - -// deploymentDevDB returns a deployment for a dev database. -func deploymentDevDB(name types.NamespacedName, drv string, schemaBound bool) (*appsv1.Deployment, error) { - v := &devDB{ - Driver: drv, - NamespacedName: name, - User: "root", - Pass: "pass", - SchemaBound: schemaBound, - } - switch drv { - case "postgres": - v.DB = "postgres" - v.Port = 5432 - v.UID = 999 - case "mysql": - if schemaBound { - v.DB = "dev" + // Containers + c.Image = "mcr.microsoft.com/mssql/server:2022-latest" + c.Ports = []corev1.ContainerPort{ + {Name: drv.String(), ContainerPort: 1433}, } - v.Port = 3306 - v.UID = 1000 - case "sqlserver": - v.User = "sa" - v.Pass = "P@ssw0rd0995" - v.DB = "master" - v.Port = 1433 - default: - return nil, fmt.Errorf("unsupported driver %q", v.Driver) - } - b := &bytes.Buffer{} - if err := v.render(b); err != nil { - return nil, err - } - d := &appsv1.Deployment{} - if err := yaml.NewYAMLToJSONDecoder(b).Decode(d); err != nil { - return nil, err - } - d.Spec.Template.Annotations = map[string]string{ - annoConnTmpl: v.connTmpl(), - } - if drv == "sqlserver" { - c := &d.Spec.Template.Spec.Containers[0] - if v := os.Getenv("MSSQL_ACCEPT_EULA"); v != "" { + c.ReadinessProbe.Exec = &corev1.ExecAction{ + Command: []string{ + "/opt/mssql-tools18/bin/sqlcmd", + "-C", "-Q", "SELECT 1", + "-U", user, "-P", pass, + }, + } + c.Env = []corev1.EnvVar{ + {Name: "MSSQL_SA_PASSWORD", Value: pass}, + {Name: "MSSQL_PID", Value: os.Getenv("MSSQL_PID")}, + {Name: "ACCEPT_EULA", Value: os.Getenv("MSSQL_ACCEPT_EULA")}, + } + c.SecurityContext.RunAsUser = ptr.To[int64](10001) + c.SecurityContext.Capabilities.Add = []corev1.Capability{ + // The --cap-add NET_BIND_SERVICE flag is required for non-root SQL Server + // containers to allow `sqlservr` to bind the default MSDTC RPC on port `135` + // which is less than 1024. + "NET_BIND_SERVICE", + // The --cap-add SYS_PTRACE flag is required for non-root SQL Server + // containers to generate dumps for troubleshooting purposes. + "SYS_PTRACE", + } + case dbv1alpha1.DriverMySQL: + // URLs + user, pass, path = "root", "pass", "" + // Containers + c.Image = "mysql:latest" + c.Ports = []corev1.ContainerPort{ + {Name: drv.String(), ContainerPort: 3306}, + } + c.ReadinessProbe.Exec = &corev1.ExecAction{ + Command: []string{ + "mysql", + "-h", "127.0.0.1", + "-e", "SELECT 1", + "-u", user, "-p" + pass, + }, + } + c.Env = []corev1.EnvVar{ + {Name: "MYSQL_ROOT_PASSWORD", Value: pass}, + } + if drv.SchemaBound(targetURL) { + path = "dev" c.Env = append(c.Env, corev1.EnvVar{ - Name: "ACCEPT_EULA", - Value: v, + Name: "MYSQL_DATABASE", Value: path, }) } - if v := os.Getenv("MSSQL_PID"); v != "" { + c.SecurityContext.RunAsUser = ptr.To[int64](1000) + case dbv1alpha1.DriverMariaDB: + // URLs + user, pass, path = "root", "pass", "" + // Containers + c.Image = "mariadb:latest" + c.Ports = []corev1.ContainerPort{ + {Name: drv.String(), ContainerPort: 3306}, + } + c.ReadinessProbe.Exec = &corev1.ExecAction{ + Command: []string{ + "mariadb", + "-h", "127.0.0.1", + "-e", "SELECT 1", + "-u", user, "-p" + pass, + }, + } + c.Env = []corev1.EnvVar{ + {Name: "MARIADB_ROOT_PASSWORD", Value: pass}, + } + if drv.SchemaBound(targetURL) { + path = "dev" c.Env = append(c.Env, corev1.EnvVar{ - Name: "MSSQL_PID", - Value: v, + Name: "MARIADB_DATABASE", Value: path, }) } + c.SecurityContext.RunAsUser = ptr.To[int64](999) + default: + return nil, fmt.Errorf("unsupported driver %q", drv) + } + conn := &url.URL{ + Scheme: c.Ports[0].Name, + User: url.UserPassword(user, pass), + Host: fmt.Sprintf("localhost:%d", c.Ports[0].ContainerPort), + Path: path, + RawQuery: q.Encode(), } - return d, nil + labels := map[string]string{ + labelEngine: drv.String(), + labelInstance: key.Name, + "app.kubernetes.io/name": "atlas-dev-db", + "app.kubernetes.io/part-of": "atlas-operator", + "app.kubernetes.io/created-by": "controller-manager", + } + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.Name, + Namespace: key.Namespace, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: labels, + }, + Replicas: ptr.To[int32](1), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + annoConnTmpl: conn.String(), + }, + Labels: labels, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{c}, + }, + }, + }, + }, nil +} + +func readyPod(pods []corev1.Pod) (*corev1.Pod, error) { + if len(pods) == 0 { + return nil, errors.New("no pods found") + } + idx := slices.IndexFunc(pods, func(p corev1.Pod) bool { + return slices.ContainsFunc(p.Status.Conditions, func(c corev1.PodCondition) bool { + return c.Type == corev1.PodReady && c.Status == corev1.ConditionTrue + }) + }) + if idx == -1 { + return nil, errors.New("no running pods found") + } + return &pods[idx], nil } // nameDevDB returns the namespaced name of the dev database. @@ -292,20 +374,3 @@ func nameDevDB(owner metav1.Object) types.NamespacedName { Namespace: owner.GetNamespace(), } } - -// isSchemaBound returns true if the given target URL is schema bound. -// e.g. sqlite, postgres with search_path, mysql with path -func isSchemaBound(drv string, u *url.URL) bool { - switch drv { - case "sqlite": - return true - case "postgres": - return u.Query().Get("search_path") != "" - case "mysql": - return u.Path != "" - case "sqlserver": - m := u.Query().Get("mode") - return m == "" || strings.ToLower(m) == "schema" - } - return false -} diff --git a/internal/controller/devdb_test.go b/internal/controller/devdb_test.go deleted file mode 100644 index 3cb1f38..0000000 --- a/internal/controller/devdb_test.go +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright 2023 The Atlas Operator Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package controller - -import ( - "bytes" - "testing" - - "github.com/stretchr/testify/require" - appsv1 "k8s.io/api/apps/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/yaml" -) - -func TestTemplateSanity(t *testing.T) { - var b bytes.Buffer - v := &devDB{ - NamespacedName: types.NamespacedName{ - Name: "test", - Namespace: "default", - }, - } - for _, tt := range []string{"mysql", "postgres", "sqlserver"} { - t.Run(tt, func(t *testing.T) { - v.Driver = tt - err := tmpl.ExecuteTemplate(&b, "devdb.tmpl", v) - require.NoError(t, err) - var d appsv1.Deployment - err = yaml.NewYAMLToJSONDecoder(&b).Decode(&d) - require.NoError(t, err) - b.Reset() - }) - } -} diff --git a/internal/controller/templates/devdb.tmpl b/internal/controller/templates/devdb.tmpl deleted file mode 100644 index 7ac3eab..0000000 --- a/internal/controller/templates/devdb.tmpl +++ /dev/null @@ -1,89 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ .Name }} - namespace: {{ .Namespace }} -spec: - selector: - matchLabels: - "app.kubernetes.io/name": "atlas-dev-db" - "app.kubernetes.io/instance": "{{ .Name }}" - "app.kubernetes.io/part-of": "atlas-operator" - "app.kubernetes.io/created-by": "controller-manager" - "atlasgo.io/engine": "{{ .Driver }}" - replicas: 1 - template: - metadata: - labels: - "app.kubernetes.io/name": "atlas-dev-db" - "app.kubernetes.io/instance": "{{ .Name }}" - "app.kubernetes.io/part-of": "atlas-operator" - "app.kubernetes.io/created-by": "controller-manager" - "atlasgo.io/engine": "{{ .Driver }}" - spec: - {{- if ne .Driver "sqlserver" }} - securityContext: - runAsNonRoot: true - runAsUser: {{ .UID }} - {{- end }} - containers: - - name: {{ .Driver }} - {{- if ne .Driver "sqlserver" }} - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - {{- end }} - {{- if eq .Driver "mysql" }} - image: mysql:8 - env: - - name: MYSQL_ROOT_PASSWORD - value: pass - {{- if .SchemaBound }} - - name: MYSQL_DATABASE - value: {{ .DB }} - {{- end }} - readinessProbe: - initialDelaySeconds: 5 - periodSeconds: 5 - timeoutSeconds: 5 - exec: - command: [ - "mysql", "-u", "root", "-h", "127.0.0.1", "-ppass", "-e", "SELECT 1" - ] - {{- else if eq .Driver "postgres" }} - image: postgres:15 - env: - - name: POSTGRES_DB - value: {{ .DB }} - - name: POSTGRES_PASSWORD - value: pass - - name: POSTGRES_USER - value: root - readinessProbe: - initialDelaySeconds: 5 - periodSeconds: 5 - timeoutSeconds: 5 - exec: - command: [ "pg_isready" ] - {{- else if eq .Driver "sqlserver" }} - image: mcr.microsoft.com/mssql/server:2022-latest - env: - - name: MSSQL_SA_PASSWORD - value: {{ .Pass }} - readinessProbe: - initialDelaySeconds: 10 - periodSeconds: 5 - timeoutSeconds: 5 - exec: - command: [ - "/opt/mssql-tools18/bin/sqlcmd", - "-C", "-U", "sa", "-P", "{{ .Pass }}", - "-Q", "SELECT 1" - ] - {{- end }} - ports: - - containerPort: {{ .Port }} - name: {{ .Driver }} - diff --git a/test/e2e/testscript/schema-mariadb.txtar b/test/e2e/testscript/schema-mariadb.txtar new file mode 100644 index 0000000..56b4d79 --- /dev/null +++ b/test/e2e/testscript/schema-mariadb.txtar @@ -0,0 +1,227 @@ +env DB_URL=mariadb://root:pass@mariadb.${NAMESPACE}:3306/myapp +kubectl apply -f database.yaml +kubectl create secret generic mariadb-credentials --from-literal=url=${DB_URL} +# Wait for the DB ready before creating the schema +kubectl wait --for=condition=ready --timeout=60s -l app=mariadb pods + +# Sync the $WORK directory to the controller pod +kubectl cp -n ${CONTROLLER_NS} ${WORK} ${CONTROLLER}:/tmp/${NAMESPACE}/ +env DEV_URL=mariadb://root:pass@mariadb.${NAMESPACE}:3307/myapp +# Create a table not described in the desired schema but excluded from it. +atlas schema apply --auto-approve --dev-url=${DEV_URL} --url=${DB_URL} --to=file:///tmp/${NAMESPACE}/ignore.sql + +# Create the configmap to store the schema.sql +kubectl create configmap mariadb-schema --from-file=./schema-v1 --dry-run=client -o yaml +stdin stdout +kubectl apply -f - + +# Create the schema +kubectl apply -f schema.yaml +kubectl wait --for=condition=ready --timeout=120s AtlasSchema/atlasschema-mariadb + +# Inspect the schema to ensure it's correct +atlas schema inspect -u ${DB_URL} +cmp stdout schema-v1.hcl + +# Update the configmap with the new schema +kubectl create configmap mariadb-schema --from-file=./schema-v2 --dry-run=client -o yaml +stdin stdout +kubectl apply -f - + +# Ensure the controller is aware of the change +kubectl wait --for=condition=ready=false --timeout=60s AtlasSchema/atlasschema-mariadb +kubectl wait --for=condition=ready --timeout=120s AtlasSchema/atlasschema-mariadb + +# Inspect the schema to ensure it's correct +atlas schema inspect -u ${DB_URL} +cmp stdout schema-v2.hcl +-- schema-v1.hcl -- +table "ignore_me" { + schema = schema.myapp + column "c" { + null = true + type = int + } +} +table "users" { + schema = schema.myapp + column "id" { + null = false + type = int + auto_increment = true + } + column "name" { + null = false + type = varchar(255) + } + column "email" { + null = false + type = varchar(255) + } + column "short_bio" { + null = false + type = varchar(255) + } + primary_key { + columns = [column.id] + } + index "email" { + unique = true + columns = [column.email] + } +} +schema "myapp" { + charset = "utf8mb4" + collate = "utf8mb4_uca1400_ai_ci" +} +-- schema-v2.hcl -- +table "ignore_me" { + schema = schema.myapp + column "c" { + null = true + type = int + } +} +table "users" { + schema = schema.myapp + column "id" { + null = false + type = int + auto_increment = true + } + column "name" { + null = false + type = varchar(255) + } + column "email" { + null = false + type = varchar(255) + } + column "short_bio" { + null = false + type = varchar(255) + } + column "phone" { + null = false + type = varchar(255) + } + primary_key { + columns = [column.id] + } + index "email" { + unique = true + columns = [column.email] + } +} +schema "myapp" { + charset = "utf8mb4" + collate = "utf8mb4_uca1400_ai_ci" +} +-- schema-v1/schema.sql -- +create table users ( + id int not null auto_increment, + name varchar(255) not null, + email varchar(255) unique not null, + short_bio varchar(255) not null, + primary key (id) +); +-- schema-v2/schema.sql -- +create table users ( + id int not null auto_increment, + name varchar(255) not null, + email varchar(255) unique not null, + short_bio varchar(255) not null, + phone varchar(255) not null, + primary key (id) +); +-- ignore.sql -- +create table myapp.ignore_me (c int); +-- schema.yaml -- +apiVersion: db.atlasgo.io/v1alpha1 +kind: AtlasSchema +metadata: + name: atlasschema-mariadb +spec: + urlFrom: + secretKeyRef: + key: url + name: mariadb-credentials + policy: + lint: + destructive: + error: true + diff: + skip: + drop_column: true + schema: + configMapKeyRef: + key: schema.sql + name: mariadb-schema + exclude: + - ignore_me +-- database.yaml -- +apiVersion: v1 +kind: Service +metadata: + name: mariadb +spec: + selector: + app: mariadb + ports: + - name: mariadb + port: 3306 + targetPort: mariadb + - name: mariadb-dev + port: 3307 + targetPort: mariadb-dev + type: ClusterIP +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mariadb +spec: + selector: + matchLabels: + app: mariadb + replicas: 1 + template: + metadata: + labels: + app: mariadb + spec: + containers: + - name: mariadb + image: mariadb:latest + env: + - name: MARIADB_ROOT_PASSWORD + value: pass + - name: MARIADB_DATABASE + value: myapp + ports: + - containerPort: 3306 + name: mariadb + readinessProbe: + initialDelaySeconds: 5 + periodSeconds: 2 + timeoutSeconds: 1 + exec: + command: [ "mariadb", "-ppass", "-h", "127.0.0.1", "-e", "SELECT 1" ] + - name: mariadb-dev + image: mariadb:latest + env: + - name: MARIADB_ROOT_PASSWORD + value: pass + - name: MARIADB_DATABASE + value: myapp + - name: MYSQL_TCP_PORT + value: "3307" + ports: + - containerPort: 3307 + name: mariadb-dev + readinessProbe: + initialDelaySeconds: 5 + periodSeconds: 2 + timeoutSeconds: 1 + exec: + command: [ "mariadb", "-ppass", "-h", "127.0.0.1", "-e", "SELECT 1" ]