go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/lucicfg/configset_test.go (about)

     1  // Copyright 2019 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 lucicfg
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"encoding/base64"
    21  	"fmt"
    22  	"io"
    23  	"net/http"
    24  	"net/http/httptest"
    25  	"os"
    26  	"path/filepath"
    27  	"sort"
    28  	"strings"
    29  	"sync"
    30  	"testing"
    31  
    32  	"github.com/golang/mock/gomock"
    33  	"github.com/klauspost/compress/gzip"
    34  	"google.golang.org/grpc/codes"
    35  	"google.golang.org/grpc/status"
    36  
    37  	legacy_config "go.chromium.org/luci/common/api/luci_config/config/v1"
    38  	"go.chromium.org/luci/common/proto"
    39  	"go.chromium.org/luci/common/proto/config"
    40  	configpb "go.chromium.org/luci/config_service/proto"
    41  
    42  	. "github.com/smartystreets/goconvey/convey"
    43  	. "go.chromium.org/luci/common/testing/assertions"
    44  )
    45  
    46  func TestConfigSet(t *testing.T) {
    47  	t.Parallel()
    48  
    49  	ctx := context.Background()
    50  
    51  	Convey("With temp dir", t, func() {
    52  		tmp := t.TempDir()
    53  		path := func(p ...string) string {
    54  			return filepath.Join(append([]string{tmp}, p...)...)
    55  		}
    56  
    57  		So(os.Mkdir(path("subdir"), 0700), ShouldBeNil)
    58  		So(os.WriteFile(path("a.cfg"), []byte("a\n"), 0600), ShouldBeNil)
    59  		So(os.WriteFile(path("subdir", "b.cfg"), []byte("b\n"), 0600), ShouldBeNil)
    60  
    61  		Convey("Reading", func() {
    62  			Convey("Success", func() {
    63  				cfg, err := ReadConfigSet(tmp, "set name")
    64  				So(err, ShouldBeNil)
    65  				So(cfg, ShouldResemble, ConfigSet{
    66  					Name: "set name",
    67  					Data: map[string][]byte{
    68  						"a.cfg":        []byte("a\n"),
    69  						"subdir/b.cfg": []byte("b\n"),
    70  					},
    71  				})
    72  
    73  				So(cfg.Files(), ShouldResemble, []string{
    74  					"a.cfg",
    75  					"subdir/b.cfg",
    76  				})
    77  			})
    78  
    79  			Convey("Missing dir", func() {
    80  				_, err := ReadConfigSet(path("unknown"), "zzz")
    81  				So(err, ShouldNotBeNil)
    82  			})
    83  		})
    84  	})
    85  
    86  	Convey("Validation", t, func() {
    87  		const configSetName = "config set name"
    88  
    89  		validator := testValidator{
    90  			res: []*config.ValidationResult_Message{
    91  				{Severity: config.ValidationResult_ERROR, Text: "Boo"},
    92  			},
    93  		}
    94  
    95  		cfgSet := ConfigSet{
    96  			Name: configSetName,
    97  			Data: map[string][]byte{
    98  				"a.cfg": []byte("aaa"),
    99  				"b.cfg": {0, 1, 2},
   100  			},
   101  		}
   102  
   103  		So(cfgSet.Validate(ctx, &validator), ShouldResemble, &ValidationResult{
   104  			ConfigSet: configSetName,
   105  			Messages:  validator.res,
   106  		})
   107  
   108  		So(validator.cs, ShouldResemble, cfgSet)
   109  	})
   110  
   111  	Convey("RPC error", t, func() {
   112  		validator := testValidator{
   113  			err: fmt.Errorf("BOOM"),
   114  		}
   115  
   116  		cfg := ConfigSet{
   117  			Name: "set",
   118  			Data: map[string][]byte{"a.cfg": []byte("aaa")},
   119  		}
   120  
   121  		res := cfg.Validate(ctx, &validator)
   122  		So(res, ShouldResemble, &ValidationResult{
   123  			ConfigSet: "set",
   124  			Failed:    true,
   125  			RPCError:  "BOOM",
   126  		})
   127  
   128  		// This is considered overall failure.
   129  		err := res.OverallError(false)
   130  		So(err, ShouldErrLike, "BOOM")
   131  		So(res.Failed, ShouldBeTrue)
   132  	})
   133  
   134  	Convey("Overall error check", t, func() {
   135  		result := func(level ...config.ValidationResult_Severity) *ValidationResult {
   136  			res := &ValidationResult{}
   137  			for _, l := range level {
   138  				res.Messages = append(res.Messages, &config.ValidationResult_Message{
   139  					Severity: l,
   140  					Text:     "boo",
   141  				})
   142  			}
   143  			return res
   144  		}
   145  
   146  		// Fail on warnings = false.
   147  		So(result().OverallError(false), ShouldBeNil)
   148  		So(result(config.ValidationResult_INFO, config.ValidationResult_WARNING).OverallError(false), ShouldBeNil)
   149  		So(result(config.ValidationResult_INFO, config.ValidationResult_ERROR).OverallError(false), ShouldErrLike, "some files were invalid")
   150  
   151  		// Fail on warnings = true.
   152  		So(result().OverallError(true), ShouldBeNil)
   153  		So(result(config.ValidationResult_INFO).OverallError(true), ShouldBeNil)
   154  		So(result(config.ValidationResult_INFO, config.ValidationResult_WARNING, config.ValidationResult_ERROR).OverallError(true), ShouldErrLike, "some files were invalid")
   155  		So(result(config.ValidationResult_INFO, config.ValidationResult_WARNING).OverallError(true), ShouldErrLike, "some files had validation warnings")
   156  	})
   157  }
   158  
   159  func TestRemoteValidator(t *testing.T) {
   160  	t.Parallel()
   161  
   162  	Convey("Remote Validator", t, func(c C) {
   163  		ctx := context.Background()
   164  		ctrl := gomock.NewController(t)
   165  		mockClient := configpb.NewMockConfigsClient(ctrl)
   166  
   167  		validator := &remoteValidator{
   168  			cfgClient: mockClient,
   169  		}
   170  
   171  		cs := ConfigSet{
   172  			Name: "example-proj",
   173  			Data: map[string][]byte{
   174  				"foo.cfg": []byte("This is the config content"),
   175  			},
   176  		}
   177  		validationMsgs := []*config.ValidationResult_Message{
   178  			{
   179  				Path:     "foo.cfg",
   180  				Severity: config.ValidationResult_ERROR,
   181  				Text:     "bad config syntax",
   182  			},
   183  		}
   184  
   185  		Convey("empty config set", func() {
   186  			cs.Data = nil
   187  			res, err := validator.Validate(ctx, cs)
   188  			So(err, ShouldBeNil)
   189  			So(res, ShouldBeEmpty)
   190  		})
   191  		Convey("successfully validated", func() {
   192  			mockClient.EXPECT().ValidateConfigs(gomock.Any(), proto.MatcherEqual(&configpb.ValidateConfigsRequest{
   193  				ConfigSet: cs.Name,
   194  				FileHashes: []*configpb.ValidateConfigsRequest_FileHash{
   195  					{
   196  						Path:   "foo.cfg",
   197  						Sha256: "fd243f0466e35bcc9146b012415e11c88627a2a7c31a7fb121c5a8e99401e417",
   198  					},
   199  				},
   200  			})).Return(&config.ValidationResult{
   201  				Messages: validationMsgs,
   202  			}, nil)
   203  			res, err := validator.Validate(ctx, cs)
   204  			So(err, ShouldBeNil)
   205  			So(res, ShouldResembleProto, validationMsgs)
   206  		})
   207  		Convey("unvalidatable files", func() {
   208  			cs.Data["unvalidatable.cfg"] = []byte("some content")
   209  			st, err := status.New(codes.InvalidArgument, "invalid validate config request").WithDetails(&configpb.BadValidationRequestFixInfo{
   210  				UnvalidatableFiles: []string{"unvalidatable.cfg"},
   211  			})
   212  			So(err, ShouldBeNil)
   213  			mockClient.EXPECT().ValidateConfigs(gomock.Any(), proto.MatcherEqual(&configpb.ValidateConfigsRequest{
   214  				ConfigSet: cs.Name,
   215  				FileHashes: []*configpb.ValidateConfigsRequest_FileHash{
   216  					{
   217  						Path:   "foo.cfg",
   218  						Sha256: "fd243f0466e35bcc9146b012415e11c88627a2a7c31a7fb121c5a8e99401e417",
   219  					},
   220  					{
   221  						Path:   "unvalidatable.cfg",
   222  						Sha256: "290f493c44f5d63d06b374d0a5abd292fae38b92cab2fae5efefe1b0e9347f56",
   223  					},
   224  				},
   225  			})).Return(nil, st.Err())
   226  			// Retry after stripping out `unvalidatable.cfg`
   227  			mockClient.EXPECT().ValidateConfigs(gomock.Any(), proto.MatcherEqual(&configpb.ValidateConfigsRequest{
   228  				ConfigSet: cs.Name,
   229  				FileHashes: []*configpb.ValidateConfigsRequest_FileHash{
   230  					{
   231  						Path:   "foo.cfg",
   232  						Sha256: "fd243f0466e35bcc9146b012415e11c88627a2a7c31a7fb121c5a8e99401e417",
   233  					},
   234  				},
   235  			})).Return(&config.ValidationResult{
   236  				Messages: validationMsgs,
   237  			}, nil)
   238  			res, err := validator.Validate(ctx, cs)
   239  			So(err, ShouldBeNil)
   240  			So(res, ShouldResembleProto, validationMsgs)
   241  		})
   242  		Convey("upload files", func(c C) {
   243  			Convey("succeed", func() {
   244  				ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   245  					c.So(r.Method, ShouldEqual, http.MethodPut)
   246  					c.So(r.Header.Get("Content-Encoding"), ShouldEqual, "gzip")
   247  					c.So(r.Header.Get("x-goog-content-length-range"), ShouldEqual, "0,10240")
   248  					compressed, err := io.ReadAll(r.Body)
   249  					c.So(err, ShouldBeNil)
   250  					reader, err := gzip.NewReader(bytes.NewBuffer(compressed))
   251  					c.So(err, ShouldBeNil)
   252  					config, err := io.ReadAll(reader)
   253  					c.So(err, ShouldBeNil)
   254  					c.So(string(config), ShouldEqual, "This is the config content")
   255  					w.WriteHeader(http.StatusOK)
   256  				}))
   257  				defer ts.Close()
   258  				st, err := status.New(codes.InvalidArgument, "invalid validate config request").WithDetails(&configpb.BadValidationRequestFixInfo{
   259  					UploadFiles: []*configpb.BadValidationRequestFixInfo_UploadFile{
   260  						{
   261  							Path:          "foo.cfg",
   262  							SignedUrl:     ts.URL,
   263  							MaxConfigSize: 10240,
   264  						},
   265  					},
   266  				})
   267  				So(err, ShouldBeNil)
   268  				mockClient.EXPECT().ValidateConfigs(gomock.Any(), proto.MatcherEqual(&configpb.ValidateConfigsRequest{
   269  					ConfigSet: cs.Name,
   270  					FileHashes: []*configpb.ValidateConfigsRequest_FileHash{
   271  						{
   272  							Path:   "foo.cfg",
   273  							Sha256: "fd243f0466e35bcc9146b012415e11c88627a2a7c31a7fb121c5a8e99401e417",
   274  						},
   275  					},
   276  				})).Return(nil, st.Err()).
   277  					Return(&config.ValidationResult{
   278  						Messages: validationMsgs,
   279  					}, nil)
   280  				res, err := validator.Validate(ctx, cs)
   281  				So(err, ShouldBeNil)
   282  				So(res, ShouldResembleProto, validationMsgs)
   283  			})
   284  
   285  			Convey("no file unvalidatable", func() {
   286  				cs.Data = map[string][]byte{
   287  					"unvalidatable.cfg": []byte("some content"),
   288  				}
   289  				st, err := status.New(codes.InvalidArgument, "invalid validate config request").WithDetails(&configpb.BadValidationRequestFixInfo{
   290  					UnvalidatableFiles: []string{"unvalidatable.cfg"},
   291  				})
   292  				So(err, ShouldBeNil)
   293  				mockClient.EXPECT().ValidateConfigs(gomock.Any(), proto.MatcherEqual(&configpb.ValidateConfigsRequest{
   294  					ConfigSet: cs.Name,
   295  					FileHashes: []*configpb.ValidateConfigsRequest_FileHash{
   296  						{
   297  							Path:   "unvalidatable.cfg",
   298  							Sha256: "290f493c44f5d63d06b374d0a5abd292fae38b92cab2fae5efefe1b0e9347f56",
   299  						},
   300  					},
   301  				})).Return(nil, st.Err())
   302  				res, err := validator.Validate(ctx, cs)
   303  				So(err, ShouldBeNil)
   304  				So(res, ShouldBeEmpty)
   305  			})
   306  			Convey("failed", func() {
   307  				ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   308  					w.WriteHeader(http.StatusBadRequest)
   309  					fmt.Fprintf(w, "config is too large")
   310  				}))
   311  				defer ts.Close()
   312  				st, err := status.New(codes.InvalidArgument, "invalid validate config request").WithDetails(&configpb.BadValidationRequestFixInfo{
   313  					UploadFiles: []*configpb.BadValidationRequestFixInfo_UploadFile{
   314  						{
   315  							Path:          "foo.cfg",
   316  							SignedUrl:     ts.URL,
   317  							MaxConfigSize: 10240,
   318  						},
   319  					},
   320  				})
   321  				So(err, ShouldBeNil)
   322  				mockClient.EXPECT().ValidateConfigs(gomock.Any(), proto.MatcherEqual(&configpb.ValidateConfigsRequest{
   323  					ConfigSet: cs.Name,
   324  					FileHashes: []*configpb.ValidateConfigsRequest_FileHash{
   325  						{
   326  							Path:   "foo.cfg",
   327  							Sha256: "fd243f0466e35bcc9146b012415e11c88627a2a7c31a7fb121c5a8e99401e417",
   328  						},
   329  					},
   330  				})).Return(nil, st.Err())
   331  				res, err := validator.Validate(ctx, cs)
   332  				So(err, ShouldErrLike, "failed to upload file")
   333  				So(res, ShouldBeEmpty)
   334  			})
   335  		})
   336  		Convey("failed to call LUCI Config", func() {
   337  			mockClient.EXPECT().ValidateConfigs(gomock.Any(), proto.MatcherEqual(&configpb.ValidateConfigsRequest{
   338  				ConfigSet: cs.Name,
   339  				FileHashes: []*configpb.ValidateConfigsRequest_FileHash{
   340  					{
   341  						Path:   "foo.cfg",
   342  						Sha256: "fd243f0466e35bcc9146b012415e11c88627a2a7c31a7fb121c5a8e99401e417",
   343  					},
   344  				},
   345  			})).Return(nil, status.Error(codes.InvalidArgument, "invalid validate config request"))
   346  			res, err := validator.Validate(ctx, cs)
   347  			So(err, ShouldErrLike, "failed to call LUCI Config")
   348  			So(res, ShouldBeEmpty)
   349  		})
   350  	})
   351  }
   352  
   353  func TestLegacyRemoteValidator(t *testing.T) {
   354  	t.Parallel()
   355  
   356  	ctx := context.Background()
   357  
   358  	mustBase64Decode := func(s string) []byte {
   359  		ret, err := base64.StdEncoding.DecodeString(s)
   360  		if err != nil {
   361  			panic(err)
   362  		}
   363  		return ret
   364  	}
   365  
   366  	cfgSet := ConfigSet{
   367  		Name: "config-set",
   368  		Data: map[string][]byte{
   369  			// "aaaaaaaa", "bbbb", "cccccccccccc", "dddddddd" will be the actual
   370  			// content send to LUCI Config
   371  			"a.cfg": mustBase64Decode("aaaaaaaa"),
   372  			"b.cfg": mustBase64Decode("bbbb"),
   373  			"c.cfg": mustBase64Decode("cccccccccccc"),
   374  			"d.cfg": mustBase64Decode("dddddddd"),
   375  		},
   376  	}
   377  
   378  	Convey("Splits requests, collects messages", t, func() {
   379  		var reqs []*legacy_config.LuciConfigValidateConfigRequestMessage
   380  		var lock sync.Mutex
   381  
   382  		val := &legacyRemoteValidator{
   383  			requestSizeLimitBytes: 12,
   384  			validateConfig: func(ctx context.Context, req *legacy_config.LuciConfigValidateConfigRequestMessage) (*legacy_config.LuciConfigValidateConfigResponseMessage, error) {
   385  				lock.Lock()
   386  				reqs = append(reqs, req)
   387  				lock.Unlock()
   388  
   389  				if req.ConfigSet != "config-set" {
   390  					panic("bad ConfigSet")
   391  				}
   392  
   393  				var messages []*legacy_config.ComponentsConfigEndpointValidationMessage
   394  				for _, f := range req.Files {
   395  					messages = append(messages, &legacy_config.ComponentsConfigEndpointValidationMessage{
   396  						Path:     f.Path,
   397  						Severity: "ERROR",
   398  						Text:     fmt.Sprintf("Boom in %s", f.Path),
   399  					})
   400  				}
   401  
   402  				return &legacy_config.LuciConfigValidateConfigResponseMessage{
   403  					Messages: messages,
   404  				}, nil
   405  			},
   406  		}
   407  
   408  		msg, err := val.Validate(ctx, cfgSet)
   409  		So(err, ShouldBeNil)
   410  		So(msg, ShouldResembleProto, []*config.ValidationResult_Message{
   411  			{Path: "a.cfg", Severity: config.ValidationResult_ERROR, Text: "Boom in a.cfg"},
   412  			{Path: "b.cfg", Severity: config.ValidationResult_ERROR, Text: "Boom in b.cfg"},
   413  			{Path: "c.cfg", Severity: config.ValidationResult_ERROR, Text: "Boom in c.cfg"},
   414  			{Path: "d.cfg", Severity: config.ValidationResult_ERROR, Text: "Boom in d.cfg"},
   415  		})
   416  
   417  		var sets []string
   418  		for _, req := range reqs {
   419  			var reqSize int
   420  			var names []string
   421  			for _, f := range req.Files {
   422  				reqSize += len(f.Content)
   423  				names = append(names, f.Path)
   424  			}
   425  			sets = append(sets, strings.Join(names, "+"))
   426  			So(reqSize, ShouldBeLessThanOrEqualTo, val.requestSizeLimitBytes)
   427  		}
   428  		sort.Strings(sets)
   429  		So(sets, ShouldResemble, []string{"b.cfg+a.cfg", "c.cfg", "d.cfg"})
   430  		Convey("Single file too large", func() {
   431  			val.requestSizeLimitBytes = 10
   432  			_, err := val.Validate(ctx, cfgSet)
   433  			So(err, ShouldErrLike, "the size of file \"c.cfg\" is 12")
   434  		})
   435  	})
   436  
   437  	Convey("Handles errors", t, func() {
   438  		val := &legacyRemoteValidator{
   439  			requestSizeLimitBytes: 12,
   440  			validateConfig: func(ctx context.Context, req *legacy_config.LuciConfigValidateConfigRequestMessage) (*legacy_config.LuciConfigValidateConfigResponseMessage, error) {
   441  				if req.ConfigSet != "config-set" {
   442  					panic("bad ConfigSet")
   443  				}
   444  
   445  				var messages []*legacy_config.ComponentsConfigEndpointValidationMessage
   446  				for _, f := range req.Files {
   447  					if f.Path == "c.cfg" || f.Path == "d.cfg" {
   448  						return nil, fmt.Errorf("fake error")
   449  					}
   450  					messages = append(messages, &legacy_config.ComponentsConfigEndpointValidationMessage{
   451  						Path:     f.Path,
   452  						Severity: "ERROR",
   453  						Text:     fmt.Sprintf("Boom in %s", f.Path),
   454  					})
   455  				}
   456  
   457  				return &legacy_config.LuciConfigValidateConfigResponseMessage{
   458  					Messages: messages,
   459  				}, nil
   460  			},
   461  		}
   462  
   463  		msg, err := val.Validate(ctx, cfgSet)
   464  		So(err, ShouldNotBeNil)
   465  		So(err.Error(), ShouldEqual, "fake error (and 1 other error)")
   466  		So(msg, ShouldResembleProto, []*config.ValidationResult_Message{
   467  			{Path: "a.cfg", Severity: config.ValidationResult_ERROR, Text: "Boom in a.cfg"},
   468  			{Path: "b.cfg", Severity: config.ValidationResult_ERROR, Text: "Boom in b.cfg"},
   469  		})
   470  	})
   471  }
   472  
   473  type testValidator struct {
   474  	cs  ConfigSet                          // captured config set
   475  	res []*config.ValidationResult_Message // a reply to send
   476  	err error                              // an RPC error
   477  }
   478  
   479  func (t *testValidator) Validate(ctx context.Context, cs ConfigSet) ([]*config.ValidationResult_Message, error) {
   480  	t.cs = cs
   481  	return t.res, t.err
   482  }