github.com/verrazzano/verrazzano@v1.7.1/cluster-operator/controllers/vmc/sync_thanos.go (about) 1 // Copyright (c) 2023, Oracle and/or its affiliates. 2 // Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl. 3 4 package vmc 5 6 import ( 7 "context" 8 "encoding/json" 9 "fmt" 10 "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 11 12 clustersv1alpha1 "github.com/verrazzano/verrazzano/cluster-operator/apis/clusters/v1alpha1" 13 "github.com/verrazzano/verrazzano/pkg/log/vzlog" 14 "github.com/verrazzano/verrazzano/pkg/vzcr" 15 "github.com/verrazzano/verrazzano/platform-operator/constants" 16 "github.com/verrazzano/verrazzano/platform-operator/controllers/verrazzano/component/thanos" 17 "google.golang.org/protobuf/types/known/wrapperspb" 18 istionet "istio.io/api/networking/v1beta1" 19 istioclinet "istio.io/client-go/pkg/apis/networking/v1beta1" 20 appsv1 "k8s.io/api/apps/v1" 21 v1 "k8s.io/api/core/v1" 22 k8sapiext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 23 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 24 "k8s.io/apimachinery/pkg/types" 25 controllerruntime "sigs.k8s.io/controller-runtime" 26 "sigs.k8s.io/controller-runtime/pkg/client" 27 "sigs.k8s.io/yaml" 28 ) 29 30 const ( 31 serviceEntryCRDName = "serviceentries.networking.istio.io" 32 destinationRuleCRDName = "destinationrules.networking.istio.io" 33 thanosQueryDeployName = "thanos-query" 34 verrazzanoManagedLabel = "verrazzano_cluster" 35 thanosGrpcIngressPort = 443 36 37 istioVolumeAnnotation = "sidecar.istio.io/userVolume" 38 istioVolumeMountAnnotation = "sidecar.istio.io/userVolumeMount" 39 istioVolumeName = "managed-certs" 40 istioCertPath = "/etc/certs" 41 ) 42 43 // thanosServiceDiscovery represents one element in the Thanos service discovery YAML. The YAML 44 // format contains a list of thanosServiceDiscovery elements 45 // The format of this object is outlined here https://github.com/thanos-io/thanos/blob/main/docs/service-discovery.md#file-service-discovery 46 type thanosServiceDiscovery struct { 47 Targets []string `json:"targets"` 48 Labels map[string]string `json:"labels"` 49 } 50 51 const ThanosManagedClusterEndpointsConfigMap = "verrazzano-thanos-endpoints" 52 const serviceDiscoveryKey = "servicediscovery.yml" 53 54 // syncThanosQuery will perform the necessary sync to make sure Thanos Query on admin cluster can 55 // talk to Thanos Query on managed cluster (this involves updating the endpoints ConfigMap and 56 // the Istio config needed for TLS communication to managed cluster) 57 // TODO - we will also need to add the cluster's CA cert for Thanos Query to use 58 func (r *VerrazzanoManagedClusterReconciler) syncThanosQuery(ctx context.Context, 59 vmc *clustersv1alpha1.VerrazzanoManagedCluster) error { 60 61 if err := r.syncThanosQueryEndpoint(ctx, vmc); err != nil { 62 return err 63 } 64 if err := r.createOrUpdateCACertVolume(vmc, r.addCACertToDeployment); err != nil { 65 return err 66 } 67 if err := r.createOrUpdateServiceEntry(vmc.Name, vmc.Status.ThanosQueryStore, thanosGrpcIngressPort); err != nil { 68 return err 69 } 70 return r.createOrUpdateDestinationRule(vmc, vmc.Status.ThanosQueryStore, thanosGrpcIngressPort) 71 } 72 73 // syncThanosQueryEndpoint will update the config map used by Thanos Query with the managed cluster 74 // Thanos store API endpoint. 75 func (r *VerrazzanoManagedClusterReconciler) syncThanosQueryEndpoint(ctx context.Context, 76 vmc *clustersv1alpha1.VerrazzanoManagedCluster) error { 77 return r.addThanosHostIfNotPresent(ctx, vmc.Status.ThanosQueryStore, vmc.Name) 78 } 79 80 func (r *VerrazzanoManagedClusterReconciler) syncThanosQueryEndpointDelete(ctx context.Context, vmc *clustersv1alpha1.VerrazzanoManagedCluster) error { 81 if err := r.removeThanosHostFromConfigMap(ctx, vmc.Name, r.log); err != nil { 82 return err 83 } 84 if err := r.deleteDestinationRule(vmc.Name); err != nil { 85 return err 86 } 87 if err := r.deleteServiceEntry(vmc.Name); err != nil { 88 return err 89 } 90 return nil 91 } 92 93 func (r *VerrazzanoManagedClusterReconciler) removeThanosHostFromConfigMap(ctx context.Context, vmcName string, log vzlog.VerrazzanoLogger) error { 94 // Check if Thanos is enabled before getting the endpoints ConfigMap 95 // to avoid repeating error message when Thanos is disabled 96 thanosEnabled, err := r.isThanosEnabled() 97 if err != nil || !thanosEnabled { 98 return err 99 } 100 configMap, err := r.getThanosEndpointsConfigMap(ctx) 101 if err != nil { 102 return err 103 } 104 serviceDiscoveryList, err := parseThanosEndpointsConfigMap(configMap, log) 105 if err != nil { 106 // nothing to do - we can't remove entries from an invalid config map - next time an add happens, 107 // we will try to automatically resolve the issue 108 return nil 109 } 110 111 for i, serviceDiscovery := range serviceDiscoveryList { 112 if findLabelName(serviceDiscovery, vmcName) { 113 serviceDiscoveryList = append(serviceDiscoveryList[:i], serviceDiscoveryList[i+1:]...) 114 return r.createOrUpdateThanosEndpointConfigMap(ctx, serviceDiscoveryList, vmcName, configMap) 115 } 116 } 117 return nil 118 } 119 120 func (r *VerrazzanoManagedClusterReconciler) createOrUpdateThanosEndpointConfigMap(ctx context.Context, serviceDiscoveryList []*thanosServiceDiscovery, vmcName string, configMap *v1.ConfigMap) error { 121 newServiceDiscoveryYaml, err := yaml.Marshal(serviceDiscoveryList) 122 if err != nil { 123 return r.log.ErrorfNewErr("Failed to serialize Thanos endpoints config map content for VMC %s: %v", vmcName, err) 124 } 125 result, err := controllerruntime.CreateOrUpdate(ctx, r.Client, configMap, func() error { 126 configMap.Data[serviceDiscoveryKey] = string(newServiceDiscoveryYaml) 127 return nil 128 }) 129 if err != nil { 130 return r.log.ErrorfNewErr("Failed to update Thanos endpoints config map after removing endpoint for VMC %s: %v", vmcName, err) 131 } 132 if result != controllerutil.OperationResultNone { 133 r.log.Infof("The Thanos endpoint Configmap %s has been modified for VMC %s", configMap.Name, vmcName) 134 } 135 return nil 136 } 137 138 func (r *VerrazzanoManagedClusterReconciler) addThanosHostIfNotPresent(ctx context.Context, host, vmcName string) error { 139 configMap, err := r.getThanosEndpointsConfigMap(ctx) 140 if err != nil { 141 return err 142 } 143 serviceDiscoveryList, err := parseThanosEndpointsConfigMap(configMap, r.log) 144 if err != nil { 145 // We will wipe out and repopulate the config map if it could not be parsed 146 r.log.Info("Clearing and repopulating Thanos endpoints ConfigMap due to parse error") 147 serviceDiscoveryList = []*thanosServiceDiscovery{} 148 } 149 hostEndpoint := toGrpcTarget(host) 150 151 for i, serviceDiscovery := range serviceDiscoveryList { 152 if findLabelAndHost(serviceDiscovery, hostEndpoint, vmcName) { 153 // already exists, nothing to be added 154 r.log.Debugf("Managed cluster endpoint %s is already present in the Thanos endpoints config map", hostEndpoint) 155 return nil 156 } 157 if findLabelName(serviceDiscovery, vmcName) { 158 // label exists, but host has changed 159 r.log.Debugf("Modifying managed cluster endpoint %s to Thanos endpoints for VMC %s", hostEndpoint, vmcName) 160 serviceDiscoveryList[i] = &thanosServiceDiscovery{ 161 Targets: []string{hostEndpoint}, 162 Labels: serviceDiscovery.Labels, 163 } 164 return r.createOrUpdateThanosEndpointConfigMap(ctx, serviceDiscoveryList, vmcName, configMap) 165 } 166 } 167 // not found, add this host endpoint and update the config map 168 r.log.Debugf("Adding managed cluster endpoint %s to Thanos endpoints config map", hostEndpoint) 169 serviceDiscoveryList = append(serviceDiscoveryList, &thanosServiceDiscovery{ 170 Targets: []string{hostEndpoint}, 171 Labels: map[string]string{ 172 verrazzanoManagedLabel: vmcName, 173 }, 174 }) 175 return r.createOrUpdateThanosEndpointConfigMap(ctx, serviceDiscoveryList, vmcName, configMap) 176 } 177 178 func findLabelAndHost(serviceDiscovery *thanosServiceDiscovery, host, name string) bool { 179 if val, ok := serviceDiscovery.Labels[verrazzanoManagedLabel]; !ok || val != name { 180 return false 181 } 182 for _, target := range serviceDiscovery.Targets { 183 if target == host { 184 return true 185 } 186 } 187 return false 188 } 189 190 // findLabelName parses the service discovery labels and matches it with a given name 191 func findLabelName(serviceDiscovery *thanosServiceDiscovery, name string) bool { 192 val, ok := serviceDiscovery.Labels[verrazzanoManagedLabel] 193 return ok && val == name 194 } 195 196 func parseThanosEndpointsConfigMap(configMap *v1.ConfigMap, log vzlog.VerrazzanoLogger) ([]*thanosServiceDiscovery, error) { 197 // ConfigMap format for Thanos endpoints is 198 // servicediscovery.yml: | 199 // - targets: 200 // - example.com:443 201 // labels: 202 // verrazzano_cluster: managed 203 // The format is outlined here: https://github.com/thanos-io/thanos/blob/main/docs/service-discovery.md#file-service-discovery 204 serviceDiscoveryYaml, exists := configMap.Data[serviceDiscoveryKey] 205 serviceDiscoveryArray := []*thanosServiceDiscovery{} 206 var err error 207 if exists { 208 err = yaml.Unmarshal([]byte(serviceDiscoveryYaml), &serviceDiscoveryArray) 209 // TODO if parse fails wipe it out and let it be repopulated 210 if err != nil { 211 return nil, log.ErrorfNewErr("Failed to parse Thanos endpoints config map %s/%s, error: %v", configMap.Namespace, configMap.Name, err) 212 } 213 } 214 return serviceDiscoveryArray, nil 215 } 216 217 func (r *VerrazzanoManagedClusterReconciler) getThanosEndpointsConfigMap(ctx context.Context) (*v1.ConfigMap, error) { 218 configMapNsn := types.NamespacedName{ 219 Namespace: thanos.ComponentNamespace, 220 Name: ThanosManagedClusterEndpointsConfigMap, 221 } 222 configMap := v1.ConfigMap{} 223 if err := r.Get(ctx, configMapNsn, &configMap); err != nil { 224 r.log.Errorf("failed to fetch the Thanos endpoints ConfigMap %s/%s, %v", configMapNsn.Namespace, configMapNsn.Name, err) 225 return nil, err 226 } 227 return &configMap, nil 228 } 229 230 func (r *VerrazzanoManagedClusterReconciler) isThanosEnabled() (bool, error) { 231 vz, err := r.getVerrazzanoResource() 232 if err != nil { 233 r.log.Errorf("Failed to retrieve Verrazzano CR: %v", err) 234 return false, err 235 } 236 return vzcr.IsThanosEnabled(vz), nil 237 } 238 239 func toGrpcTarget(hostname string) string { 240 return fmt.Sprintf("%s:%d", hostname, thanosGrpcIngressPort) 241 } 242 243 // createOrUpdateCACertVolume updates a volume on the Istio Proxy sidecar on the Thanos Deployment to supply CA certs from managed clusters 244 // This CA cert will be applied to the Destination rule to allow TLS communication to managed cluster query endpoints 245 func (r *VerrazzanoManagedClusterReconciler) createOrUpdateCACertVolume(vmc *clustersv1alpha1.VerrazzanoManagedCluster, mutation func(*appsv1.Deployment, *clustersv1alpha1.VerrazzanoManagedCluster) error) error { 246 queryDeploy := appsv1.Deployment{ 247 ObjectMeta: metav1.ObjectMeta{ 248 Namespace: constants.VerrazzanoMonitoringNamespace, 249 Name: thanosQueryDeployName, 250 }, 251 } 252 _, err := controllerruntime.CreateOrUpdate(context.TODO(), r.Client, &queryDeploy, func() error { 253 return mutation(&queryDeploy, vmc) 254 }) 255 return err 256 } 257 258 func (r *VerrazzanoManagedClusterReconciler) addCACertToDeployment(queryDeploy *appsv1.Deployment, vmc *clustersv1alpha1.VerrazzanoManagedCluster) error { 259 if queryDeploy.Spec.Template.ObjectMeta.Annotations == nil { 260 queryDeploy.Spec.Template.ObjectMeta.Annotations = map[string]string{} 261 } 262 istioVolume, err := r.unmarshalIstioVolumesAnnotation(*queryDeploy) 263 if err != nil { 264 // Clear the Volume annotation because it has been corrupted 265 delete(queryDeploy.Spec.Template.ObjectMeta.Annotations, istioVolumeAnnotation) 266 return nil 267 } 268 269 if err := r.createCAVolume(queryDeploy, istioVolume, vmc); err != nil { 270 return err 271 } 272 return r.createCAVolumeMount(queryDeploy) 273 } 274 275 // createOrUpdateServiceEntry ensures that an Istio ServiceEntry exists for a managed cluster Thanos endpoint. The ServiceEntry is 276 // used along with a DestinationRule to initiate TLS to the managed cluster ingress. Skip processing if the ServiceEntry CRD 277 // does not exist in the cluster. 278 func (r *VerrazzanoManagedClusterReconciler) createOrUpdateServiceEntry(name, host string, port uint32) error { 279 isInstalled, err := r.isCRDInstalled(serviceEntryCRDName) 280 if err != nil { 281 return r.log.ErrorfNewErr("Unable to determine if CRD %s is installed: %v", serviceEntryCRDName, err) 282 } 283 if !isInstalled { 284 r.log.Debugf("CRD %s does not exist in cluster, skipping creating/updating ServiceEntry", serviceEntryCRDName) 285 return nil 286 } 287 288 // NOTE: We cannot use controller-runtime CreateOrUpdate here because DeepEqual does not work with protobuf-generated types 289 se := &istioclinet.ServiceEntry{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: constants.VerrazzanoMonitoringNamespace}} 290 291 // get the ServiceEntry, if it exists we update it, if it does not exist we create it 292 err = r.Client.Get(context.TODO(), client.ObjectKey{Namespace: constants.VerrazzanoMonitoringNamespace, Name: name}, se) 293 if client.IgnoreNotFound(err) != nil { 294 return r.log.ErrorfNewErr("Unable to get ServiceEntry %s/%s: %v", constants.VerrazzanoMonitoringNamespace, name, err) 295 } 296 if err == nil { 297 // we should do some basic fields checks and only update if there are changes, but for now this will have to do 298 populateServiceEntry(se, host, port) 299 if err = r.Client.Update(context.TODO(), se); err != nil { 300 return r.log.ErrorfNewErr("Unable to update ServiceEntry %s/%s: %v", constants.VerrazzanoMonitoringNamespace, name, err) 301 } 302 return nil 303 } 304 305 populateServiceEntry(se, host, port) 306 if err = r.Client.Create(context.TODO(), se); err != nil { 307 return r.log.ErrorfNewErr("Unable to create ServiceEntry %s/%s: %v", constants.VerrazzanoMonitoringNamespace, name, err) 308 } 309 310 return nil 311 } 312 313 func populateServiceEntry(se *istioclinet.ServiceEntry, host string, port uint32) { 314 se.Spec.Hosts = []string{host} 315 se.Spec.Ports = []*istionet.ServicePort{ 316 { 317 Name: "grpc", 318 Number: port, 319 TargetPort: port, 320 Protocol: "GRPC", 321 }, 322 } 323 se.Spec.Resolution = istionet.ServiceEntry_DNS 324 } 325 326 // createOrUpdateDestinationRule ensures that an Istio DestinationRule exists for a managed cluster Thanos endpoint. The DestinationRule is 327 // used along with a ServiceEntry to initiate TLS to the managed cluster ingress. Skip processing if the DestinationRule CRD 328 // does not exist in the cluster. 329 func (r *VerrazzanoManagedClusterReconciler) createOrUpdateDestinationRule(vmc *clustersv1alpha1.VerrazzanoManagedCluster, host string, port uint32) error { 330 isInstalled, err := r.isCRDInstalled(destinationRuleCRDName) 331 if err != nil { 332 r.log.Errorf("Unable to determine if CRD %s is installed: %v", destinationRuleCRDName, err) 333 return err 334 } 335 if !isInstalled { 336 r.log.Debugf("CRD %s does not exist in cluster, skipping creating/updating DestinationRule", destinationRuleCRDName) 337 return nil 338 } 339 340 // NOTE: We cannot use controller-runtime CreateOrUpdate here because DeepEqual does not work with protobuf-generated types 341 dr := &istioclinet.DestinationRule{ObjectMeta: metav1.ObjectMeta{Name: vmc.Name, Namespace: constants.VerrazzanoMonitoringNamespace}} 342 343 // get the DestinationRule, if it exists we update it, if it does not exist we create it 344 err = r.Client.Get(context.TODO(), client.ObjectKey{Namespace: constants.VerrazzanoMonitoringNamespace, Name: vmc.Name}, dr) 345 if client.IgnoreNotFound(err) != nil { 346 return r.log.ErrorfNewErr("Unable to get DestinationRule %s/%s: %v", constants.VerrazzanoMonitoringNamespace, vmc.Name, err) 347 } 348 if err == nil { 349 // we should do some basic fields checks and only update if there are changes, but for now this will have to do 350 populateDestinationRule(dr, host, port, vmc) 351 if err = r.Client.Update(context.TODO(), dr); err != nil { 352 return r.log.ErrorfNewErr("Unable to update DestinationRule %s/%s: %v", constants.VerrazzanoMonitoringNamespace, vmc.Name, err) 353 } 354 return nil 355 } 356 357 populateDestinationRule(dr, host, port, vmc) 358 if err = r.Client.Create(context.TODO(), dr); err != nil { 359 return r.log.ErrorfNewErr("Unable to create DestinationRule %s/%s: %v", constants.VerrazzanoMonitoringNamespace, vmc.Name, err) 360 } 361 362 return nil 363 } 364 365 func populateDestinationRule(dr *istioclinet.DestinationRule, host string, port uint32, vmc *clustersv1alpha1.VerrazzanoManagedCluster) { 366 dr.Spec.Host = host 367 dr.Spec.TrafficPolicy = &istionet.TrafficPolicy{ 368 PortLevelSettings: []*istionet.TrafficPolicy_PortTrafficPolicy{ 369 { 370 Port: &istionet.PortSelector{ 371 Number: port, 372 }, 373 Tls: &istionet.ClientTLSSettings{ 374 Mode: istionet.ClientTLSSettings_SIMPLE, 375 InsecureSkipVerify: wrapperspb.Bool(false), 376 CaCertificates: fmt.Sprintf("%s/%s", istioCertPath, getCAKey(vmc)), 377 Sni: host, 378 }, 379 }, 380 }, 381 } 382 } 383 384 // isCRDInstalled returns true if the named CRD exists in the cluster, otherwise false. 385 func (r *VerrazzanoManagedClusterReconciler) isCRDInstalled(crdName string) (bool, error) { 386 crd := &k8sapiext.CustomResourceDefinition{} 387 err := r.Client.Get(context.TODO(), client.ObjectKey{Name: crdName}, crd) 388 if client.IgnoreNotFound(err) != nil { 389 return false, err 390 } 391 return err == nil, nil 392 } 393 394 // deleteServiceEntry deletes an Istio ServiceEntry. No error is returned if the ServiceEntry is not found. 395 func (r *VerrazzanoManagedClusterReconciler) deleteServiceEntry(name string) error { 396 isInstalled, err := r.isCRDInstalled(serviceEntryCRDName) 397 if err != nil { 398 r.log.Errorf("Unable to determine if CRD %s is installed: %v", serviceEntryCRDName, err) 399 return err 400 } 401 if !isInstalled { 402 r.log.Debugf("CRD %s does not exist in cluster, skipping creating/updating DestinationRule", serviceEntryCRDName) 403 return nil 404 } 405 406 se := &istioclinet.ServiceEntry{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: constants.VerrazzanoMonitoringNamespace}} 407 err = r.Client.Delete(context.TODO(), se) 408 return client.IgnoreNotFound(err) 409 } 410 411 // deleteDestinationRule deletes an Istio DestinationRule. No error is returned if the DestinationRule is not found. 412 func (r *VerrazzanoManagedClusterReconciler) deleteDestinationRule(name string) error { 413 isInstalled, err := r.isCRDInstalled(destinationRuleCRDName) 414 if err != nil { 415 r.log.Errorf("Unable to determine if CRD %s is installed: %v", destinationRuleCRDName, err) 416 return err 417 } 418 if !isInstalled { 419 r.log.Debugf("CRD %s does not exist in cluster, skipping creating/updating DestinationRule", destinationRuleCRDName) 420 return nil 421 } 422 423 dr := &istioclinet.DestinationRule{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: constants.VerrazzanoMonitoringNamespace}} 424 err = r.Client.Delete(context.TODO(), dr) 425 return client.IgnoreNotFound(err) 426 } 427 428 // createCAVolumeMount Adds the CA Volume mount as an Istio annotation to the deployment if it does not exist 429 func (r *VerrazzanoManagedClusterReconciler) createCAVolumeMount(deployment *appsv1.Deployment) error { 430 if _, ok := deployment.Spec.Template.Annotations[istioVolumeMountAnnotation]; !ok { 431 volumeMount := []v1.VolumeMount{ 432 { 433 Name: istioVolumeName, 434 MountPath: istioCertPath, 435 }, 436 } 437 volumeMountJSON, err := json.Marshal(volumeMount) 438 if err != nil { 439 return r.log.ErrorfNewErr("Failed to marshal VolumeMount object for Volume %s Istio Annotation: %v", istioVolumeName, err) 440 } 441 deployment.Spec.Template.Annotations[istioVolumeMountAnnotation] = string(volumeMountJSON) 442 } 443 return nil 444 } 445 446 // unmarshalIstioVolumesAnnotations returns the Volumes pod annotations from a deployment object 447 func (r *VerrazzanoManagedClusterReconciler) unmarshalIstioVolumesAnnotation(deployment appsv1.Deployment) (v1.Volume, error) { 448 istioVolume := []v1.Volume{{Name: istioVolumeName}} 449 podAnnotations := deployment.Spec.Template.ObjectMeta.Annotations 450 if volumeJSON, volumeOk := podAnnotations[istioVolumeAnnotation]; volumeOk { 451 err := json.Unmarshal([]byte(volumeJSON), &istioVolume) 452 if err != nil { 453 return istioVolume[0], r.log.ErrorfNewErr("Failed to unmarshal the Istio volume annotation, clearing the volume annotations due to corruption: %v", err) 454 } 455 } 456 return istioVolume[0], nil 457 } 458 459 // createCAVolume adds the CA mount given a Volume object from the Istio annotations and creates the annotation on the deployment 460 func (r *VerrazzanoManagedClusterReconciler) createCAVolume(deployment *appsv1.Deployment, volume v1.Volume, vmc *clustersv1alpha1.VerrazzanoManagedCluster) error { 461 if volume.Secret == nil { 462 volume.Secret = &v1.SecretVolumeSource{ 463 SecretName: constants.PromManagedClusterCACertsSecretName, 464 } 465 } 466 volumeJSON, err := json.Marshal([]v1.Volume{volume}) 467 if err != nil { 468 return r.log.ErrorfNewErr("Failed to marshal Volume %s Istio Annotation: %v", istioVolumeName, err) 469 } 470 deployment.Spec.Template.Annotations[istioVolumeAnnotation] = string(volumeJSON) 471 return nil 472 }