|
3 | 3 | package integration_tests
|
4 | 4 |
|
5 | 5 | import (
|
6 |
| - "io" |
| 6 | + "context" |
7 | 7 | "os"
|
8 | 8 | "os/exec"
|
9 | 9 | "strings"
|
10 | 10 | "testing"
|
| 11 | + "time" |
| 12 | + |
| 13 | + "github.com/google/uuid" |
| 14 | + appsv1 "k8s.io/api/apps/v1" |
| 15 | + batchv1 "k8s.io/api/batch/v1" |
| 16 | + corev1 "k8s.io/api/core/v1" |
| 17 | + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
| 18 | + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" |
| 19 | + "k8s.io/apimachinery/pkg/runtime/schema" |
| 20 | + "k8s.io/apimachinery/pkg/util/intstr" |
| 21 | + "k8s.io/client-go/dynamic" |
| 22 | + "k8s.io/client-go/kubernetes" |
| 23 | + "k8s.io/client-go/tools/clientcmd" |
11 | 24 | )
|
12 | 25 |
|
13 | 26 | func kubectl(args ...string) *exec.Cmd {
|
14 | 27 | return exec.Command("kubectl", args...)
|
15 | 28 | }
|
16 | 29 |
|
| 30 | +func mapIncludesKeys[T any](m map[string]T, keys ...string) bool { |
| 31 | + for _, key := range keys { |
| 32 | + if _, ok := m[key]; !ok { |
| 33 | + return false |
| 34 | + } |
| 35 | + } |
| 36 | + return true |
| 37 | +} |
| 38 | + |
| 39 | +func deployOperator(kubernetesApiClient *kubernetes.Clientset, name, provider, dsn string) error { |
| 40 | + operatorLabels := map[string]string{ |
| 41 | + "app.kubernetes.io/name": "external-db-operator", |
| 42 | + "app.kubernetes.io/instance": name, |
| 43 | + } |
| 44 | + |
| 45 | + image, operatorImageGiven := os.LookupEnv("OPERATOR_IMAGE") |
| 46 | + if !operatorImageGiven { |
| 47 | + image = "registry.fsrv.services/fsrvcorp/integration/external-db-operator:latest" |
| 48 | + } |
| 49 | + |
| 50 | + deploymentConfiguration := &appsv1.Deployment{ |
| 51 | + ObjectMeta: metav1.ObjectMeta{ |
| 52 | + Name: "external-db-operator-" + name, |
| 53 | + }, |
| 54 | + Spec: appsv1.DeploymentSpec{ |
| 55 | + Selector: &metav1.LabelSelector{ |
| 56 | + MatchLabels: operatorLabels, |
| 57 | + }, |
| 58 | + Template: corev1.PodTemplateSpec{ |
| 59 | + ObjectMeta: metav1.ObjectMeta{ |
| 60 | + Labels: operatorLabels, |
| 61 | + }, |
| 62 | + Spec: corev1.PodSpec{ |
| 63 | + ServiceAccountName: "external-db-operator-sa", |
| 64 | + RestartPolicy: corev1.RestartPolicyAlways, |
| 65 | + InitContainers: []corev1.Container{ |
| 66 | + { |
| 67 | + Name: "wait-for-database", |
| 68 | + Image: "alpine:latest", |
| 69 | + Command: []string{ |
| 70 | + "sh", |
| 71 | + "-c", |
| 72 | + "until getent hosts " + name + ".databases.svc.cluster.local; do echo 'Waiting for database connection...' && sleep 1; done", |
| 73 | + }, |
| 74 | + }, |
| 75 | + }, |
| 76 | + Containers: []corev1.Container{ |
| 77 | + { |
| 78 | + Name: "external-db-operator", |
| 79 | + Image: image, |
| 80 | + ReadinessProbe: &corev1.Probe{ |
| 81 | + InitialDelaySeconds: 5, |
| 82 | + PeriodSeconds: 2, |
| 83 | + ProbeHandler: corev1.ProbeHandler{ |
| 84 | + HTTPGet: &corev1.HTTPGetAction{ |
| 85 | + Path: "/status", |
| 86 | + Port: intstr.FromInt32(8080), |
| 87 | + }, |
| 88 | + }, |
| 89 | + }, |
| 90 | + Env: []corev1.EnvVar{ |
| 91 | + { |
| 92 | + Name: "DATABASE_PROVIDER", |
| 93 | + Value: provider, |
| 94 | + }, |
| 95 | + { |
| 96 | + Name: "DATABASE_DSN", |
| 97 | + Value: dsn, |
| 98 | + }, |
| 99 | + { |
| 100 | + Name: "INSTANCE_NAME", |
| 101 | + Value: name, |
| 102 | + }, |
| 103 | + }, |
| 104 | + }, |
| 105 | + }, |
| 106 | + }, |
| 107 | + }, |
| 108 | + }, |
| 109 | + } |
| 110 | + |
| 111 | + // check if deployment already exists |
| 112 | + _, getDeploymentError := kubernetesApiClient.AppsV1().Deployments("default").Get(context.Background(), deploymentConfiguration.Name, metav1.GetOptions{}) |
| 113 | + if getDeploymentError == nil { |
| 114 | + // deployment already exists, delete it |
| 115 | + deleteDeploymentError := kubernetesApiClient.AppsV1().Deployments("default").Delete(context.Background(), deploymentConfiguration.Name, metav1.DeleteOptions{}) |
| 116 | + if deleteDeploymentError != nil { |
| 117 | + return deleteDeploymentError |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + _, operatorDeployError := kubernetesApiClient.AppsV1().Deployments("default").Create(context.Background(), deploymentConfiguration, metav1.CreateOptions{}) |
| 122 | + |
| 123 | + return operatorDeployError |
| 124 | +} |
| 125 | + |
17 | 126 | func TestDatabase(t *testing.T) {
|
| 127 | + clientConfig, _ := clientcmd.BuildConfigFromFlags("", os.Getenv("KUBECONFIG")) |
| 128 | + kubernetesApiClient := kubernetes.NewForConfigOrDie(clientConfig) |
| 129 | + kubernetesDynamicClient := dynamic.NewForConfigOrDie(clientConfig) |
| 130 | + |
18 | 131 | kubectl("apply", "-f", "../manifests/crd.yaml").Run()
|
19 | 132 | kubectl("apply", "-f", "../manifests/rbac.yaml").Run()
|
20 | 133 |
|
21 |
| - deploymentFile, deploymentFileOpenError := os.Open("../manifests/deployment.yaml") |
22 |
| - if deploymentFileOpenError != nil { |
23 |
| - t.Fatalf("failed to open deployment manifest: %v", deploymentFileOpenError) |
24 |
| - } |
25 |
| - deploymentFileContent, manifestLoadError := io.ReadAll(deploymentFile) |
26 |
| - if manifestLoadError != nil { |
27 |
| - t.Fatalf("failed to load manifest: %v", manifestLoadError) |
| 134 | + databaseNamespace := "databases" |
| 135 | + kubectl("delete", "namespace", databaseNamespace).Run() |
| 136 | + kubectl("create", "namespace", databaseNamespace).Run() |
| 137 | + |
| 138 | + type check struct { |
| 139 | + packages []string |
| 140 | + command string |
28 | 141 | }
|
29 |
| - deploymentManifest := string(deploymentFileContent) |
| 142 | + |
| 143 | + mysqlProviderCheckCommand := "mysql -h $(cat /etc/db-credentials/host) -P $(cat /etc/db-credentials/port) -u $(cat /etc/db-credentials/username) -p$(cat /etc/db-credentials/password) $(cat /etc/db-credentials/database) -e 'SELECT 1'" |
30 | 144 |
|
31 | 145 | for _, testCase := range []struct {
|
32 | 146 | name string
|
33 | 147 | provider string
|
34 | 148 | dsn string
|
| 149 | + check check |
35 | 150 | }{
|
36 | 151 | {
|
37 | 152 | name: "postgres",
|
38 | 153 | provider: "postgres",
|
39 |
| - dsn: "postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable", |
| 154 | + dsn: "postgres://postgres:[email protected]:5432/postgres?sslmode=disable", |
| 155 | + check: check{ |
| 156 | + packages: []string{"postgresql-client"}, |
| 157 | + command: "PGPASSWORD=$(cat /etc/db-credentials/password) psql -h $(cat /etc/db-credentials/host) -p $(cat /etc/db-credentials/port) -U $(cat /etc/db-credentials/username) -d $(cat /etc/db-credentials/database) -c 'SELECT 1'", |
| 158 | + }, |
| 159 | + }, |
| 160 | + { |
| 161 | + name: "mysql", |
| 162 | + provider: "mysql", |
| 163 | + dsn: "root:password@tcp(mysql.databases.svc.cluster.local:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local", |
| 164 | + check: check{ |
| 165 | + packages: []string{"default-mysql-client"}, |
| 166 | + command: mysqlProviderCheckCommand, |
| 167 | + }, |
| 168 | + }, |
| 169 | + { |
| 170 | + name: "mariadb", |
| 171 | + provider: "mysql", |
| 172 | + dsn: "root:password@tcp(mariadb.databases.svc.cluster.local:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local", |
| 173 | + check: check{ |
| 174 | + packages: []string{"default-mysql-client"}, |
| 175 | + command: mysqlProviderCheckCommand, |
| 176 | + }, |
| 177 | + }, |
| 178 | + { |
| 179 | + name: "percona", |
| 180 | + provider: "mysql", |
| 181 | + dsn: "root:password@tcp(percona.databases.svc.cluster.local:3306)/mysql?charset=utf8mb4&parseTime=True&loc=Local", |
| 182 | + check: check{ |
| 183 | + packages: []string{"default-mysql-client"}, |
| 184 | + command: mysqlProviderCheckCommand, |
| 185 | + }, |
40 | 186 | },
|
41 | 187 | } {
|
42 | 188 | t.Run(testCase.name, func(t *testing.T) {
|
43 | 189 | // Deploy test database
|
44 |
| - kubectl("apply", "-f", "../manifests/databases/"+testCase.provider+".yaml").Run() |
45 |
| - |
46 |
| - testCaseDeploymentManifest := strings.Replace(deploymentManifest, "<DSN>", testCase.dsn, -1) |
47 |
| - testCaseDeploymentManifest = strings.Replace(testCaseDeploymentManifest, "<PROVIDER>", testCase.provider, -1) |
48 |
| - apply := kubectl("apply", "-f", "-") |
49 |
| - apply.Stdin = strings.NewReader(testCaseDeploymentManifest) |
50 |
| - if applyOperatorOutput, applyOperatorError := apply.CombinedOutput(); applyOperatorError != nil { |
51 |
| - t.Errorf("failed to apply operator: %v \n %v", applyOperatorError, string(applyOperatorOutput)) |
52 |
| - return |
53 |
| - } else { |
54 |
| - t.Log(string(applyOperatorOutput)) |
55 |
| - } |
| 190 | + t.Run("deploy test database", func(t *testing.T) { |
| 191 | + if deployTestDatabaseOutput, deployTestDatabaseError := kubectl("apply", "-n", databaseNamespace, "-f", "../manifests/databases/"+testCase.name+".yaml").CombinedOutput(); deployTestDatabaseError != nil { |
| 192 | + t.Errorf("failed to deploy test database: %v \n %v", deployTestDatabaseError, string(deployTestDatabaseOutput)) |
| 193 | + return |
| 194 | + } else { |
| 195 | + t.Log(string(deployTestDatabaseOutput)) |
| 196 | + } |
| 197 | + }) |
| 198 | + |
| 199 | + // Deploy operator |
| 200 | + t.Run("deploy operator", func(t *testing.T) { |
| 201 | + deployOperatorError := deployOperator(kubernetesApiClient, testCase.name, testCase.provider, testCase.dsn) |
| 202 | + if deployOperatorError != nil { |
| 203 | + t.Fatalf("failed to deploy operator: %v", deployOperatorError) |
| 204 | + } |
56 | 205 |
|
57 |
| - if output, waitError := kubectl("wait", "--for=condition=Available", "--timeout=1m", "deployment/external-db-operator-"+testCase.provider).CombinedOutput(); waitError != nil { |
58 |
| - t.Errorf("failed to wait for operator: %v \n %v", waitError, string(output)) |
| 206 | + // Wait for operator to be ready |
| 207 | + if output, waitError := kubectl("wait", "--for=condition=Available", "--timeout=1m", "deployment/external-db-operator-"+testCase.name).CombinedOutput(); waitError != nil { |
| 208 | + t.Errorf("failed to wait for operator: %v \n %v", waitError, string(output)) |
| 209 | + return |
| 210 | + } else { |
| 211 | + t.Log(string(output)) |
| 212 | + } |
| 213 | + }) |
| 214 | + |
| 215 | + databaseResourceNamespace := "test-namespace-" + testCase.name |
| 216 | + |
| 217 | + // Create test database |
| 218 | + kubectl("delete", "namespace", databaseResourceNamespace).Run() |
| 219 | + kubectl("create", "namespace", databaseResourceNamespace).Run() |
| 220 | + _, createTestDatabaseError := kubernetesDynamicClient.Resource(schema.GroupVersionResource(metav1.GroupVersionResource{ |
| 221 | + Group: "bonsai-oss.org", |
| 222 | + Version: "v1", |
| 223 | + Resource: "databases", |
| 224 | + })).Namespace(databaseResourceNamespace).Create(context.Background(), &unstructured.Unstructured{ |
| 225 | + Object: map[string]interface{}{ |
| 226 | + "apiVersion": "bonsai-oss.org/v1", |
| 227 | + "kind": "Database", |
| 228 | + "metadata": map[string]interface{}{ |
| 229 | + "name": "demo", |
| 230 | + "labels": map[string]interface{}{ |
| 231 | + "bonsai-oss.org/external-db-operator": testCase.provider + "-" + testCase.name, |
| 232 | + }, |
| 233 | + }, |
| 234 | + }, |
| 235 | + }, metav1.CreateOptions{}) |
| 236 | + |
| 237 | + if createTestDatabaseError != nil { |
| 238 | + t.Errorf("failed to create test database: %v", createTestDatabaseError) |
59 | 239 | return
|
60 |
| - } else { |
61 |
| - t.Log(string(output)) |
62 | 240 | }
|
| 241 | + |
| 242 | + time.Sleep(5 * time.Second) |
| 243 | + t.Run("check secret integrity", func(t *testing.T) { |
| 244 | + secret, secretGetError := kubernetesApiClient. |
| 245 | + CoreV1(). |
| 246 | + Secrets(databaseResourceNamespace). |
| 247 | + Get(context.Background(), "edb-demo", metav1.GetOptions{}) |
| 248 | + if secretGetError != nil { |
| 249 | + t.Errorf("failed to get secret: %v", secretGetError) |
| 250 | + return |
| 251 | + } |
| 252 | + |
| 253 | + if !mapIncludesKeys(secret.Data, "username", "password", "host", "port", "database") { |
| 254 | + t.Errorf("secret does not contain all required keys") |
| 255 | + return |
| 256 | + } |
| 257 | + }) |
| 258 | + |
| 259 | + t.Run("check database integrity", func(t *testing.T) { |
| 260 | + _, jobError := kubernetesApiClient.BatchV1().Jobs(databaseResourceNamespace).Create(context.Background(), &batchv1.Job{ |
| 261 | + ObjectMeta: metav1.ObjectMeta{ |
| 262 | + Name: "db-integrity-check-" + uuid.NewString(), |
| 263 | + }, |
| 264 | + Spec: batchv1.JobSpec{ |
| 265 | + BackoffLimit: func() *int32 { i := int32(0); return &i }(), |
| 266 | + Template: corev1.PodTemplateSpec{ |
| 267 | + Spec: corev1.PodSpec{ |
| 268 | + RestartPolicy: corev1.RestartPolicyNever, |
| 269 | + Volumes: []corev1.Volume{ |
| 270 | + { |
| 271 | + Name: "db-credentials", |
| 272 | + VolumeSource: corev1.VolumeSource{ |
| 273 | + Secret: &corev1.SecretVolumeSource{ |
| 274 | + SecretName: "edb-demo", |
| 275 | + }, |
| 276 | + }, |
| 277 | + }, |
| 278 | + }, |
| 279 | + Containers: []corev1.Container{ |
| 280 | + { |
| 281 | + Name: "db-integrity-check", |
| 282 | + Image: "debian:sid", |
| 283 | + Command: []string{ |
| 284 | + "bash", |
| 285 | + "-xc", |
| 286 | + "(apt update && apt install -y " + strings.Join(testCase.check.packages, " ") + ") >/dev/null 2>&1 && " + testCase.check.command, |
| 287 | + }, |
| 288 | + VolumeMounts: []corev1.VolumeMount{ |
| 289 | + { |
| 290 | + Name: "db-credentials", |
| 291 | + MountPath: "/etc/db-credentials", |
| 292 | + }, |
| 293 | + }, |
| 294 | + }, |
| 295 | + }, |
| 296 | + }, |
| 297 | + }, |
| 298 | + }, |
| 299 | + }, metav1.CreateOptions{}) |
| 300 | + if jobError != nil { |
| 301 | + t.Errorf("failed to create job: %v", jobError) |
| 302 | + return |
| 303 | + } |
| 304 | + |
| 305 | + // check for job completion |
| 306 | + watcher, _ := kubernetesApiClient.BatchV1().Jobs(databaseResourceNamespace).Watch(context.Background(), metav1.ListOptions{ |
| 307 | + Watch: true, |
| 308 | + }) |
| 309 | + defer watcher.Stop() |
| 310 | + |
| 311 | + for event := range watcher.ResultChan() { |
| 312 | + job, _ := event.Object.(*batchv1.Job) |
| 313 | + if len(job.Status.Conditions) == 0 { |
| 314 | + continue |
| 315 | + } |
| 316 | + |
| 317 | + switch job.Status.Conditions[0].Type { |
| 318 | + case batchv1.JobComplete: |
| 319 | + return |
| 320 | + default: |
| 321 | + t.Errorf("job failed: %v", job.Status.Conditions[0].Message) |
| 322 | + return |
| 323 | + } |
| 324 | + } |
| 325 | + }) |
63 | 326 | })
|
64 | 327 | }
|
65 | 328 | }
|
0 commit comments