diff --git a/PROJECT b/PROJECT index de9edb21..5362be00 100644 --- a/PROJECT +++ b/PROJECT @@ -24,6 +24,15 @@ resources: defaulting: true validation: true webhookVersion: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: nutanix.com + group: ndb + kind: LinkedDatabase + path: github.com/nutanix-cloud-native/ndb-operator/api/v1alpha1 + version: v1alpha1 - api: crdVersion: v1 namespaced: true diff --git a/README.md b/README.md index 6a884a5a..eb981239 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ The container and bundle image creation steps can be skipped if existing images ## Usage -**NDBServer and credentials:** The operator uses two custom resources—**NDBServer** (cluster-scoped) and **Database** (namespaced). **NDBServer** is cluster-scoped so that admins can store the NDB API credential secret in a restricted namespace (e.g. `ndb-credentials`) and set `credentialSecretRef` to point to it. Developers who create **Database** resources only need to reference the NDBServer by **name** in `ndbRef` (e.g. `ndbRef: ndb`); they can list and use cluster-scoped NDBServers without needing access to the secret's namespace. +**NDBServer and credentials:** The operator uses **NDBServer** (cluster-scoped) plus namespaced workload resources such as **Database** and **LinkedDatabase**. **NDBServer** is cluster-scoped so that admins can store the NDB API credential secret in a restricted namespace (e.g. `ndb-credentials`) and set `credentialSecretRef` to point to it. Developers who create **Database** or **LinkedDatabase** resources only need to reference the NDBServer by **name** in `ndbRef` (e.g. `ndbRef: ndb`); they can list and use cluster-scoped NDBServers without needing access to the secret's namespace. **Supported Database Engines:** - PostgreSQL @@ -521,6 +521,46 @@ Create the Database resource: kubectl apply -f ``` +### Creating a linked PostgreSQL database on an existing instance + +Use `LinkedDatabase` when you need to add a logical PostgreSQL database to an existing NDB PostgreSQL instance without provisioning a new database server VM. + +The operator maps this resource to: + +```sh +POST /databases//linked-databases +``` + +with a request body like: + +```json +{"databases":[{"databaseName":"appdb"}]} +``` + +Example: + +```yaml +apiVersion: ndb.nutanix.com/v1alpha1 +kind: LinkedDatabase +metadata: + name: appdb + namespace: default +spec: + # Name of the cluster-scoped NDBServer resource + ndbRef: ndb + # Use either sourceDatabaseId or sourceDatabaseName. + sourceDatabaseName: existing-postgres-instance + # sourceDatabaseId: "" + databaseName: appdb +``` + +Create the linked database resource: + +```sh +kubectl apply -f +kubectl get linkeddatabases -n default +``` + ### Additional Arguments for Databases Below are the various optional addtionalArguments you can specify along with examples of their corresponding values. Arguments that have defaults will be indicated. diff --git a/api/v1alpha1/linked_database_types.go b/api/v1alpha1/linked_database_types.go new file mode 100644 index 00000000..21dd0909 --- /dev/null +++ b/api/v1alpha1/linked_database_types.go @@ -0,0 +1,81 @@ +/* +Copyright 2022-2023 Nutanix, Inc. + +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 v1alpha1 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// LinkedDatabaseSpec defines the desired state of LinkedDatabase. +// +kubebuilder:validation:XValidation:rule="(has(self.sourceDatabaseId) && size(self.sourceDatabaseId) > 0) || (has(self.sourceDatabaseName) && size(self.sourceDatabaseName) > 0)",message="Either sourceDatabaseId or sourceDatabaseName must be provided" +type LinkedDatabaseSpec struct { + // Name of the cluster-scoped NDBServer resource. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + NDBRef string `json:"ndbRef"` + // UUID of the existing NDB database instance to add a linked database to. + // Either sourceDatabaseId or sourceDatabaseName must be provided. + // +optional + // +kubebuilder:validation:MinLength=1 + SourceDatabaseId string `json:"sourceDatabaseId,omitempty"` + // Name of the existing NDB database instance to add a linked database to. + // Either sourceDatabaseId or sourceDatabaseName must be provided. + // +optional + // +kubebuilder:validation:MinLength=1 + SourceDatabaseName string `json:"sourceDatabaseName,omitempty"` + // Name of the logical database to create on the existing database instance. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + DatabaseName string `json:"databaseName"` +} + +// LinkedDatabaseStatus defines the observed state of LinkedDatabase. +type LinkedDatabaseStatus struct { + Status string `json:"status,omitempty"` + SourceDatabaseId string `json:"sourceDatabaseId,omitempty"` + LinkedDatabaseId string `json:"linkedDatabaseId,omitempty"` + CreationOperationId string `json:"creationOperationId,omitempty"` + Message string `json:"message,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName={"ldb","ldbs"} +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.status` +// +kubebuilder:printcolumn:name="Source Database ID",type=string,JSONPath=`.status.sourceDatabaseId` +// +kubebuilder:printcolumn:name="Linked Database ID",type=string,JSONPath=`.status.linkedDatabaseId` +// +kubebuilder:printcolumn:name="Database Name",type=string,JSONPath=`.spec.databaseName` + +// LinkedDatabase is the Schema for the linkeddatabases API. +type LinkedDatabase struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec LinkedDatabaseSpec `json:"spec,omitempty"` + Status LinkedDatabaseStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// LinkedDatabaseList contains a list of LinkedDatabase. +type LinkedDatabaseList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []LinkedDatabase `json:"items"` +} + +func init() { + SchemeBuilder.Register(&LinkedDatabase{}, &LinkedDatabaseList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 70faa0e7..2f372a3e 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -252,6 +252,95 @@ func (in *InstanceHANode) DeepCopy() *InstanceHANode { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinkedDatabase) DeepCopyInto(out *LinkedDatabase) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinkedDatabase. +func (in *LinkedDatabase) DeepCopy() *LinkedDatabase { + if in == nil { + return nil + } + out := new(LinkedDatabase) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LinkedDatabase) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinkedDatabaseList) DeepCopyInto(out *LinkedDatabaseList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]LinkedDatabase, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinkedDatabaseList. +func (in *LinkedDatabaseList) DeepCopy() *LinkedDatabaseList { + if in == nil { + return nil + } + out := new(LinkedDatabaseList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LinkedDatabaseList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinkedDatabaseSpec) DeepCopyInto(out *LinkedDatabaseSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinkedDatabaseSpec. +func (in *LinkedDatabaseSpec) DeepCopy() *LinkedDatabaseSpec { + if in == nil { + return nil + } + out := new(LinkedDatabaseSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LinkedDatabaseStatus) DeepCopyInto(out *LinkedDatabaseStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinkedDatabaseStatus. +func (in *LinkedDatabaseStatus) DeepCopy() *LinkedDatabaseStatus { + if in == nil { + return nil + } + out := new(LinkedDatabaseStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NDBServer) DeepCopyInto(out *NDBServer) { *out = *in diff --git a/config/crd/bases/ndb.nutanix.com_linkeddatabases.yaml b/config/crd/bases/ndb.nutanix.com_linkeddatabases.yaml new file mode 100644 index 00000000..c6651606 --- /dev/null +++ b/config/crd/bases/ndb.nutanix.com_linkeddatabases.yaml @@ -0,0 +1,106 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: linkeddatabases.ndb.nutanix.com +spec: + group: ndb.nutanix.com + names: + kind: LinkedDatabase + listKind: LinkedDatabaseList + plural: linkeddatabases + shortNames: + - ldb + - ldbs + singular: linkeddatabase + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.status + name: Status + type: string + - jsonPath: .status.sourceDatabaseId + name: Source Database ID + type: string + - jsonPath: .status.linkedDatabaseId + name: Linked Database ID + type: string + - jsonPath: .spec.databaseName + name: Database Name + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: LinkedDatabase is the Schema for the linkeddatabases API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: LinkedDatabaseSpec defines the desired state of LinkedDatabase. + properties: + databaseName: + description: Name of the logical database to create on the existing + database instance. + minLength: 1 + type: string + ndbRef: + description: Name of the cluster-scoped NDBServer resource. + minLength: 1 + type: string + sourceDatabaseId: + description: |- + UUID of the existing NDB database instance to add a linked database to. + Either sourceDatabaseId or sourceDatabaseName must be provided. + minLength: 1 + type: string + sourceDatabaseName: + description: |- + Name of the existing NDB database instance to add a linked database to. + Either sourceDatabaseId or sourceDatabaseName must be provided. + minLength: 1 + type: string + required: + - databaseName + - ndbRef + type: object + x-kubernetes-validations: + - message: Either sourceDatabaseId or sourceDatabaseName must be provided + rule: (has(self.sourceDatabaseId) && size(self.sourceDatabaseId) > 0) + || (has(self.sourceDatabaseName) && size(self.sourceDatabaseName) + > 0) + status: + description: LinkedDatabaseStatus defines the observed state of LinkedDatabase. + properties: + creationOperationId: + type: string + linkedDatabaseId: + type: string + message: + type: string + sourceDatabaseId: + type: string + status: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 3f1aa251..f330238b 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,6 +3,7 @@ # It should be run by config/default resources: - bases/ndb.nutanix.com_databases.yaml +- bases/ndb.nutanix.com_linkeddatabases.yaml - bases/ndb.nutanix.com_ndbservers.yaml #+kubebuilder:scaffold:crdkustomizeresource diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index e8787388..e8030334 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -43,6 +43,7 @@ rules: - ndb.nutanix.com resources: - databases + - linkeddatabases - ndbservers verbs: - create @@ -63,6 +64,7 @@ rules: - ndb.nutanix.com resources: - databases/status + - linkeddatabases/status - ndbservers/status verbs: - get diff --git a/controllers/linked_database_controller.go b/controllers/linked_database_controller.go new file mode 100644 index 00000000..da00b3eb --- /dev/null +++ b/controllers/linked_database_controller.go @@ -0,0 +1,224 @@ +/* +Copyright 2022-2023 Nutanix, Inc. + +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 controllers + +import ( + "context" + "fmt" + "reflect" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + ndbv1alpha1 "github.com/nutanix-cloud-native/ndb-operator/api/v1alpha1" + "github.com/nutanix-cloud-native/ndb-operator/common" + "github.com/nutanix-cloud-native/ndb-operator/ndb_api" + "github.com/nutanix-cloud-native/ndb-operator/ndb_client" +) + +type linkedDatabaseNDBClientFactory func(username, password, server, caCert string, skipVerify bool) ndb_client.NDBClientHTTPInterface + +// LinkedDatabaseReconciler reconciles a LinkedDatabase object. +type LinkedDatabaseReconciler struct { + client.Client + Scheme *runtime.Scheme + recorder record.EventRecorder + ndbClientFactory linkedDatabaseNDBClientFactory +} + +// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch +// +kubebuilder:rbac:groups="core",resources=secrets,verbs=get;list;watch +// +kubebuilder:rbac:groups=ndb.nutanix.com,resources=ndbservers,verbs=get;list;watch +// +kubebuilder:rbac:groups=ndb.nutanix.com,resources=linkeddatabases,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=ndb.nutanix.com,resources=linkeddatabases/status,verbs=get;update;patch + +func (r *LinkedDatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := ctrllog.FromContext(ctx) + log.Info("LinkedDatabase reconcile started") + + linkedDatabase := &ndbv1alpha1.LinkedDatabase{} + if err := r.Get(ctx, req.NamespacedName, linkedDatabase); err != nil { + if apierrors.IsNotFound(err) { + log.Info("LinkedDatabase resource not found. Ignoring since object must be deleted") + return doNotRequeue() + } + log.Error(err, "Failed to get LinkedDatabase") + return requeueOnErr(err) + } + + if !linkedDatabase.ObjectMeta.DeletionTimestamp.IsZero() { + return doNotRequeue() + } + + ndbServer := &ndbv1alpha1.NDBServer{} + if err := r.Get(ctx, types.NamespacedName{Name: linkedDatabase.Spec.NDBRef}, ndbServer); err != nil { + if apierrors.IsNotFound(err) { + log.Info("NDBServer resource not found. Ignoring until it exists", "ndbRef", linkedDatabase.Spec.NDBRef) + return requeueWithTimeout(common.DATABASE_RECONCILE_INTERVAL_SECONDS) + } + log.Error(err, "Failed to get NDBServer") + return requeueOnErr(err) + } + + username, password, caCert, err := getNDBCredentialsFromSecret(ctx, r.Client, ndbServer.Spec.CredentialSecretRef) + if err != nil { + r.recordEvent(linkedDatabase, corev1.EventTypeWarning, EVENT_INVALID_CREDENTIALS, "Error: %s", err.Error()) + return requeueOnErr(err) + } + + ndbClient := r.newNDBClient(username, password, ndbServer.Spec.Server, caCert, ndbServer.Spec.SkipCertificateVerification) + return r.handleLinkedDatabaseSync(ctx, linkedDatabase, ndbServer, ndbClient) +} + +func (r *LinkedDatabaseReconciler) newNDBClient(username, password, server, caCert string, skipVerify bool) ndb_client.NDBClientHTTPInterface { + if r.ndbClientFactory != nil { + return r.ndbClientFactory(username, password, server, caCert, skipVerify) + } + return ndb_client.NewNDBClient(username, password, server, caCert, skipVerify) +} + +func (r *LinkedDatabaseReconciler) handleLinkedDatabaseSync(ctx context.Context, linkedDatabase *ndbv1alpha1.LinkedDatabase, ndbServer *ndbv1alpha1.NDBServer, ndbClient ndb_client.NDBClientHTTPInterface) (ctrl.Result, error) { + log := ctrllog.FromContext(ctx) + status := linkedDatabase.Status + + if status.Status == "" && status.CreationOperationId == "" { + sourceDatabaseId, err := resolveLinkedDatabaseSourceDatabaseId(linkedDatabase, ndbServer) + if err != nil { + r.recordEvent(linkedDatabase, corev1.EventTypeWarning, EVENT_RESOURCE_LOOKUP_ERROR, "Error: %s", err.Error()) + return requeueOnErr(err) + } + + existingLinkedDatabase, err := ndb_api.GetLinkedDatabaseByName(ctx, ndbClient, sourceDatabaseId, linkedDatabase.Spec.DatabaseName) + if err != nil { + r.recordEvent(linkedDatabase, corev1.EventTypeWarning, EVENT_NDB_REQUEST_FAILED, "Error checking linked database existence: %s", err.Error()) + return requeueOnErr(err) + } + if existingLinkedDatabase != nil { + status.Status = common.DATABASE_CR_STATUS_READY + status.SourceDatabaseId = sourceDatabaseId + status.LinkedDatabaseId = existingLinkedDatabase.Id + status.Message = "Linked database already exists on NDB" + r.recordEvent(linkedDatabase, corev1.EventTypeNormal, EVENT_CREATION_COMPLETED, "Linked database already exists on NDB") + } else { + request, err := ndb_api.GenerateLinkedDatabaseProvisionRequest(linkedDatabase.Spec.DatabaseName) + if err != nil { + r.recordEvent(linkedDatabase, corev1.EventTypeWarning, EVENT_REQUEST_GENERATION_FAILURE, "Error: %s", err.Error()) + return requeueOnErr(err) + } + r.recordEvent(linkedDatabase, corev1.EventTypeNormal, EVENT_REQUEST_GENERATION, "Generated linked database provisioning request") + + task, err := ndb_api.ProvisionLinkedDatabase(ctx, ndbClient, sourceDatabaseId, request) + if err != nil { + r.recordEvent(linkedDatabase, corev1.EventTypeWarning, EVENT_NDB_REQUEST_FAILED, "Error: %s", err.Error()) + return requeueOnErr(err) + } + + status.Status = common.DATABASE_CR_STATUS_CREATING + status.SourceDatabaseId = sourceDatabaseId + status.CreationOperationId = task.OperationId + r.recordEvent(linkedDatabase, corev1.EventTypeNormal, EVENT_CREATION_STARTED, "Linked database creation initiated on NDB") + } + } else if status.Status == common.DATABASE_CR_STATUS_CREATING { + operation, err := ndb_api.GetOperationById(ctx, ndbClient, status.CreationOperationId) + if err != nil { + r.recordEvent(linkedDatabase, corev1.EventTypeWarning, EVENT_NDB_REQUEST_FAILED, "Error fetching operation %s: %s", status.CreationOperationId, err.Error()) + } else { + switch ndb_api.GetOperationStatus(operation) { + case ndb_api.OPERATION_STATUS_FAILED: + status.Status = common.DATABASE_CR_STATUS_CREATION_ERROR + status.Message = operation.Message + r.recordEvent(linkedDatabase, corev1.EventTypeWarning, EVENT_CREATION_FAILED, "Linked database creation failed: %s", operation.Message) + case ndb_api.OPERATION_STATUS_PASSED: + status.Status = common.DATABASE_CR_STATUS_READY + status.Message = operation.Message + linkedDatabaseResponse, lookupErr := ndb_api.GetLinkedDatabaseByName(ctx, ndbClient, status.SourceDatabaseId, linkedDatabase.Spec.DatabaseName) + if lookupErr != nil { + r.recordEvent(linkedDatabase, corev1.EventTypeWarning, EVENT_NDB_REQUEST_FAILED, "Error fetching linked database after creation: %s", lookupErr.Error()) + } else if linkedDatabaseResponse != nil { + status.LinkedDatabaseId = linkedDatabaseResponse.Id + } + r.recordEvent(linkedDatabase, corev1.EventTypeNormal, EVENT_CREATION_COMPLETED, "Linked database creation operation passed") + default: + log.Info("Linked database creation operation is still running", "operationId", operation.Id, "status", operation.Status) + } + } + } + + if !reflect.DeepEqual(linkedDatabase.Status, status) { + if err := r.updateLinkedDatabaseStatusWithRetry(ctx, linkedDatabase, status); err != nil { + r.recordEvent(linkedDatabase, corev1.EventTypeWarning, EVENT_CR_STATUS_UPDATE_FAILED, "Error: %s", err.Error()) + return requeueOnErr(err) + } + } + + switch status.Status { + case common.DATABASE_CR_STATUS_READY, common.DATABASE_CR_STATUS_CREATION_ERROR: + return doNotRequeue() + default: + return requeueWithTimeout(common.DATABASE_RECONCILE_INTERVAL_SECONDS) + } +} + +func resolveLinkedDatabaseSourceDatabaseId(linkedDatabase *ndbv1alpha1.LinkedDatabase, ndbServer *ndbv1alpha1.NDBServer) (string, error) { + if linkedDatabase.Spec.SourceDatabaseId != "" { + return linkedDatabase.Spec.SourceDatabaseId, nil + } + if linkedDatabase.Spec.SourceDatabaseName == "" { + return "", fmt.Errorf("either sourceDatabaseId or sourceDatabaseName must be provided") + } + for _, database := range ndbServer.Status.Databases { + if database.Name == linkedDatabase.Spec.SourceDatabaseName { + return database.Id, nil + } + } + return "", fmt.Errorf("source database %q was not found in NDBServer status", linkedDatabase.Spec.SourceDatabaseName) +} + +func (r *LinkedDatabaseReconciler) updateLinkedDatabaseStatusWithRetry(ctx context.Context, linkedDatabase *ndbv1alpha1.LinkedDatabase, status ndbv1alpha1.LinkedDatabaseStatus) error { + linkedDatabase.Status = status + if err := r.Status().Update(ctx, linkedDatabase); err == nil { + return nil + } + if err := r.Get(ctx, types.NamespacedName{Name: linkedDatabase.Name, Namespace: linkedDatabase.Namespace}, linkedDatabase); err != nil { + return err + } + linkedDatabase.Status = status + return r.Status().Update(ctx, linkedDatabase) +} + +func (r *LinkedDatabaseReconciler) recordEvent(linkedDatabase *ndbv1alpha1.LinkedDatabase, eventtype, reason, messageFmt string, args ...interface{}) { + if r.recorder == nil { + return + } + r.recorder.Eventf(linkedDatabase, eventtype, reason, messageFmt, args...) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *LinkedDatabaseReconciler) SetupWithManager(mgr ctrl.Manager) error { + r.recorder = mgr.GetEventRecorderFor("linked-database-controller") + return ctrl.NewControllerManagedBy(mgr). + For(&ndbv1alpha1.LinkedDatabase{}). + WithEventFilter(predicate.GenerationChangedPredicate{}). + Complete(r) +} diff --git a/controllers/linked_database_controller_test.go b/controllers/linked_database_controller_test.go new file mode 100644 index 00000000..01ed0c12 --- /dev/null +++ b/controllers/linked_database_controller_test.go @@ -0,0 +1,75 @@ +package controllers + +import ( + "testing" + + ndbv1alpha1 "github.com/nutanix-cloud-native/ndb-operator/api/v1alpha1" +) + +func TestResolveLinkedDatabaseSourceDatabaseId(t *testing.T) { + ndbServer := &ndbv1alpha1.NDBServer{ + Status: ndbv1alpha1.NDBServerStatus{ + Databases: map[string]ndbv1alpha1.NDBServerDatabaseInfo{ + "postgres-id": { + Id: "postgres-id", + Name: "existing-postgres", + }, + }, + }, + } + + tests := []struct { + name string + linked *ndbv1alpha1.LinkedDatabase + wantSourceId string + wantErr bool + }{ + { + name: "uses explicit source database id", + linked: &ndbv1alpha1.LinkedDatabase{ + Spec: ndbv1alpha1.LinkedDatabaseSpec{ + SourceDatabaseId: "explicit-id", + }, + }, + wantSourceId: "explicit-id", + }, + { + name: "resolves source database name from NDBServer status", + linked: &ndbv1alpha1.LinkedDatabase{ + Spec: ndbv1alpha1.LinkedDatabaseSpec{ + SourceDatabaseName: "existing-postgres", + }, + }, + wantSourceId: "postgres-id", + }, + { + name: "returns an error when no source database reference is provided", + linked: &ndbv1alpha1.LinkedDatabase{ + Spec: ndbv1alpha1.LinkedDatabaseSpec{}, + }, + wantErr: true, + }, + { + name: "returns an error when the source database name is not in NDBServer status", + linked: &ndbv1alpha1.LinkedDatabase{ + Spec: ndbv1alpha1.LinkedDatabaseSpec{ + SourceDatabaseName: "missing-postgres", + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotSourceId, err := resolveLinkedDatabaseSourceDatabaseId(tt.linked, ndbServer) + if (err != nil) != tt.wantErr { + t.Errorf("resolveLinkedDatabaseSourceDatabaseId() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotSourceId != tt.wantSourceId { + t.Errorf("resolveLinkedDatabaseSourceDatabaseId() = %v, want %v", gotSourceId, tt.wantSourceId) + } + }) + } +} diff --git a/deploy/helm/README.md b/deploy/helm/README.md index a6b83072..305c622c 100644 --- a/deploy/helm/README.md +++ b/deploy/helm/README.md @@ -7,7 +7,8 @@ NDB operator supports these functionalities: 1. Provisioning and deprovisioning a single instance postgres, mssql, sql server, and mongodb database with or without time machine. 2. Cloning support for the above database engines 3. Provisioning and deprovisioning Postgres High Availability (HA) instances. -4. Creation of a service for the applications to consume the database within Kubernetes. +4. Creating linked PostgreSQL databases on existing NDB PostgreSQL instances. +5. Creation of a service for the applications to consume the database within Kubernetes. --- @@ -124,7 +125,7 @@ helm uninstall ndb-operator --namespace ndb-operator-system For the complete operator guide (including migration notes and development), see the [main repository README](https://github.com/nutanix-cloud-native/ndb-operator/blob/main/README.md). -**NDBServer and credentials:** The operator uses two custom resources—**NDBServer** (cluster-scoped) and **Database** (namespaced). **NDBServer** is cluster-scoped so that admins can store the NDB API credential secret in a restricted namespace (e.g. `ndb-credentials`) and set `credentialSecretRef` to point to it. Developers who create **Database** resources only need to reference the NDBServer by **name** in `ndbRef` (e.g. `ndbRef: ndb`); they can list and use cluster-scoped NDBServers without needing access to the secret's namespace. +**NDBServer and credentials:** The operator uses **NDBServer** (cluster-scoped) plus namespaced workload resources such as **Database** and **LinkedDatabase**. **NDBServer** is cluster-scoped so that admins can store the NDB API credential secret in a restricted namespace (e.g. `ndb-credentials`) and set `credentialSecretRef` to point to it. Developers who create **Database** or **LinkedDatabase** resources only need to reference the NDBServer by **name** in `ndbRef` (e.g. `ndbRef: ndb`); they can list and use cluster-scoped NDBServers without needing access to the secret's namespace. ### Create secrets to be used by the NDBServer and Database resources using the manifest: @@ -516,6 +517,46 @@ data: kubectl apply -f ``` +### Creating a linked PostgreSQL database on an existing instance + +Use `LinkedDatabase` when you need to add a logical PostgreSQL database to an existing NDB PostgreSQL instance without provisioning a new database server VM. + +The operator maps this resource to: + +```sh +POST /databases//linked-databases +``` + +with a request body like: + +```json +{"databases":[{"databaseName":"appdb"}]} +``` + +Example: + +```yaml +apiVersion: ndb.nutanix.com/v1alpha1 +kind: LinkedDatabase +metadata: + name: appdb + namespace: default +spec: + # Name of the cluster-scoped NDBServer resource + ndbRef: ndb + # Use either sourceDatabaseId or sourceDatabaseName. + sourceDatabaseName: existing-postgres-instance + # sourceDatabaseId: "" + databaseName: appdb +``` + +Create the linked database resource: + +```sh +kubectl apply -f +kubectl get linkeddatabases -n default +``` + ### Additional Arguments for Databases Below are the various optional addtionalArguments you can specify along with examples of their corresponding values. Arguments that have defaults will be indicated. @@ -743,4 +784,4 @@ Issues and enhancement requests can be submitted in the [Issues tab of this repo Copyright 2022-2026 Nutanix, Inc. -The project is released under version 2.0 of the [Apache license](http://www.apache.org/licenses/LICENSE-2.0). \ No newline at end of file +The project is released under version 2.0 of the [Apache license](http://www.apache.org/licenses/LICENSE-2.0). diff --git a/deploy/helm/crds/customresourcedefinition-linkeddatabases.ndb.nutanix.com.yaml b/deploy/helm/crds/customresourcedefinition-linkeddatabases.ndb.nutanix.com.yaml new file mode 100644 index 00000000..c6651606 --- /dev/null +++ b/deploy/helm/crds/customresourcedefinition-linkeddatabases.ndb.nutanix.com.yaml @@ -0,0 +1,106 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: linkeddatabases.ndb.nutanix.com +spec: + group: ndb.nutanix.com + names: + kind: LinkedDatabase + listKind: LinkedDatabaseList + plural: linkeddatabases + shortNames: + - ldb + - ldbs + singular: linkeddatabase + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.status + name: Status + type: string + - jsonPath: .status.sourceDatabaseId + name: Source Database ID + type: string + - jsonPath: .status.linkedDatabaseId + name: Linked Database ID + type: string + - jsonPath: .spec.databaseName + name: Database Name + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: LinkedDatabase is the Schema for the linkeddatabases API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: LinkedDatabaseSpec defines the desired state of LinkedDatabase. + properties: + databaseName: + description: Name of the logical database to create on the existing + database instance. + minLength: 1 + type: string + ndbRef: + description: Name of the cluster-scoped NDBServer resource. + minLength: 1 + type: string + sourceDatabaseId: + description: |- + UUID of the existing NDB database instance to add a linked database to. + Either sourceDatabaseId or sourceDatabaseName must be provided. + minLength: 1 + type: string + sourceDatabaseName: + description: |- + Name of the existing NDB database instance to add a linked database to. + Either sourceDatabaseId or sourceDatabaseName must be provided. + minLength: 1 + type: string + required: + - databaseName + - ndbRef + type: object + x-kubernetes-validations: + - message: Either sourceDatabaseId or sourceDatabaseName must be provided + rule: (has(self.sourceDatabaseId) && size(self.sourceDatabaseId) > 0) + || (has(self.sourceDatabaseName) && size(self.sourceDatabaseName) + > 0) + status: + description: LinkedDatabaseStatus defines the observed state of LinkedDatabase. + properties: + creationOperationId: + type: string + linkedDatabaseId: + type: string + message: + type: string + sourceDatabaseId: + type: string + status: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/helm/templates/clusterrole-ndb-operator-manager-role.yaml b/deploy/helm/templates/clusterrole-ndb-operator-manager-role.yaml index 8a0fd295..99d421bc 100644 --- a/deploy/helm/templates/clusterrole-ndb-operator-manager-role.yaml +++ b/deploy/helm/templates/clusterrole-ndb-operator-manager-role.yaml @@ -43,6 +43,7 @@ rules: - ndb.nutanix.com resources: - databases + - linkeddatabases - ndbservers verbs: - create @@ -63,6 +64,7 @@ rules: - ndb.nutanix.com resources: - databases/status + - linkeddatabases/status - ndbservers/status verbs: - get diff --git a/main.go b/main.go index cd62ee27..1751ac69 100644 --- a/main.go +++ b/main.go @@ -127,6 +127,13 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "NDBServer") os.Exit(1) } + if err = (&controllers.LinkedDatabaseReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "LinkedDatabase") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/ndb_api/db_response_types.go b/ndb_api/db_response_types.go index 419be42d..b156b55a 100644 --- a/ndb_api/db_response_types.go +++ b/ndb_api/db_response_types.go @@ -17,11 +17,25 @@ limitations under the License. package ndb_api type DatabaseResponse struct { - Id string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - DatabaseNodes []DatabaseNode `json:"databaseNodes"` - Properties []Property `json:"properties"` - TimeMachineId string `json:"timeMachineId"` - Type string `json:"type"` + Id string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + DatabaseNodes []DatabaseNode `json:"databaseNodes"` + LinkedDatabases []LinkedDatabaseResponse `json:"linkedDatabases"` + Properties []Property `json:"properties"` + TimeMachineId string `json:"timeMachineId"` + Type string `json:"type"` +} + +type LinkedDatabaseResponse struct { + Id string `json:"id"` + Name string `json:"name"` + DatabaseName string `json:"databaseName"` + Description string `json:"description"` + Status string `json:"status"` + DatabaseStatus string `json:"databaseStatus"` + ParentDatabaseId string `json:"parentDatabaseId"` + ParentLinkedDatabaseId string `json:"parentLinkedDatabaseId"` + SnapshotId string `json:"snapshotId"` + TimeZone string `json:"timeZone"` } diff --git a/ndb_api/linked_database.go b/ndb_api/linked_database.go new file mode 100644 index 00000000..37c045fa --- /dev/null +++ b/ndb_api/linked_database.go @@ -0,0 +1,78 @@ +/* +Copyright 2022-2023 Nutanix, Inc. + +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 ndb_api + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/nutanix-cloud-native/ndb-operator/ndb_client" + ctrllog "sigs.k8s.io/controller-runtime/pkg/log" +) + +func GenerateLinkedDatabaseProvisionRequest(databaseName string) (*LinkedDatabaseProvisionRequest, error) { + databaseName = strings.TrimSpace(databaseName) + if databaseName == "" { + return nil, fmt.Errorf("database name is empty") + } + return &LinkedDatabaseProvisionRequest{ + Databases: []LinkedDatabaseRequest{{DatabaseName: databaseName}}, + }, nil +} + +func ProvisionLinkedDatabase(ctx context.Context, ndbClient ndb_client.NDBClientHTTPInterface, sourceDatabaseId string, req *LinkedDatabaseProvisionRequest) (task *TaskInfoSummaryResponse, err error) { + log := ctrllog.FromContext(ctx) + sourceDatabaseId = strings.TrimSpace(sourceDatabaseId) + if sourceDatabaseId == "" { + err = fmt.Errorf("source database id is empty") + log.Error(err, "no source database id provided") + return + } + path := fmt.Sprintf("databases/%s/linked-databases", sourceDatabaseId) + if _, err = sendRequest(ctx, ndbClient, http.MethodPost, path, req, &task); err != nil { + log.Error(err, "Error in ProvisionLinkedDatabase") + return + } + return +} + +func GetLinkedDatabaseByName(ctx context.Context, ndbClient ndb_client.NDBClientHTTPInterface, sourceDatabaseId, databaseName string) (*LinkedDatabaseResponse, error) { + sourceDatabaseId = strings.TrimSpace(sourceDatabaseId) + if sourceDatabaseId == "" { + return nil, fmt.Errorf("source database id is empty") + } + databaseName = strings.TrimSpace(databaseName) + if databaseName == "" { + return nil, fmt.Errorf("database name is empty") + } + + sourceDatabase, err := GetDatabaseById(ctx, ndbClient, sourceDatabaseId) + if err != nil { + return nil, err + } + if sourceDatabase == nil { + return nil, nil + } + for _, linkedDatabase := range sourceDatabase.LinkedDatabases { + if linkedDatabase.DatabaseName == databaseName { + return &linkedDatabase, nil + } + } + return nil, nil +} diff --git a/ndb_api/linked_database_request_types.go b/ndb_api/linked_database_request_types.go new file mode 100644 index 00000000..1609f41a --- /dev/null +++ b/ndb_api/linked_database_request_types.go @@ -0,0 +1,25 @@ +/* +Copyright 2022-2023 Nutanix, Inc. + +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 ndb_api + +type LinkedDatabaseRequest struct { + DatabaseName string `json:"databaseName"` +} + +type LinkedDatabaseProvisionRequest struct { + Databases []LinkedDatabaseRequest `json:"databases"` +} diff --git a/ndb_api/linked_database_test.go b/ndb_api/linked_database_test.go new file mode 100644 index 00000000..0110d8d2 --- /dev/null +++ b/ndb_api/linked_database_test.go @@ -0,0 +1,205 @@ +package ndb_api + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "reflect" + "testing" + + "github.com/nutanix-cloud-native/ndb-operator/ndb_client" +) + +func TestGenerateLinkedDatabaseProvisionRequest(t *testing.T) { + tests := []struct { + name string + databaseName string + want *LinkedDatabaseProvisionRequest + wantErr bool + }{ + { + name: "returns an error for an empty database name", + databaseName: "", + wantErr: true, + }, + { + name: "creates the NDB linked database payload", + databaseName: "test", + want: &LinkedDatabaseProvisionRequest{ + Databases: []LinkedDatabaseRequest{{DatabaseName: "test"}}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GenerateLinkedDatabaseProvisionRequest(tt.databaseName) + if (err != nil) != tt.wantErr { + t.Errorf("GenerateLinkedDatabaseProvisionRequest() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GenerateLinkedDatabaseProvisionRequest() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestProvisionLinkedDatabase(t *testing.T) { + type args struct { + ctx context.Context + ndbClient ndb_client.NDBClientHTTPInterface + sourceDatabaseId string + req *LinkedDatabaseProvisionRequest + } + + provisionRequest := &LinkedDatabaseProvisionRequest{ + Databases: []LinkedDatabaseRequest{{DatabaseName: "test"}}, + } + + mockNDBClient := &MockNDBClientHTTPInterface{} + mockNDBClient.On("NewRequest", http.MethodPost, "databases/databaseid/linked-databases", provisionRequest).Once().Return(nil, errors.New("mock-error-new-request")) + + req := &http.Request{} + res := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"name":"linked-db-create","operationId":"operation-id","entityId":"databaseid"}`)), + } + mockNDBClient.On("NewRequest", http.MethodPost, "databases/databaseid/linked-databases", provisionRequest).Once().Return(req, nil) + mockNDBClient.On("Do", req).Once().Return(res, nil) + + tests := []struct { + name string + args args + wantTask *TaskInfoSummaryResponse + wantErr bool + }{ + { + name: "returns an error when source database id is empty", + args: args{ + ctx: context.TODO(), + ndbClient: mockNDBClient, + sourceDatabaseId: "", + req: provisionRequest, + }, + wantErr: true, + }, + { + name: "returns an error when sendRequest returns an error", + args: args{ + ctx: context.TODO(), + ndbClient: mockNDBClient, + sourceDatabaseId: "databaseid", + req: provisionRequest, + }, + wantErr: true, + }, + { + name: "posts to the linked-databases endpoint", + args: args{ + ctx: context.TODO(), + ndbClient: mockNDBClient, + sourceDatabaseId: "databaseid", + req: provisionRequest, + }, + wantTask: &TaskInfoSummaryResponse{ + Name: "linked-db-create", + OperationId: "operation-id", + EntityId: "databaseid", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotTask, err := ProvisionLinkedDatabase(tt.args.ctx, tt.args.ndbClient, tt.args.sourceDatabaseId, tt.args.req) + if (err != nil) != tt.wantErr { + t.Errorf("ProvisionLinkedDatabase() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(gotTask, tt.wantTask) { + t.Errorf("ProvisionLinkedDatabase() = %v, want %v", gotTask, tt.wantTask) + } + }) + } +} + +func TestGetLinkedDatabaseByName(t *testing.T) { + type args struct { + ctx context.Context + ndbClient ndb_client.NDBClientHTTPInterface + sourceDatabaseId string + databaseName string + } + + mockNDBClient := &MockNDBClientHTTPInterface{} + req := &http.Request{Method: http.MethodGet} + res := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString( + `{"id":"databaseid","name":"postgres","linkedDatabases":[{"id":"linked-id","databaseName":"test","databaseStatus":"READY"}]}`, + )), + } + mockNDBClient.On("NewRequest", http.MethodGet, "databases/databaseid?detailed=true", nil).Once().Return(req, nil) + mockNDBClient.On("Do", req).Once().Return(res, nil) + + tests := []struct { + name string + args args + wantLinkedDatabase *LinkedDatabaseResponse + wantErr bool + }{ + { + name: "returns an error when source database id is empty", + args: args{ + ctx: context.TODO(), + ndbClient: mockNDBClient, + sourceDatabaseId: "", + databaseName: "test", + }, + wantErr: true, + }, + { + name: "returns an error when database name is empty", + args: args{ + ctx: context.TODO(), + ndbClient: mockNDBClient, + sourceDatabaseId: "databaseid", + databaseName: "", + }, + wantErr: true, + }, + { + name: "finds linked database by logical database name", + args: args{ + ctx: context.TODO(), + ndbClient: mockNDBClient, + sourceDatabaseId: "databaseid", + databaseName: "test", + }, + wantLinkedDatabase: &LinkedDatabaseResponse{ + Id: "linked-id", + DatabaseName: "test", + DatabaseStatus: "READY", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotLinkedDatabase, err := GetLinkedDatabaseByName(tt.args.ctx, tt.args.ndbClient, tt.args.sourceDatabaseId, tt.args.databaseName) + if (err != nil) != tt.wantErr { + t.Errorf("GetLinkedDatabaseByName() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(gotLinkedDatabase, tt.wantLinkedDatabase) { + t.Errorf("GetLinkedDatabaseByName() = %v, want %v", gotLinkedDatabase, tt.wantLinkedDatabase) + } + }) + } +}