go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config_service/internal/validation/validate_test.go (about)

     1  // Copyright 2023 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package validation
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"crypto/rand"
    21  	"encoding/base64"
    22  	"encoding/json"
    23  	"errors"
    24  	"fmt"
    25  	"io"
    26  	"net/http"
    27  	"net/http/httptest"
    28  	"testing"
    29  
    30  	"cloud.google.com/go/storage"
    31  	"github.com/golang/mock/gomock"
    32  	"github.com/klauspost/compress/gzip"
    33  	"google.golang.org/grpc/codes"
    34  	"google.golang.org/grpc/status"
    35  
    36  	"go.chromium.org/luci/common/gcloud/gs"
    37  	cfgcommonpb "go.chromium.org/luci/common/proto/config"
    38  	"go.chromium.org/luci/common/testing/prpctest"
    39  	"go.chromium.org/luci/config"
    40  	"go.chromium.org/luci/config/validation"
    41  	"go.chromium.org/luci/server/auth/authtest"
    42  
    43  	"go.chromium.org/luci/config_service/internal/clients"
    44  	"go.chromium.org/luci/config_service/internal/model"
    45  	"go.chromium.org/luci/config_service/testutil"
    46  
    47  	. "github.com/smartystreets/goconvey/convey"
    48  	. "go.chromium.org/luci/common/testing/assertions"
    49  )
    50  
    51  type testConsumerServer struct {
    52  	cfgcommonpb.UnimplementedConsumerServer
    53  	fileToExpectedURL    map[string]string
    54  	fileToValidationMsgs map[string][]*cfgcommonpb.ValidationResult_Message
    55  	err                  error
    56  }
    57  
    58  func (srv *testConsumerServer) ValidateConfigs(ctx context.Context, req *cfgcommonpb.ValidateConfigsRequest) (*cfgcommonpb.ValidationResult, error) {
    59  	if srv.err != nil {
    60  		return nil, srv.err
    61  	}
    62  	result := &cfgcommonpb.ValidationResult{}
    63  	for _, file := range req.GetFiles().GetFiles() {
    64  		path := file.GetPath()
    65  		switch expectedURL, ok := srv.fileToExpectedURL[path]; {
    66  		case !ok:
    67  			return nil, status.Errorf(codes.InvalidArgument, "unexpected file %q", path)
    68  		case file.GetSignedUrl() != expectedURL:
    69  			return nil, status.Errorf(codes.InvalidArgument, "expected url %q; got %q", expectedURL, file.GetSignedUrl())
    70  		}
    71  		switch msgs, ok := srv.fileToValidationMsgs[path]; {
    72  		case !ok:
    73  			return nil, status.Errorf(codes.InvalidArgument, "unexpected file %q", path)
    74  		default:
    75  			result.Messages = append(result.Messages, msgs...)
    76  		}
    77  	}
    78  
    79  	return result, nil
    80  }
    81  
    82  type mockFinder struct {
    83  	mapping map[string][]*model.Service
    84  }
    85  
    86  func (m *mockFinder) FindInterestedServices(_ context.Context, _ config.Set, filePath string) []*model.Service {
    87  	return m.mapping[filePath]
    88  }
    89  
    90  type testFile struct {
    91  	path    string
    92  	gsPath  gs.Path
    93  	content []byte
    94  }
    95  
    96  func (tf testFile) GetPath() string {
    97  	return tf.path
    98  }
    99  
   100  func (tf testFile) GetGSPath() gs.Path {
   101  	return tf.gsPath
   102  }
   103  
   104  func (tf testFile) GetRawContent(context.Context) ([]byte, error) {
   105  	return tf.content, nil
   106  }
   107  
   108  var _ File = testFile{} // ensure testFile implements File interface.
   109  
   110  func TestValidate(t *testing.T) {
   111  	t.Parallel()
   112  
   113  	Convey("Validate", t, func() {
   114  		ctx := testutil.SetupContext()
   115  		ctx = authtest.MockAuthConfig(ctx)
   116  		ctl := gomock.NewController(t)
   117  		mockGsClient := clients.NewMockGsClient(ctl)
   118  		finder := &mockFinder{}
   119  		v := &Validator{
   120  			GsClient: mockGsClient,
   121  			Finder:   finder,
   122  		}
   123  
   124  		Convey("Single File", func() {
   125  			cs := config.MustProjectSet("my-project")
   126  			const filePath = "sub/foo.cfg"
   127  			const serviceName = "my-service"
   128  			ts := &prpctest.Server{}
   129  			srv := &testConsumerServer{}
   130  			cfgcommonpb.RegisterConsumerServer(ts, srv)
   131  			ts.Start(ctx)
   132  			defer ts.Close()
   133  
   134  			Convey("No service to validate", func() {
   135  				res, err := v.Validate(ctx, cs, []File{testFile{path: filePath}})
   136  				So(err, ShouldBeNil)
   137  				So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{})
   138  			})
   139  			Convey("Validate", func() {
   140  				const singedURL = "https://example.com/signed"
   141  				var recordedOpts *storage.SignedURLOptions
   142  				mockGsClient.EXPECT().SignedURL(
   143  					gomock.Eq("test-bucket"),
   144  					gomock.Eq("test-obj"),
   145  					gomock.AssignableToTypeOf(recordedOpts),
   146  				).DoAndReturn(
   147  					func(_, _ string, opts *storage.SignedURLOptions) (string, error) {
   148  						recordedOpts = opts
   149  						return singedURL, nil
   150  					},
   151  				)
   152  
   153  				finder.mapping = map[string][]*model.Service{
   154  					filePath: {
   155  						{
   156  							Name: serviceName,
   157  							Info: &cfgcommonpb.Service{
   158  								Id:       serviceName,
   159  								Hostname: ts.Host,
   160  							},
   161  						},
   162  					},
   163  				}
   164  				Convey("Success", func() {
   165  					srv.fileToExpectedURL = map[string]string{
   166  						filePath: singedURL,
   167  					}
   168  					srv.fileToValidationMsgs = map[string][]*cfgcommonpb.ValidationResult_Message{
   169  						filePath: {
   170  							{
   171  								Path:     filePath,
   172  								Severity: cfgcommonpb.ValidationResult_ERROR,
   173  								Text:     "bad bad bad",
   174  							},
   175  						},
   176  					}
   177  
   178  					res, err := v.Validate(ctx, cs, []File{
   179  						testFile{path: filePath, gsPath: gs.MakePath("test-bucket", "test-obj")},
   180  					})
   181  					So(err, ShouldBeNil)
   182  					So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{
   183  						Messages: []*cfgcommonpb.ValidationResult_Message{
   184  							{
   185  								Path:     filePath,
   186  								Severity: cfgcommonpb.ValidationResult_ERROR,
   187  								Text:     "bad bad bad",
   188  							},
   189  						},
   190  					})
   191  					So(recordedOpts.Method, ShouldEqual, http.MethodGet)
   192  					So(recordedOpts.Headers, ShouldBeEmpty)
   193  				})
   194  				Convey("Error", func() {
   195  					srv.err = status.Errorf(codes.Internal, "internal server error")
   196  
   197  					res, err := v.Validate(ctx, cs, []File{
   198  						testFile{path: filePath, gsPath: gs.MakePath("test-bucket", "test-obj")},
   199  					})
   200  					So(err, ShouldErrLike, "failed to validate configs against service \"my-service\"")
   201  					So(res, ShouldBeNil)
   202  				})
   203  			})
   204  
   205  			Convey("Validate against self", func() {
   206  				v.SelfRuleSet = validation.NewRuleSet()
   207  				finder.mapping = map[string][]*model.Service{
   208  					filePath: {
   209  						{
   210  							Name: testutil.AppID,
   211  							Info: &cfgcommonpb.Service{
   212  								Id:       testutil.AppID,
   213  								Hostname: ts.Host,
   214  							},
   215  						},
   216  					},
   217  				}
   218  				Convey("Succeeds", func() {
   219  					var validated bool
   220  					var recordedContent []byte
   221  					v.SelfRuleSet.Add(string(cs), filePath, func(vCtx *validation.Context, configSet, path string, content []byte) error {
   222  						validated = true
   223  						recordedContent = content
   224  						vCtx.Errorf("bad config")
   225  						return nil
   226  					})
   227  					tf := testFile{
   228  						path:    filePath,
   229  						content: []byte("This is config content"),
   230  					}
   231  					res, err := v.Validate(ctx, cs, []File{tf})
   232  					So(err, ShouldBeNil)
   233  					So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{
   234  						Messages: []*cfgcommonpb.ValidationResult_Message{
   235  							{
   236  								Path:     filePath,
   237  								Severity: cfgcommonpb.ValidationResult_ERROR,
   238  								Text:     "in \"sub/foo.cfg\": bad config",
   239  							},
   240  						},
   241  					})
   242  					So(validated, ShouldBeTrue)
   243  					So(recordedContent, ShouldEqual, tf.content)
   244  				})
   245  				Convey("Error", func() {
   246  					v.SelfRuleSet.Add(string(cs), filePath, func(vCtx *validation.Context, configSet, path string, content []byte) error {
   247  						return errors.New("something went wrong")
   248  					})
   249  					tf := testFile{
   250  						path:    filePath,
   251  						content: []byte("This is config content"),
   252  					}
   253  					res, err := v.Validate(ctx, cs, []File{tf})
   254  					So(err, ShouldErrLike, "something went wrong")
   255  					So(res, ShouldBeNil)
   256  				})
   257  			})
   258  		})
   259  
   260  		Convey("Multiple files and services", func() {
   261  			// test cases:
   262  			//  4 files: a,b,c,d and 2 services: foo and bar
   263  			//  file a: validated by both foo and bar, foo output 1 warning and bar
   264  			//          output 1 error.
   265  			//  file b: validated by foo, no error or warning
   266  			//  file c: validated by bar, bar returns 1 warning and 1 error.
   267  			//  file d: no service can validate file d
   268  			fileA := testFile{
   269  				path:   "a.cfg",
   270  				gsPath: gs.MakePath("test-bucket", "test-object-a"),
   271  			}
   272  			fileB := testFile{
   273  				path:   "b.cfg",
   274  				gsPath: gs.MakePath("test-bucket", "test-object-b"),
   275  			}
   276  			fileC := testFile{
   277  				path:   "c.cfg",
   278  				gsPath: gs.MakePath("test-bucket", "test-object-c"),
   279  			}
   280  			fileD := testFile{
   281  				path:   "d.cfg",
   282  				gsPath: gs.MakePath("test-bucket", "test-object-d"),
   283  			}
   284  
   285  			testServerFoo := &prpctest.Server{}
   286  			consumerServerFoo := &testConsumerServer{}
   287  			cfgcommonpb.RegisterConsumerServer(testServerFoo, consumerServerFoo)
   288  			testServerFoo.Start(ctx)
   289  			defer testServerFoo.Close()
   290  
   291  			testServerBar := &prpctest.Server{}
   292  			consumerServerBar := &testConsumerServer{}
   293  			cfgcommonpb.RegisterConsumerServer(testServerBar, consumerServerBar)
   294  			testServerBar.Start(ctx)
   295  			defer testServerBar.Close()
   296  
   297  			serviceFoo := &model.Service{
   298  				Name: "foo",
   299  				Info: &cfgcommonpb.Service{
   300  					Id:       "foo",
   301  					Hostname: testServerFoo.Host,
   302  				},
   303  			}
   304  			serviceBar := &model.Service{
   305  				Name: "bar",
   306  				Info: &cfgcommonpb.Service{
   307  					Id:       "bar",
   308  					Hostname: testServerBar.Host,
   309  				},
   310  			}
   311  
   312  			const signedURLPrefix = "https://example.com/signed"
   313  			mockGsClient.EXPECT().SignedURL(
   314  				gomock.Eq("test-bucket"),
   315  				gomock.Any(),
   316  				gomock.Any(),
   317  			).DoAndReturn(
   318  				func(bucket, object string, _ *storage.SignedURLOptions) (string, error) {
   319  					return fmt.Sprintf("%s/%s/%s", signedURLPrefix, bucket, object), nil
   320  				},
   321  			).AnyTimes()
   322  
   323  			finder.mapping = map[string][]*model.Service{
   324  				fileA.path: {serviceFoo, serviceBar},
   325  				fileB.path: {serviceFoo},
   326  				fileC.path: {serviceBar},
   327  				// No service can validate fileD.
   328  			}
   329  
   330  			consumerServerFoo.fileToExpectedURL = map[string]string{
   331  				fileA.path: "https://example.com/signed/test-bucket/test-object-a",
   332  				fileB.path: "https://example.com/signed/test-bucket/test-object-b",
   333  			}
   334  			consumerServerBar.fileToExpectedURL = map[string]string{
   335  				fileA.path: "https://example.com/signed/test-bucket/test-object-a",
   336  				fileC.path: "https://example.com/signed/test-bucket/test-object-c",
   337  			}
   338  			consumerServerFoo.fileToValidationMsgs = map[string][]*cfgcommonpb.ValidationResult_Message{
   339  				fileA.path: {
   340  					{
   341  						Path:     fileA.path,
   342  						Severity: cfgcommonpb.ValidationResult_WARNING,
   343  						Text:     "warning for file a from service foo",
   344  					},
   345  				},
   346  				fileB.path: {}, // No validation error for fileB
   347  			}
   348  			consumerServerBar.fileToValidationMsgs = map[string][]*cfgcommonpb.ValidationResult_Message{
   349  				fileA.path: {
   350  					{
   351  						Path:     fileA.path,
   352  						Severity: cfgcommonpb.ValidationResult_ERROR,
   353  						Text:     "error for file a from service bar",
   354  					},
   355  				},
   356  				fileC.path: {
   357  					{
   358  						Path:     fileC.path,
   359  						Severity: cfgcommonpb.ValidationResult_WARNING,
   360  						Text:     "warning for file c from service bar",
   361  					},
   362  					{
   363  						Path:     fileC.path,
   364  						Severity: cfgcommonpb.ValidationResult_ERROR,
   365  						Text:     "error for file c from service bar",
   366  					},
   367  				},
   368  			}
   369  
   370  			res, err := v.Validate(ctx, config.MustProjectSet("my-project"), []File{fileA, fileB, fileC, fileD})
   371  			So(err, ShouldBeNil)
   372  			So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{
   373  				Messages: []*cfgcommonpb.ValidationResult_Message{
   374  					{
   375  						Path:     fileA.path,
   376  						Severity: cfgcommonpb.ValidationResult_ERROR,
   377  						Text:     "error for file a from service bar",
   378  					},
   379  					{
   380  						Path:     fileA.path,
   381  						Severity: cfgcommonpb.ValidationResult_WARNING,
   382  						Text:     "warning for file a from service foo",
   383  					},
   384  					{
   385  						Path:     fileC.path,
   386  						Severity: cfgcommonpb.ValidationResult_ERROR,
   387  						Text:     "error for file c from service bar",
   388  					},
   389  					{
   390  						Path:     fileC.path,
   391  						Severity: cfgcommonpb.ValidationResult_WARNING,
   392  						Text:     "warning for file c from service bar",
   393  					},
   394  				},
   395  			})
   396  		})
   397  	})
   398  }
   399  
   400  func TestValidateLegacy(t *testing.T) {
   401  	t.Parallel()
   402  
   403  	Convey("Validate using legacy protocol", t, func() {
   404  		ctx := testutil.SetupContext()
   405  		ctx = authtest.MockAuthConfig(ctx)
   406  		ctl := gomock.NewController(t)
   407  		mockGsClient := clients.NewMockGsClient(ctl)
   408  		finder := &mockFinder{}
   409  		v := &Validator{
   410  			GsClient: mockGsClient,
   411  			Finder:   finder,
   412  		}
   413  
   414  		var srvResponse []byte
   415  		var srvErrMsg string
   416  		var capturedRequestBody []byte
   417  		var capturedRequestHeader http.Header
   418  		legacyTestSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   419  			capturedRequestHeader = r.Header
   420  			var err error
   421  			if capturedRequestBody, err = io.ReadAll(r.Body); err != nil {
   422  				w.WriteHeader(http.StatusInternalServerError)
   423  				fmt.Fprintf(w, "%s", err)
   424  				return
   425  			}
   426  			if srvErrMsg != "" {
   427  				w.WriteHeader(http.StatusInternalServerError)
   428  				fmt.Fprint(w, srvErrMsg)
   429  				return
   430  			}
   431  			if _, err := w.Write(srvResponse); err != nil {
   432  				w.WriteHeader(http.StatusInternalServerError)
   433  				fmt.Fprintf(w, "failed to write response: %s", err)
   434  			}
   435  		}))
   436  		defer legacyTestSrv.Close()
   437  
   438  		cs := config.MustProjectSet("my-project")
   439  		const filePath = "sub/foo.cfg"
   440  		const serviceName = "my-service"
   441  		finder.mapping = map[string][]*model.Service{
   442  			filePath: {
   443  				{
   444  					Name: serviceName,
   445  					Info: &cfgcommonpb.Service{
   446  						Id:          serviceName,
   447  						MetadataUrl: legacyTestSrv.URL,
   448  					},
   449  					LegacyMetadata: &cfgcommonpb.ServiceDynamicMetadata{
   450  						Version: "1.0",
   451  						Validation: &cfgcommonpb.Validator{
   452  							Url: legacyTestSrv.URL,
   453  							Patterns: []*cfgcommonpb.ConfigPattern{
   454  								{ConfigSet: string(cs), Path: filePath},
   455  							},
   456  						},
   457  						SupportsGzipCompression: true,
   458  					},
   459  				},
   460  			},
   461  		}
   462  
   463  		Convey("Works", func() {
   464  			Convey("With int severity", func() {
   465  				srvResponse = []byte(`{"messages": [{"severity": 40, "text": "bad config"}]}`)
   466  			})
   467  			Convey("With string severity", func() {
   468  				srvResponse = []byte(`{"messages": [{"severity": "ERROR", "text": "bad config"}]}`)
   469  			})
   470  			tf := testFile{
   471  				path:    filePath,
   472  				content: []byte("This is config content"),
   473  			}
   474  			res, err := v.Validate(ctx, cs, []File{tf})
   475  			So(err, ShouldBeNil)
   476  			So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{
   477  				Messages: []*cfgcommonpb.ValidationResult_Message{
   478  					{
   479  						Path:     filePath,
   480  						Severity: cfgcommonpb.ValidationResult_ERROR,
   481  						Text:     "bad config",
   482  					},
   483  				},
   484  			})
   485  
   486  			So(capturedRequestBody, ShouldNotBeEmpty)
   487  			reqMap := map[string]any{}
   488  			So(json.Unmarshal(capturedRequestBody, &reqMap), ShouldBeNil)
   489  			So(reqMap, ShouldHaveLength, 3)
   490  			So(reqMap["config_set"], ShouldEqual, "projects/my-project")
   491  			So(reqMap["path"], ShouldEqual, filePath)
   492  			So(reqMap["content"], ShouldEqual, base64.StdEncoding.EncodeToString(tf.content))
   493  			So(capturedRequestHeader.Get("Content-Type"), ShouldEqual, "application/json; charset=utf-8")
   494  			So(capturedRequestHeader.Get("Content-Encoding"), ShouldBeEmpty)
   495  		})
   496  
   497  		Convey("Empty messages", func() {
   498  			srvResponse = []byte(`{"messages": []}`)
   499  			tf := testFile{
   500  				path:    filePath,
   501  				content: []byte("This is config content"),
   502  			}
   503  			res, err := v.Validate(ctx, cs, []File{tf})
   504  			So(err, ShouldBeNil)
   505  			So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{})
   506  		})
   507  
   508  		Convey("Empty response", func() {
   509  			srvResponse = nil
   510  			tf := testFile{
   511  				path:    filePath,
   512  				content: []byte("This is config content"),
   513  			}
   514  			res, err := v.Validate(ctx, cs, []File{tf})
   515  			So(err, ShouldBeNil)
   516  			So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{})
   517  		})
   518  
   519  		Convey("Compress large payload", func() {
   520  			tf := testFile{
   521  				path:    filePath,
   522  				content: make([]byte, 1024*1024),
   523  			}
   524  			_, err := rand.Read(tf.content)
   525  			So(err, ShouldBeNil)
   526  			res, err := v.Validate(ctx, cs, []File{tf})
   527  			So(err, ShouldBeNil)
   528  			So(res, ShouldNotBeNil)
   529  
   530  			So(capturedRequestBody, ShouldNotBeEmpty)
   531  			r, err := gzip.NewReader(bytes.NewBuffer(capturedRequestBody))
   532  			So(err, ShouldBeNil)
   533  			uncompressed, err := io.ReadAll(r)
   534  			So(err, ShouldBeNil)
   535  			reqMap := map[string]any{}
   536  			So(json.Unmarshal(uncompressed, &reqMap), ShouldBeNil)
   537  			So(reqMap, ShouldHaveLength, 3)
   538  			So(reqMap["content"], ShouldEqual, base64.StdEncoding.EncodeToString(tf.content))
   539  			So(capturedRequestHeader.Get("Content-Type"), ShouldEqual, "application/json; charset=utf-8")
   540  			So(capturedRequestHeader.Get("Content-Encoding"), ShouldEqual, "gzip")
   541  		})
   542  
   543  		Convey("Omit unknown severity", func() {
   544  			Convey("Not provided", func() {
   545  				srvResponse = []byte(`{"messages": [{"text": "bad config"}]}`)
   546  			})
   547  			Convey("Returns unknown", func() {
   548  				srvResponse = []byte(`{"messages": [{"severity": 0, "text": "bad config"}]}`)
   549  			})
   550  
   551  			tf := testFile{
   552  				path:    filePath,
   553  				content: []byte("This is config content"),
   554  			}
   555  			res, err := v.Validate(ctx, cs, []File{tf})
   556  			So(err, ShouldBeNil)
   557  			So(res, ShouldResembleProto, &cfgcommonpb.ValidationResult{})
   558  		})
   559  
   560  		Convey("Error on unrecognized severity", func() {
   561  			Convey("Int severity", func() {
   562  				srvResponse = []byte(`{"messages": [{"severity": 1234, "text": "bad config"}]}`)
   563  			})
   564  			Convey("String severity", func() {
   565  				srvResponse = []byte(`{"messages": [{"severity": "BAD", "text": "bad config"}]}`)
   566  			})
   567  			Convey("Not int not string", func() {
   568  				srvResponse = []byte(`{"messages": [{"severity": true, "text": "bad config"}]}`)
   569  			})
   570  			tf := testFile{
   571  				path:    filePath,
   572  				content: []byte("This is config content"),
   573  			}
   574  			res, err := v.Validate(ctx, cs, []File{tf})
   575  			So(err, ShouldErrLike, "unrecognized severity")
   576  			So(res, ShouldBeNil)
   577  		})
   578  
   579  		Convey("Server Error", func() {
   580  			srvErrMsg = "server encounter error"
   581  			tf := testFile{
   582  				path:    filePath,
   583  				content: []byte("This is config content"),
   584  			}
   585  			res, err := v.Validate(ctx, cs, []File{tf})
   586  			So(err, ShouldErrLike, legacyTestSrv.URL+" returns 500")
   587  			So(res, ShouldBeNil)
   588  		})
   589  
   590  		Convey("Server returns malformed response", func() {
   591  			srvResponse = []byte("[")
   592  			tf := testFile{
   593  				path:    filePath,
   594  				content: []byte("This is config content"),
   595  			}
   596  			res, err := v.Validate(ctx, cs, []File{tf})
   597  			So(err, ShouldErrLike, "failed to unmarshal response")
   598  			So(res, ShouldBeNil)
   599  		})
   600  	})
   601  }