diff --git a/api/bases/keystone.openstack.org_keystoneapis.yaml b/api/bases/keystone.openstack.org_keystoneapis.yaml index 3e57d2e9..77e2098c 100644 --- a/api/bases/keystone.openstack.org_keystoneapis.yaml +++ b/api/bases/keystone.openstack.org_keystoneapis.yaml @@ -98,6 +98,11 @@ spec: description: EnableSecureRBAC - Enable Consistent and Secure RBAC policies type: boolean + externalKeystoneAPI: + default: false + description: ExternalKeystoneAPI - Enable use of external Keystone + API endpoints instead of deploying a local Keystone API + type: boolean extraMounts: default: [] description: ExtraMounts containing conf files diff --git a/api/v1beta1/conditions.go b/api/v1beta1/conditions.go index e263abda..f31899a4 100644 --- a/api/v1beta1/conditions.go +++ b/api/v1beta1/conditions.go @@ -111,4 +111,25 @@ const ( // KeystoneServiceOSUserReadyErrorMessage KeystoneServiceOSUserReadyErrorMessage = "Keystone Service user error occured %s" + + // + // External Keystone API condition messages + // + // ExternalKeystoneAPIDBMessage + ExternalKeystoneAPIDBMessage = "External Keystone API configured - database is not managed by this operator" + + // ExternalKeystoneAPIDBAccountMessage + ExternalKeystoneAPIDBAccountMessage = "External Keystone API configured - database account is not managed by this operator" + + // ExternalKeystoneAPIRabbitMQTransportURLMessage + ExternalKeystoneAPIRabbitMQTransportURLMessage = "External Keystone API configured - RabbitMQ is not managed by this operator" + + // ExternalKeystoneAPIMemcachedReadyMessage + ExternalKeystoneAPIMemcachedReadyMessage = "External Keystone API configured - memcached is not managed by this operator" + + // ExternalKeystoneAPIServiceConfigReadyMessage + ExternalKeystoneAPIServiceMessage = "External Keystone API configured - service is not managed by this operator" + + // ExternalKeystoneAPINetworkAttachmentsReadyMessage + ExternalKeystoneAPINetworkAttachmentsReadyMessage = "External Keystone API configured - network attachments are not managed by this operator" ) diff --git a/api/v1beta1/keystoneapi.go b/api/v1beta1/keystoneapi.go index b75b703d..90953350 100644 --- a/api/v1beta1/keystoneapi.go +++ b/api/v1beta1/keystoneapi.go @@ -26,7 +26,6 @@ import ( "github.com/openstack-k8s-operators/lib-common/modules/common/endpoint" "github.com/openstack-k8s-operators/lib-common/modules/common/helper" "github.com/openstack-k8s-operators/lib-common/modules/common/secret" - "github.com/openstack-k8s-operators/lib-common/modules/common/tls" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/predicate" @@ -145,8 +144,13 @@ func GetScopedAdminServiceClient( keystoneAPI *KeystoneAPI, scope *gophercloud.AuthScope, ) (*openstack.OpenStack, ctrl.Result, error) { - // get public endpoint as authurl from keystone instance - authURL, err := keystoneAPI.GetEndpoint(endpoint.EndpointInternal) + // get endpoint as authurl from keystone instance + // default to internal endpoint if not specified + epInterface := endpoint.EndpointInternal + if keystoneAPI.Spec.ExternalKeystoneAPI { + epInterface = endpoint.Endpoint(endpoint.EndpointPublic) + } + authURL, err := keystoneAPI.GetEndpoint(epInterface) if err != nil { return nil, ctrl.Result{}, err } @@ -163,7 +167,7 @@ func GetScopedAdminServiceClient( h, keystoneAPI.Spec.TLS.CaBundleSecretName, 10*time.Second, - tls.InternalCABundleKey) + interfaceBundleKeys[epInterface]) if err != nil { return nil, ctrl.Result{}, err } diff --git a/api/v1beta1/keystoneapi_types.go b/api/v1beta1/keystoneapi_types.go index c65ab6c7..7c2b0ec7 100644 --- a/api/v1beta1/keystoneapi_types.go +++ b/api/v1beta1/keystoneapi_types.go @@ -53,6 +53,14 @@ const ( APIDefaultTimeout = 60 ) +var ( + // interfaceBundleKeys maps endpoint winterfaces to their corresponding key in the CA bundle secret + interfaceBundleKeys = map[endpoint.Endpoint]string{ + endpoint.EndpointInternal: tls.InternalCABundleKey, + endpoint.EndpointPublic: tls.CABundleKey, + } +) + // KeystoneAPISpec defines the desired state of KeystoneAPI type KeystoneAPISpec struct { KeystoneAPISpecCore `json:",inline"` @@ -213,6 +221,11 @@ type KeystoneAPISpecCore struct { // This is only needed when multiple realms are federated. // Config files mount path is set to /var/lib/httpd/metadata/ FederatedRealmConfig string `json:"federatedRealmConfig"` + + // +kubebuilder:validation:Optional + // +kubebuilder:default=false + // ExternalKeystoneAPI - Enable use of external Keystone API endpoints instead of deploying a local Keystone API + ExternalKeystoneAPI bool `json:"externalKeystoneAPI"` } // APIOverrideSpec to override the generated manifest of several child resources. diff --git a/config/crd/bases/keystone.openstack.org_keystoneapis.yaml b/config/crd/bases/keystone.openstack.org_keystoneapis.yaml index 3e57d2e9..77e2098c 100644 --- a/config/crd/bases/keystone.openstack.org_keystoneapis.yaml +++ b/config/crd/bases/keystone.openstack.org_keystoneapis.yaml @@ -98,6 +98,11 @@ spec: description: EnableSecureRBAC - Enable Consistent and Secure RBAC policies type: boolean + externalKeystoneAPI: + default: false + description: ExternalKeystoneAPI - Enable use of external Keystone + API endpoints instead of deploying a local Keystone API + type: boolean extraMounts: default: [] description: ExtraMounts containing conf files diff --git a/internal/controller/keystoneapi_controller.go b/internal/controller/keystoneapi_controller.go index 6f0771e7..9852ea9c 100644 --- a/internal/controller/keystoneapi_controller.go +++ b/internal/controller/keystoneapi_controller.go @@ -244,6 +244,11 @@ func (r *KeystoneAPIReconciler) Reconcile(ctx context.Context, req ctrl.Request) return r.reconcileDelete(ctx, instance, helper) } + // Check if external Keystone API is configured + if instance.Spec.ExternalKeystoneAPI { + return r.reconcileExternalKeystoneAPI(ctx, instance, helper) + } + // Handle non-deleted clusters return r.reconcileNormal(ctx, instance, helper) } @@ -451,6 +456,14 @@ func (r *KeystoneAPIReconciler) reconcileDelete(ctx context.Context, instance *k Log := r.GetLogger(ctx) Log.Info("Reconciling Service delete") + // If using external Keystone API, we don't have any resources to clean up + if instance.Spec.ExternalKeystoneAPI { + // Just remove the finalizer + controllerutil.RemoveFinalizer(instance, helper.GetFinalizer()) + Log.Info("Reconciled External Keystone API delete successfully") + return ctrl.Result{}, nil + } + // We need to allow all KeystoneEndpoint and KeystoneService processing to finish // in the case of a delete before we remove the finalizers. For instance, in the // case of the Memcached dependency, if Memcached is deleted before all Keystone @@ -747,6 +760,196 @@ func (r *KeystoneAPIReconciler) reconcileInit( return ctrl.Result{}, nil } +func (r *KeystoneAPIReconciler) reconcileExternalKeystoneAPI( + ctx context.Context, + instance *keystonev1.KeystoneAPI, + helper *helper.Helper, +) (ctrl.Result, error) { + Log := r.GetLogger(ctx) + Log.Info("Reconciling External Keystone API") + + // When using external Keystone API, we skip all deployment logic + // and just use the endpoints from the override spec + + // serviceLabels? + + // Verify override spec is valid + if instance.Spec.Override.Service == nil { + instance.Status.Conditions.Set(condition.FalseCondition( + condition.InputReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + condition.InputReadyWaitingMessage)) + return ctrl.Result{}, nil + } + + configMapVars := make(map[string]env.Setter) + + // Verify secret is available (needed for admin client operations) + ctrlResult, err := r.verifySecret(ctx, instance, helper, configMapVars) + if err != nil { + return ctrlResult, err + } else if (ctrlResult != ctrl.Result{}) { + return ctrlResult, nil + } + + // service DB is not needed + instance.Status.Conditions.MarkTrue(mariadbv1.MariaDBAccountReadyCondition, keystonev1.ExternalKeystoneAPIDBAccountMessage) + instance.Status.Conditions.MarkTrue(condition.DBReadyCondition, keystonev1.ExternalKeystoneAPIDBMessage) + instance.Status.Conditions.MarkTrue(condition.DBSyncReadyCondition, keystonev1.ExternalKeystoneAPIDBMessage) + + // RabbitMQ transportURL is not needed + instance.Status.Conditions.MarkTrue(condition.RabbitMqTransportURLReadyCondition, keystonev1.ExternalKeystoneAPIRabbitMQTransportURLMessage) + + // memcached is not needed + instance.Status.Conditions.MarkTrue(condition.MemcachedReadyCondition, keystonev1.ExternalKeystoneAPIMemcachedReadyMessage) + + // Mark service conditions as ready since they're being managed externally + instance.Status.Conditions.MarkTrue(condition.ServiceAccountReadyCondition, "External Keystone API - no service account needed") + instance.Status.Conditions.MarkTrue(condition.RoleReadyCondition, "External Keystone API - no role needed") + instance.Status.Conditions.MarkTrue(condition.RoleBindingReadyCondition, "External Keystone API - no role binding needed") + instance.Status.Conditions.MarkTrue(condition.ServiceConfigReadyCondition, keystonev1.ExternalKeystoneAPIServiceMessage) + instance.Status.Conditions.MarkTrue(condition.BootstrapReadyCondition, keystonev1.ExternalKeystoneAPIServiceMessage) + instance.Status.Conditions.MarkTrue(condition.CreateServiceReadyCondition, keystonev1.ExternalKeystoneAPIServiceMessage) + instance.Status.Conditions.MarkTrue(condition.DeploymentReadyCondition, keystonev1.ExternalKeystoneAPIServiceMessage) + instance.Status.Conditions.MarkTrue(condition.CronJobReadyCondition, keystonev1.ExternalKeystoneAPIServiceMessage) + // Set ready count to 0 since we're not deploying anything + instance.Status.ReadyCount = 0 + + // Verify TLS input (CA cert secret if provided) + err = r.verifyTLSInput(ctx, instance, helper, configMapVars) + if err != nil { + if k8s_errors.IsNotFound(err) { + // Don't return NotFound error to the caller + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + instance.Status.Conditions.MarkTrue(condition.TLSInputReadyCondition, condition.InputReadyMessage) + + // TODO: Do we need network annotations if we we dont have Keystone API? + instance.Status.Conditions.MarkTrue(condition.NetworkAttachmentsReadyCondition, keystonev1.ExternalKeystoneAPINetworkAttachmentsReadyMessage) + + // Add endpoints + + // Set API endpoints from externalKeystoneAPI spec + if instance.Status.APIEndpoints == nil { + instance.Status.APIEndpoints = map[string]string{} + } + // Copy endpoints from spec to status + for endpointType, data := range instance.Spec.Override.Service { + if data.EndpointURL != nil { + instance.Status.APIEndpoints[string(endpointType)] = *data.EndpointURL + } else { + instance.Status.Conditions.Set(condition.FalseCondition( + condition.InputReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + condition.InputReadyWaitingMessage)) + return ctrl.Result{}, nil + } + } + // Copy region from spec to status + instance.Status.Region = instance.Spec.Region + + // + // create OpenStackClient config + // + err = r.reconcileCloudConfig(ctx, helper, instance) + if err != nil { + return ctrl.Result{}, err + } + + Log.Info("Reconciled External Keystone API successfully") + return ctrl.Result{}, nil +} + +// verifySecret verifies the OpenStack secret exists and adds its hash to configMapVars +func (r *KeystoneAPIReconciler) verifySecret( + ctx context.Context, + instance *keystonev1.KeystoneAPI, + helper *helper.Helper, + configMapVars map[string]env.Setter, +) (ctrl.Result, error) { + // check for required OpenStack secret holding passwords for service/admin user and add hash to the vars map + // NOTE: VerifySecret handles the "not found" error and returns RequeueAfter ctrl.Result if so, so we don't + // need to check the error type here + hash, result, err := oko_secret.VerifySecret(ctx, types.NamespacedName{Name: instance.Spec.Secret, Namespace: instance.Namespace}, []string{"AdminPassword"}, helper.GetClient(), time.Second*10) + if err != nil { + instance.Status.Conditions.Set(condition.FalseCondition( + condition.InputReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + condition.InputReadyErrorMessage, + err.Error())) + return ctrl.Result{}, err + } else if (result != ctrl.Result{}) { + // This case is "secret not found". VerifySecret already logs a message for it. + // We treat this as a warning because it means that the service will not be able to start + // while we are waiting for the secret to be created manually by the user. + instance.Status.Conditions.Set(condition.FalseCondition( + condition.InputReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + condition.InputReadyWaitingMessage)) + return result, nil + } + + // Add hash to configMapVars if provided + if configMapVars != nil { + configMapVars[instance.Spec.Secret] = env.SetValue(hash) + } + + instance.Status.Conditions.MarkTrue(condition.InputReadyCondition, condition.InputReadyMessage) + + return ctrl.Result{}, nil +} +func (r *KeystoneAPIReconciler) verifyTLSInput( + ctx context.Context, + instance *keystonev1.KeystoneAPI, + helper *helper.Helper, + configMapVars map[string]env.Setter, +) error { + // Validate the CA cert secret if provided + if instance.Spec.TLS.CaBundleSecretName != "" { + hash, err := tls.ValidateCACertSecret( + ctx, + helper.GetClient(), + types.NamespacedName{ + Name: instance.Spec.TLS.CaBundleSecretName, + Namespace: instance.Namespace, + }, + ) + if err != nil { + if k8s_errors.IsNotFound(err) { + // Since the CA cert secret should have been manually created by the user and provided in the spec, + // we treat this as a warning because it means that the service will not be able to start. + instance.Status.Conditions.Set(condition.FalseCondition( + condition.TLSInputReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + condition.TLSInputReadyWaitingMessage, + instance.Spec.TLS.CaBundleSecretName)) + } else { + instance.Status.Conditions.Set(condition.FalseCondition( + condition.TLSInputReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + condition.TLSInputErrorMessage, + err.Error())) + } + return err + } + + if hash != "" { + if configMapVars != nil { + configMapVars[tls.CABundleKey] = env.SetValue(hash) + } + } + } + + return nil +} func (r *KeystoneAPIReconciler) reconcileUpdate(ctx context.Context) (ctrl.Result, error) { Log := r.GetLogger(ctx) Log.Info("Reconciling Service update") @@ -787,32 +990,13 @@ func (r *KeystoneAPIReconciler) reconcileNormal( // // check for required OpenStack secret holding passwords for service/admin user and add hash to the vars map - // NOTE: VerifySecret handles the "not found" error and returns RequeueAfter ctrl.Result if so, so we don't - // need to check the error type here // - hash, result, err := oko_secret.VerifySecret(ctx, types.NamespacedName{Name: instance.Spec.Secret, Namespace: instance.Namespace}, []string{"AdminPassword"}, helper.GetClient(), time.Second*10) + ctrlResult, err := r.verifySecret(ctx, instance, helper, configMapVars) if err != nil { - instance.Status.Conditions.Set(condition.FalseCondition( - condition.InputReadyCondition, - condition.ErrorReason, - condition.SeverityWarning, - condition.InputReadyErrorMessage, - err.Error())) - return ctrl.Result{}, err - } else if (result != ctrl.Result{}) { - // This case is "secret not found". VerifySecret already logs a message for it. - // We treat this as a warning because it means that the service will not be able to start - // while we are waiting for the secret to be created manually by the user. - instance.Status.Conditions.Set(condition.FalseCondition( - condition.InputReadyCondition, - condition.ErrorReason, - condition.SeverityWarning, - condition.InputReadyWaitingMessage)) - return result, nil + return ctrlResult, err + } else if (ctrlResult != ctrl.Result{}) { + return ctrlResult, nil } - configMapVars[instance.Spec.Secret] = env.SetValue(hash) - - instance.Status.Conditions.MarkTrue(condition.InputReadyCondition, condition.InputReadyMessage) // run check OpenStack secret - end @@ -970,40 +1154,14 @@ func (r *KeystoneAPIReconciler) reconcileNormal( // // TLS input validation // - // Validate the CA cert secret if provided - if instance.Spec.TLS.CaBundleSecretName != "" { - hash, err := tls.ValidateCACertSecret( - ctx, - helper.GetClient(), - types.NamespacedName{ - Name: instance.Spec.TLS.CaBundleSecretName, - Namespace: instance.Namespace, - }, - ) - if err != nil { - if k8s_errors.IsNotFound(err) { - // Since the CA cert secret should have been manually created by the user and provided in the spec, - // we treat this as a warning because it means that the service will not be able to start. - instance.Status.Conditions.Set(condition.FalseCondition( - condition.TLSInputReadyCondition, - condition.ErrorReason, - condition.SeverityWarning, - condition.TLSInputReadyWaitingMessage, - instance.Spec.TLS.CaBundleSecretName)) - return ctrl.Result{}, nil - } - instance.Status.Conditions.Set(condition.FalseCondition( - condition.TLSInputReadyCondition, - condition.ErrorReason, - condition.SeverityWarning, - condition.TLSInputErrorMessage, - err.Error())) - return ctrl.Result{}, err - } - - if hash != "" { - configMapVars[tls.CABundleKey] = env.SetValue(hash) + // Verify TLS input (CA cert secret if provided) + err = r.verifyTLSInput(ctx, instance, helper, configMapVars) + if err != nil { + if k8s_errors.IsNotFound(err) { + // Don't return NotFound error to the caller + return ctrl.Result{}, nil } + return ctrl.Result{}, err } // Validate API service certs secrets @@ -1089,7 +1247,7 @@ func (r *KeystoneAPIReconciler) reconcileNormal( } // Handle service init - ctrlResult, err := r.reconcileInit(ctx, instance, helper, serviceLabels, serviceAnnotations, memcached) + ctrlResult, err = r.reconcileInit(ctx, instance, helper, serviceLabels, serviceAnnotations, memcached) if err != nil { return ctrlResult, err } else if (ctrlResult != ctrl.Result{}) { diff --git a/internal/controller/keystoneservice_controller.go b/internal/controller/keystoneservice_controller.go index 8433d397..52548456 100644 --- a/internal/controller/keystoneservice_controller.go +++ b/internal/controller/keystoneservice_controller.go @@ -433,7 +433,7 @@ func (r *KeystoneServiceReconciler) reconcileService( instance.Spec.ServiceName, ) // If the service is not found, don't count that as an error here, - // it gets created bellow + // it gets created below if err != nil && !strings.Contains(err.Error(), openstack.ServiceNotFound) { return err }