diff --git a/components/api-server/plugins/gateways/integration_test.go b/components/api-server/plugins/gateways/integration_test.go index 9c290b00..b3bc6f47 100644 --- a/components/api-server/plugins/gateways/integration_test.go +++ b/components/api-server/plugins/gateways/integration_test.go @@ -101,7 +101,7 @@ func TestGatewayPostAllowsEmptyReconcilerOwnedIDs(t *testing.T) { Expect(gatewayOutput.Namespace).To(MatchRegexp(`^openshell-[0-9a-f]{16}$`)) } -func TestGatewayPostRejectsEmptyDatabaseId(t *testing.T) { +func TestGatewayPostAllowsEmptyDatabaseId(t *testing.T) { h, client := test.RegisterIntegration(t) account := h.NewRandAccount() @@ -114,9 +114,10 @@ func TestGatewayPostRejectsEmptyDatabaseId(t *testing.T) { DatabaseId: "", } - _, resp, err := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(gatewayInput).Execute() - Expect(err).To(HaveOccurred()) - Expect(resp.StatusCode).To(Equal(http.StatusBadRequest)) + gatewayOutput, resp, err := client.DefaultAPI.CreateGateway(ctx).GatewayCreateRequest(gatewayInput).Execute() + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(http.StatusCreated)) + Expect(gatewayOutput.DatabaseId).To(BeEmpty()) } func TestGatewayPostWithoutRouteRemainsUnrouted(t *testing.T) { diff --git a/components/api-server/plugins/gateways/service.go b/components/api-server/plugins/gateways/service.go index 8d016670..2d1be35e 100644 --- a/components/api-server/plugins/gateways/service.go +++ b/components/api-server/plugins/gateways/service.go @@ -118,29 +118,24 @@ func (s *sqlGatewayService) Create(ctx context.Context, gateway *Gateway) (*Gate gateway.FleetId = fleetID } - if gateway.DatabaseId == "" { - if s.dbFinder == nil { - return nil, errors.Validation("database_id is required") - } + if gateway.DatabaseId == "" && s.dbFinder != nil { if gateway.FleetId != "" { dbID, findErr := s.dbFinder.FindSoleInFleet(ctx, gateway.FleetId) if findErr != nil { return nil, errors.GeneralError("resolve fleet database: %s", findErr) } - if dbID == "" { - return nil, errors.Validation("database_id is required: fleet %s has zero or multiple ManagedDatabases", gateway.FleetId) + if dbID != "" { + gateway.DatabaseId = dbID } - gateway.DatabaseId = dbID } else { dbID, fleetID, findErr := s.dbFinder.FindSole(ctx) if findErr != nil { return nil, errors.GeneralError("resolve database: %s", findErr) } - if dbID == "" { - return nil, errors.Validation("database_id is required: zero or multiple ManagedDatabases exist") + if dbID != "" { + gateway.DatabaseId = dbID + gateway.FleetId = fleetID } - gateway.DatabaseId = dbID - gateway.FleetId = fleetID } } diff --git a/components/api-server/plugins/gateways/service_test.go b/components/api-server/plugins/gateways/service_test.go new file mode 100644 index 00000000..436f33e3 --- /dev/null +++ b/components/api-server/plugins/gateways/service_test.go @@ -0,0 +1,58 @@ +package gateways + +import ( + "context" + "testing" + + daomocks "github.com/openshift-online/rh-trex-ai/pkg/dao/mocks" + "github.com/openshift-online/rh-trex-ai/pkg/services" +) + +type databaseFinderStub struct { + databaseID string + fleetID string +} + +func (f databaseFinderStub) FindSoleInFleet(context.Context, string) (string, error) { + return f.databaseID, nil +} + +func (f databaseFinderStub) FindSole(context.Context) (string, string, error) { + return f.databaseID, f.fleetID, nil +} + +func TestCreateFallsBackWhenNoSoleManagedDatabaseExists(t *testing.T) { + service := NewGatewayService( + nil, + NewMockGatewayDao(), + services.NewEventService(daomocks.NewEventDao()), + databaseFinderStub{}, + nil, + ) + + created, serviceErr := service.Create(context.Background(), &Gateway{Name: "fallback"}) + if serviceErr != nil { + t.Fatalf("create gateway without a sole ManagedDatabase: %v", serviceErr) + } + if created.DatabaseId != "" { + t.Fatalf("database_id = %q, want blank fallback assignment", created.DatabaseId) + } +} + +func TestCreateStillAutoAssignsSoleManagedDatabase(t *testing.T) { + service := NewGatewayService( + nil, + NewMockGatewayDao(), + services.NewEventService(daomocks.NewEventDao()), + databaseFinderStub{databaseID: "managed-db", fleetID: "fleet-a"}, + nil, + ) + + created, serviceErr := service.Create(context.Background(), &Gateway{Name: "cnpg"}) + if serviceErr != nil { + t.Fatalf("create gateway with a sole ManagedDatabase: %v", serviceErr) + } + if created.DatabaseId != "managed-db" || created.FleetId != "fleet-a" { + t.Fatalf("assignment = database:%q fleet:%q", created.DatabaseId, created.FleetId) + } +} diff --git a/components/control-plane/internal/gateway/config.go b/components/control-plane/internal/gateway/config.go index 0352bc1d..fd366f48 100644 --- a/components/control-plane/internal/gateway/config.go +++ b/components/control-plane/internal/gateway/config.go @@ -179,6 +179,10 @@ type ReconcileOpts struct { CNPG CNPGConfig ControlPlaneNamespace string Images ImageDefaults + // SelfManagedDB provisions the legacy per-gateway PostgreSQL Deployment. It + // is used only for gateways without a database_id; assigned gateways continue + // through the CNPG path. + SelfManagedDB bool // SkipNetworkPolicies disables creation of the per-tenant gateway // NetworkPolicies. On distributions where the shared Gateway data plane // runs out-of-cluster (e.g. cloud-provider-kind's Envoy container in local diff --git a/components/control-plane/internal/gateway/manifests.go b/components/control-plane/internal/gateway/manifests.go index 2cef601e..52851204 100644 --- a/components/control-plane/internal/gateway/manifests.go +++ b/components/control-plane/internal/gateway/manifests.go @@ -110,6 +110,38 @@ func ApplyManifestToNamespace(manifest *unstructured.Unstructured, namespace str return result, nil } +// ApplySelfManagedDatabaseOverrides renders the legacy per-gateway PostgreSQL +// manifest used only when a Gateway has no ManagedDatabase assignment. +func ApplySelfManagedDatabaseOverrides(obj *unstructured.Unstructured, images ImageDefaults) error { + if images == nil { + images = StaticImageDefaults{} + } + + jsonBytes, err := obj.MarshalJSON() + if err != nil { + return fmt.Errorf("marshal for self-managed database overrides: %w", err) + } + manifestJSON := string(jsonBytes) + + dbImage := images.DefaultDatabaseImage() + userKey, passKey, dbKey := selfManagedPostgresEnvKeys(dbImage) + dataPath := selfManagedPostgresDataPath(dbImage) + + // DB_IMAGE_PLACEHOLDER must be replaced before the generic + // IMAGE_PLACEHOLDER substitution because it contains that shorter token. + manifestJSON = strings.ReplaceAll(manifestJSON, "DB_IMAGE_PLACEHOLDER", dbImage) + manifestJSON = strings.ReplaceAll(manifestJSON, "DB_STORAGE_PLACEHOLDER", "5Gi") + manifestJSON = strings.ReplaceAll(manifestJSON, "DB_USER_KEY_PLACEHOLDER", userKey) + manifestJSON = strings.ReplaceAll(manifestJSON, "DB_PASS_KEY_PLACEHOLDER", passKey) + manifestJSON = strings.ReplaceAll(manifestJSON, "DB_NAME_KEY_PLACEHOLDER", dbKey) + manifestJSON = strings.ReplaceAll(manifestJSON, "DB_DATA_PATH_PLACEHOLDER", dataPath) + + if err := obj.UnmarshalJSON([]byte(manifestJSON)); err != nil { + return fmt.Errorf("unmarshal after self-managed database overrides: %w", err) + } + return nil +} + func ApplyConfigOverrides(obj *unstructured.Unstructured, config GatewayConfig, tenantNamespace ...string) error { kind := obj.GetKind() diff --git a/components/control-plane/internal/gateway/manifests_test.go b/components/control-plane/internal/gateway/manifests_test.go index a12813e1..d5d28f59 100644 --- a/components/control-plane/internal/gateway/manifests_test.go +++ b/components/control-plane/internal/gateway/manifests_test.go @@ -7,6 +7,63 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) +func TestSelfManagedDatabaseManifestRenders(t *testing.T) { + t.Setenv("HYPERSHELL_DATABASE_IMAGE", "postgres:18") + + manifests, err := LoadGatewayManifests("../../manifests/gateway") + if err != nil { + t.Fatalf("load gateway manifests: %v", err) + } + resources, ok := manifests["database.yaml"] + if !ok { + t.Fatal("database.yaml was not loaded") + } + if len(resources) != 4 { + t.Fatalf("database.yaml resources = %d, want 4", len(resources)) + } + + seen := make(map[string]bool) + for _, manifest := range resources { + raw := manifest.DeepCopy() + if err := ApplySelfManagedDatabaseOverrides(raw, StaticImageDefaults{}); err != nil { + t.Fatalf("apply database overrides to %s: %v", raw.GetKind(), err) + } + obj, err := ApplyManifestToNamespace(raw, "openshell-test", GatewayConfig{}, StaticImageDefaults{}) + if err != nil { + t.Fatalf("render %s: %v", raw.GetKind(), err) + } + data, err := obj.MarshalJSON() + if err != nil { + t.Fatalf("marshal rendered %s: %v", obj.GetKind(), err) + } + rendered := string(data) + if strings.Contains(rendered, "PLACEHOLDER") { + t.Errorf("rendered %s still contains a placeholder: %s", obj.GetKind(), rendered) + } + if obj.GetNamespace() != "openshell-test" { + t.Errorf("rendered %s namespace = %q, want openshell-test", obj.GetKind(), obj.GetNamespace()) + } + seen[obj.GetKind()] = true + + if obj.GetKind() == "Deployment" { + for _, want := range []string{`"image":"postgres:18"`, `"name":"POSTGRES_USER"`, `"name":"POSTGRES_PASSWORD"`, `"name":"POSTGRES_DB"`} { + if !strings.Contains(rendered, want) { + t.Errorf("rendered database Deployment missing %s", want) + } + } + } + if obj.GetKind() == "PersistentVolumeClaim" && !strings.Contains(rendered, `"storage":"5Gi"`) { + t.Error("rendered database PVC does not request 5Gi") + } + } + + for _, kind := range []string{"PersistentVolumeClaim", "Deployment", "Service", "NetworkPolicy"} { + if !seen[kind] { + t.Errorf("database.yaml missing %s", kind) + } + } +} + func TestApplyCredentialDriverToml_KubernetesSecrets(t *testing.T) { lines := []string{ "[openshell.gateway]", diff --git a/components/control-plane/internal/gateway/reconciler.go b/components/control-plane/internal/gateway/reconciler.go index 62ef8a78..2b46f123 100644 --- a/components/control-plane/internal/gateway/reconciler.go +++ b/components/control-plane/internal/gateway/reconciler.go @@ -84,17 +84,27 @@ func ReconcileGateway( } } - if !opts.HasCNPG { - return fmt.Errorf("CNPG operator is required but not available on the cluster: gateway deployment blocked for namespace %s", nsConfig.Name) - } + if opts.SelfManagedDB { + dbImage := images.DefaultDatabaseImage() + if err := reconcileSelfManagedDatabaseCredentials(ctx, clientset, nsConfig.Name, dbImage); err != nil { + return fmt.Errorf("reconcile self-managed database credentials in %s: %w", nsConfig.Name, err) + } + if opts.RotateDBCredentials != "" { + return fmt.Errorf("credential rotation is not supported for self-managed database fallback in namespace %s", nsConfig.Name) + } + } else { + if !opts.HasCNPG { + return fmt.Errorf("CNPG operator is required but not available on the cluster: gateway deployment blocked for namespace %s", nsConfig.Name) + } - if err := reconcileCNPGDatabaseResources(ctx, dynamicClient, clientset, nsConfig.Name, opts.GatewayID, opts.CNPG); err != nil { - return fmt.Errorf("reconcile CNPG database resources in %s: %w", nsConfig.Name, err) - } + if err := reconcileCNPGDatabaseResources(ctx, dynamicClient, clientset, nsConfig.Name, opts.GatewayID, opts.CNPG); err != nil { + return fmt.Errorf("reconcile CNPG database resources in %s: %w", nsConfig.Name, err) + } - if opts.RotateDBCredentials != "" { - if err := rotateCNPGDatabaseCredentials(ctx, clientset, nsConfig.Name, opts.GatewayID, opts.CNPG, opts.RotateDBCredentials); err != nil { - return fmt.Errorf("rotate database credentials in %s: %w", nsConfig.Name, err) + if opts.RotateDBCredentials != "" { + if err := rotateCNPGDatabaseCredentials(ctx, clientset, nsConfig.Name, opts.GatewayID, opts.CNPG, opts.RotateDBCredentials); err != nil { + return fmt.Errorf("rotate database credentials in %s: %w", nsConfig.Name, err) + } } } @@ -226,7 +236,7 @@ func DeleteGatewayResources( } } - if opts.HasCNPG && opts.GatewayID != "" { + if !opts.SelfManagedDB && opts.HasCNPG && opts.GatewayID != "" { if opts.CNPG.ClusterNamespace == "" { log.Printf("WARN gateway %s: CNPG cluster namespace unknown; Database, DatabaseRole, and password Secret were not deleted and may require manual cleanup", opts.GatewayID) } else { @@ -674,10 +684,14 @@ func deployGateway( "serviceaccount.yaml", "configmap.yaml", "certgen-job.yaml", - "service.yaml", - "deployment.yaml", - "networkpolicy.yaml", } + if opts.SelfManagedDB { + if _, ok := manifests["database.yaml"]; !ok { + return fmt.Errorf("self-managed database manifest file database.yaml not found") + } + order = append(order, "database.yaml") + } + order = append(order, "service.yaml", "deployment.yaml", "networkpolicy.yaml") for _, filename := range order { resources, ok := manifests[filename] @@ -692,7 +706,14 @@ func deployGateway( continue } - obj, err := ApplyManifestToNamespace(manifest.DeepCopy(), nsConfig.Name, nsConfig.Gateway, images) + raw := manifest.DeepCopy() + if filename == "database.yaml" { + if err := ApplySelfManagedDatabaseOverrides(raw, images); err != nil { + return fmt.Errorf("apply self-managed database overrides for %s: %w", filename, err) + } + } + + obj, err := ApplyManifestToNamespace(raw, nsConfig.Name, nsConfig.Gateway, images) if err != nil { return fmt.Errorf("apply substitutions for %s: %w", filename, err) } @@ -719,6 +740,12 @@ func deployGateway( log.Printf("DEBUG reconciled %s %s in %s", obj.GetKind(), obj.GetName(), nsConfig.Name) } + + if filename == "database.yaml" { + if err := waitForDeploymentReady(ctx, clientset, nsConfig.Name, "openshell-gateway-db", 2*time.Minute); err != nil { + return fmt.Errorf("wait for self-managed database in %s: %w", nsConfig.Name, err) + } + } } return nil @@ -836,6 +863,34 @@ func WaitForGatewayReady(ctx context.Context, clientset *kubernetes.Clientset, n } } +func waitForDeploymentReady(ctx context.Context, clientset *kubernetes.Clientset, namespace, name string, timeout time.Duration) error { + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return fmt.Errorf("timed out waiting for deployment %s/%s to become ready", namespace, name) + case <-ticker.C: + deploy, err := clientset.AppsV1().Deployments(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if !k8serrors.IsNotFound(err) { + log.Printf("WARN error checking deployment %s/%s readiness: %v", namespace, name, err) + } + continue + } + if deploy.Spec.Replicas != nil && deploy.Status.ReadyReplicas >= *deploy.Spec.Replicas { + log.Printf("INFO deployment %s/%s is ready", namespace, name) + return nil + } + } + } +} + func reconcileResource(ctx context.Context, dynamicClient dynamic.Interface, obj *unstructured.Unstructured) error { gvk := obj.GroupVersionKind() gvr := schema.GroupVersionResource{ @@ -1499,6 +1554,100 @@ func reconcileKeycloakClient(ctx context.Context, opts ReconcileOpts, nsConfig * return nil } +func reconcileSelfManagedDatabaseCredentials(ctx context.Context, clientset kubernetes.Interface, namespace, dbImage string) error { + const secretName = "openshell-gateway-db-credentials" + existing, err := clientset.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{}) + if err == nil { + if existing.Data == nil { + return fmt.Errorf("existing database credentials secret has no data") + } + + changed := false + if len(existing.Data["uri"]) == 0 && len(existing.Data["url"]) > 0 { + existing.Data["uri"] = append([]byte(nil), existing.Data["url"]...) + changed = true + } + if len(existing.Data["url"]) == 0 && len(existing.Data["uri"]) > 0 { + existing.Data["url"] = append([]byte(nil), existing.Data["uri"]...) + changed = true + } + if len(existing.Data["uri"]) == 0 { + return fmt.Errorf("existing database credentials secret has neither uri nor url") + } + if changed { + if _, err := clientset.CoreV1().Secrets(namespace).Update(ctx, existing, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("add database connection string alias to credentials secret: %w", err) + } + log.Printf("INFO added database connection string alias to credentials secret %s in %s", secretName, namespace) + } else { + log.Printf("DEBUG database credentials secret %s already exists in %s, skipping", secretName, namespace) + } + return nil + } + if !k8serrors.IsNotFound(err) { + return fmt.Errorf("get database credentials secret: %w", err) + } + + passwordBytes := make([]byte, 32) + if _, err := rand.Read(passwordBytes); err != nil { + return fmt.Errorf("generate database password: %w", err) + } + password := hex.EncodeToString(passwordBytes) + + const dbUser = "openshell" + const dbName = "openshell" + const dbHost = "openshell-gateway-db" + dbURI := fmt.Sprintf("postgresql://%s:%s@%s:5432/%s?sslmode=disable", + dbUser, url.QueryEscape(password), dbHost, dbName) + userKey, passKey, dbKey := selfManagedPostgresEnvKeys(dbImage) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/name": "openshell", + "app.kubernetes.io/component": "database", + "app.kubernetes.io/managed-by": "hypershell-control-plane", + "hypershell.redhat.io/managed": "true", + }, + }, + Type: corev1.SecretTypeOpaque, + StringData: map[string]string{ + userKey: dbUser, + passKey: password, + dbKey: dbName, + "uri": dbURI, + "url": dbURI, + }, + } + + if _, err := clientset.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("create database credentials secret: %w", err) + } + + log.Printf("INFO created self-managed database credentials secret %s in %s", secretName, namespace) + return nil +} + +func isSelfManagedRHELPostgres(image string) bool { + return strings.Contains(image, "rhel") && strings.Contains(image, "postgresql-") +} + +func selfManagedPostgresEnvKeys(image string) (userKey, passKey, dbKey string) { + if isSelfManagedRHELPostgres(image) { + return "POSTGRESQL_USER", "POSTGRESQL_PASSWORD", "POSTGRESQL_DATABASE" + } + return "POSTGRES_USER", "POSTGRES_PASSWORD", "POSTGRES_DB" +} + +func selfManagedPostgresDataPath(image string) string { + if isSelfManagedRHELPostgres(image) { + return "/var/lib/pgsql/data" + } + return "/var/lib/postgresql/data" +} + func rotateCNPGDatabaseCredentials( ctx context.Context, clientset *kubernetes.Clientset, diff --git a/components/control-plane/internal/gateway/reconciler_test.go b/components/control-plane/internal/gateway/reconciler_test.go index 774284de..45fb475c 100644 --- a/components/control-plane/internal/gateway/reconciler_test.go +++ b/components/control-plane/internal/gateway/reconciler_test.go @@ -138,6 +138,52 @@ func TestRouteResourcesAbsent(t *testing.T) { }) } +func TestReconcileSelfManagedDatabaseCredentials(t *testing.T) { + const namespace = "openshell-test" + const connection = "postgresql://openshell:secret@openshell-gateway-db:5432/openshell?sslmode=disable" + + t.Run("adds uri alias to a legacy secret without changing credentials", func(t *testing.T) { + client := k8sfake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "openshell-gateway-db-credentials", Namespace: namespace}, + Data: map[string][]byte{ + "url": []byte(connection), + "POSTGRES_PASSWORD": []byte("secret"), + }, + }) + + if err := reconcileSelfManagedDatabaseCredentials(context.Background(), client, namespace, "postgres:18"); err != nil { + t.Fatalf("reconcile credentials: %v", err) + } + got, err := client.CoreV1().Secrets(namespace).Get(context.Background(), "openshell-gateway-db-credentials", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get reconciled secret: %v", err) + } + if string(got.Data["uri"]) != connection || string(got.Data["url"]) != connection { + t.Fatalf("connection aliases = uri:%q url:%q", got.Data["uri"], got.Data["url"]) + } + if string(got.Data["POSTGRES_PASSWORD"]) != "secret" { + t.Fatal("existing password was changed") + } + }) + + t.Run("creates both connection aliases for a new secret", func(t *testing.T) { + client := k8sfake.NewSimpleClientset() + if err := reconcileSelfManagedDatabaseCredentials(context.Background(), client, namespace, "postgres:18"); err != nil { + t.Fatalf("reconcile credentials: %v", err) + } + got, err := client.CoreV1().Secrets(namespace).Get(context.Background(), "openshell-gateway-db-credentials", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get created secret: %v", err) + } + if got.StringData["uri"] == "" || got.StringData["url"] == "" { + t.Fatalf("created secret aliases = uri:%q url:%q", got.StringData["uri"], got.StringData["url"]) + } + if got.StringData["uri"] != got.StringData["url"] { + t.Fatal("created uri and url aliases differ") + } + }) +} + // fakeConsoleClientChecker is a stub ConsoleClientChecker for driving the // Keycloak console-client probe in RouteResourcesAbsent. type fakeConsoleClientChecker struct { diff --git a/components/control-plane/internal/reconciler/managed_database_test.go b/components/control-plane/internal/reconciler/managed_database_test.go index e765f3db..0d3de868 100644 --- a/components/control-plane/internal/reconciler/managed_database_test.go +++ b/components/control-plane/internal/reconciler/managed_database_test.go @@ -63,3 +63,12 @@ func TestCNPGClusterReadyFromObject(t *testing.T) { t.Fatal("nil object should not be ready") } } + +func TestUsesSelfManagedDatabaseOnlyWithoutAssignment(t *testing.T) { + if !usesSelfManagedDatabase(&pb.Gateway{}) { + t.Fatal("gateway without database_id should use the self-managed fallback") + } + if usesSelfManagedDatabase(&pb.Gateway{DatabaseId: "managed-db"}) { + t.Fatal("gateway with database_id must remain on the CNPG path") + } +} diff --git a/components/control-plane/internal/reconciler/reconciler.go b/components/control-plane/internal/reconciler/reconciler.go index f4dd5503..96a0a574 100644 --- a/components/control-plane/internal/reconciler/reconciler.go +++ b/components/control-plane/internal/reconciler/reconciler.go @@ -500,9 +500,14 @@ func (r *GatewayReconciler) Handle(ctx context.Context, event watcher.Event[*pb. return nil } - deleteCNPGConfig, cnpgErr := r.resolveCNPGConfig(ctx, gw) - if cnpgErr != nil { - log.Printf("WARN gateway %s deleted but could not resolve CNPG config: %v; CNPG resources may require manual cleanup", event.ResourceID, cnpgErr) + useSelfManagedDatabase := usesSelfManagedDatabase(gw) + var deleteCNPGConfig gateway.CNPGConfig + if !useSelfManagedDatabase { + var cnpgErr error + deleteCNPGConfig, cnpgErr = r.resolveCNPGConfig(ctx, gw) + if cnpgErr != nil { + log.Printf("WARN gateway %s deleted but could not resolve CNPG config: %v; CNPG resources may require manual cleanup", event.ResourceID, cnpgErr) + } } log.Printf("INFO gateway %s deleted, cleaning up resources in namespace %s", event.ResourceID, namespace) opts := gateway.ReconcileOpts{ @@ -512,6 +517,7 @@ func (r *GatewayReconciler) Handle(ctx context.Context, event watcher.Event[*pb. SkipNetworkPolicies: r.skipNetworkPolicies, HasCNPG: r.hasCNPG, CNPG: deleteCNPGConfig, + SelfManagedDB: useSelfManagedDatabase, ControlPlaneNamespace: r.controlPlaneNamespace, KeycloakClient: r.keycloakClient, GatewayID: event.ResourceID, @@ -568,9 +574,14 @@ func (r *GatewayReconciler) Handle(ctx context.Context, event watcher.Event[*pb. return nil } - cnpgConfig, resolveErr := r.resolveCNPGConfig(ctx, gw) - if resolveErr != nil { - return fmt.Errorf("resolve CNPG config for gateway %s: %w", gw.Name, resolveErr) + useSelfManagedDatabase := usesSelfManagedDatabase(gw) + var cnpgConfig gateway.CNPGConfig + if !useSelfManagedDatabase { + var resolveErr error + cnpgConfig, resolveErr = r.resolveCNPGConfig(ctx, gw) + if resolveErr != nil { + return fmt.Errorf("resolve CNPG config for gateway %s: %w", gw.Name, resolveErr) + } } namespace, err := gatewayNamespace(gw) @@ -644,6 +655,7 @@ func (r *GatewayReconciler) Handle(ctx context.Context, event watcher.Event[*pb. SkipNetworkPolicies: r.skipNetworkPolicies, HasCNPG: r.hasCNPG, CNPG: cnpgConfig, + SelfManagedDB: useSelfManagedDatabase, ControlPlaneNamespace: r.controlPlaneNamespace, GatewayID: event.ResourceID, UpdateRouteAddress: r.makeRouteAddressUpdater(event.ResourceID), @@ -1066,6 +1078,10 @@ func (r *GatewayReconciler) resolveCNPGConfig(ctx context.Context, gw *pb.Gatewa }, nil } +func usesSelfManagedDatabase(gw *pb.Gateway) bool { + return gw.GetDatabaseId() == "" +} + func (r *GatewayReconciler) makeOIDCUpdater(gatewayID string) func(ctx context.Context, oidcJSON string) error { return func(ctx context.Context, oidcJSON string) error { client := pb.NewGatewayServiceClient(r.grpcConn) diff --git a/components/control-plane/manifests/gateway/database.yaml b/components/control-plane/manifests/gateway/database.yaml new file mode 100644 index 00000000..1b47cbd5 --- /dev/null +++ b/components/control-plane/manifests/gateway/database.yaml @@ -0,0 +1,157 @@ +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: openshell-gateway-db-data + namespace: NAMESPACE_PLACEHOLDER + labels: + app.kubernetes.io/name: openshell + app.kubernetes.io/component: database + app.kubernetes.io/managed-by: hypershell-control-plane + hypershell.redhat.io/managed: "true" +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: DB_STORAGE_PLACEHOLDER +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: openshell-gateway-db + namespace: NAMESPACE_PLACEHOLDER + labels: + app.kubernetes.io/name: openshell + app.kubernetes.io/component: database + app.kubernetes.io/managed-by: hypershell-control-plane + hypershell.redhat.io/managed: "true" +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: openshell + app.kubernetes.io/instance: openshell-gateway-db + template: + metadata: + labels: + app.kubernetes.io/name: openshell + app.kubernetes.io/instance: openshell-gateway-db + app.kubernetes.io/component: database + app.kubernetes.io/managed-by: hypershell-control-plane + hypershell.redhat.io/managed: "true" + spec: + terminationGracePeriodSeconds: 30 + containers: + - name: postgresql + image: DB_IMAGE_PLACEHOLDER + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + seccompProfile: + type: RuntimeDefault + capabilities: + drop: + - ALL + runAsNonRoot: true + runAsUser: 1001 + env: + - name: DB_USER_KEY_PLACEHOLDER + valueFrom: + secretKeyRef: + name: openshell-gateway-db-credentials + key: DB_USER_KEY_PLACEHOLDER + - name: DB_PASS_KEY_PLACEHOLDER + valueFrom: + secretKeyRef: + name: openshell-gateway-db-credentials + key: DB_PASS_KEY_PLACEHOLDER + - name: DB_NAME_KEY_PLACEHOLDER + valueFrom: + secretKeyRef: + name: openshell-gateway-db-credentials + key: DB_NAME_KEY_PLACEHOLDER + - name: PGDATA + value: DB_DATA_PATH_PLACEHOLDER/pgdata + ports: + - name: postgresql + containerPort: 5432 + protocol: TCP + readinessProbe: + tcpSocket: + port: 5432 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 1 + failureThreshold: 3 + livenessProbe: + tcpSocket: + port: 5432 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 1 + failureThreshold: 3 + volumeMounts: + - name: db-data + mountPath: DB_DATA_PATH_PLACEHOLDER + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: db-data + persistentVolumeClaim: + claimName: openshell-gateway-db-data +--- +apiVersion: v1 +kind: Service +metadata: + name: openshell-gateway-db + namespace: NAMESPACE_PLACEHOLDER + labels: + app.kubernetes.io/name: openshell + app.kubernetes.io/component: database + app.kubernetes.io/managed-by: hypershell-control-plane + hypershell.redhat.io/managed: "true" +spec: + type: ClusterIP + ports: + - port: 5432 + targetPort: postgresql + protocol: TCP + name: postgresql + selector: + app.kubernetes.io/name: openshell + app.kubernetes.io/instance: openshell-gateway-db +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: openshell-gateway-db + namespace: NAMESPACE_PLACEHOLDER + labels: + app.kubernetes.io/name: openshell + app.kubernetes.io/component: database + app.kubernetes.io/managed-by: hypershell-control-plane + hypershell.redhat.io/managed: "true" +spec: + podSelector: + matchLabels: + app.kubernetes.io/instance: openshell-gateway-db + app.kubernetes.io/name: openshell + policyTypes: + - Ingress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/name: openshell + app.kubernetes.io/instance: openshell-gateway + ports: + - protocol: TCP + port: 5432