go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config_service/internal/validation/service.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 "encoding/base64" 21 "encoding/json" 22 "errors" 23 "fmt" 24 "io" 25 "net/http" 26 "strings" 27 "sync" 28 29 "github.com/klauspost/compress/gzip" 30 "golang.org/x/sync/errgroup" 31 32 "go.chromium.org/luci/common/gcloud/gs" 33 "go.chromium.org/luci/common/logging" 34 cfgcommonpb "go.chromium.org/luci/common/proto/config" 35 "go.chromium.org/luci/config" 36 "go.chromium.org/luci/config/validation" 37 "go.chromium.org/luci/gae/service/info" 38 "go.chromium.org/luci/grpc/prpc" 39 "go.chromium.org/luci/server/auth" 40 41 "go.chromium.org/luci/config_service/internal/clients" 42 "go.chromium.org/luci/config_service/internal/common" 43 "go.chromium.org/luci/config_service/internal/model" 44 ) 45 46 // serviceValidator calls external service to validate the config or 47 // validate locally for config the service itself it is interested in. 48 type serviceValidator struct { 49 service *model.Service 50 gsClient clients.GsClient 51 selfRuleSet *validation.RuleSet 52 cs config.Set 53 files []File 54 } 55 56 func (sv *serviceValidator) validate(ctx context.Context) (*cfgcommonpb.ValidationResult, error) { 57 switch { 58 case sv.service.Info.GetId() == info.AppID(ctx): 59 return sv.validateAgainstSelfRules(ctx) 60 case sv.service.Info.GetHostname() != "": 61 tr, err := auth.GetRPCTransport(ctx, auth.AsSelf) 62 if err != nil { 63 return nil, fmt.Errorf("failed to create transport %w", err) 64 } 65 endpoint := sv.service.Info.GetHostname() 66 prpcClient := &prpc.Client{ 67 C: &http.Client{Transport: tr}, 68 Host: endpoint, 69 } 70 if strings.HasPrefix(endpoint, "127.0.0.1") { // testing 71 prpcClient.Options = &prpc.Options{Insecure: true} 72 } 73 client := cfgcommonpb.NewConsumerClient(prpcClient) 74 req, err := sv.prepareRequest(ctx) 75 if err != nil { 76 return nil, err 77 } 78 return client.ValidateConfigs(ctx, req) 79 case sv.service.LegacyMetadata != nil: 80 return sv.validateInLegacyProtocol(ctx) 81 default: 82 return nil, fmt.Errorf("service is not %s; it also doesn't provide either hostname or metadata_url for validation", sv.service.Info.GetId()) 83 } 84 } 85 86 // validateAgainstSelfRules validates config files against the rules 87 // registered to the current service (i.e. LUCI Config itself). 88 func (sv *serviceValidator) validateAgainstSelfRules(ctx context.Context) (*cfgcommonpb.ValidationResult, error) { 89 var msgs []*cfgcommonpb.ValidationResult_Message 90 var msgsMu sync.Mutex 91 eg, ectx := errgroup.WithContext(ctx) 92 eg.SetLimit(8) 93 94 for _, file := range sv.files { 95 file := file 96 eg.Go(func() (err error) { 97 path := file.GetPath() 98 content, err := file.GetRawContent(ectx) 99 if err != nil { 100 return err 101 } 102 vc := &validation.Context{Context: ectx} 103 vc.SetFile(path) 104 if err := sv.selfRuleSet.ValidateConfig(vc, string(sv.cs), path, content); err != nil { 105 return err 106 } 107 var vErr *validation.Error 108 switch err := vc.Finalize(); { 109 case errors.As(err, &vErr): 110 msgsMu.Lock() 111 msgs = append(msgs, vErr.ToValidationResultMsgs(ctx)...) 112 msgsMu.Unlock() 113 case err != nil: 114 msgsMu.Lock() 115 msgs = append(msgs, &cfgcommonpb.ValidationResult_Message{ 116 Path: path, 117 Severity: cfgcommonpb.ValidationResult_ERROR, 118 Text: err.Error(), 119 }) 120 msgsMu.Unlock() 121 } 122 return nil 123 }) 124 } 125 126 if err := eg.Wait(); err != nil { 127 return nil, err 128 } 129 return &cfgcommonpb.ValidationResult{ 130 Messages: msgs, 131 }, nil 132 } 133 134 func (sv *serviceValidator) prepareRequest(ctx context.Context) (*cfgcommonpb.ValidateConfigsRequest, error) { 135 // This needs to be optimized if it becomes a common pattern that one config 136 // file will be validated by multiple services. Right now each service 137 // generates signed url for each file that will be included in the validation 138 // request. If a file will be validated against N services, N signed urls will 139 // be generated instead of one. 140 gsPaths := make([]gs.Path, len(sv.files)) 141 for i, f := range sv.files { 142 gsPaths[i] = f.GetGSPath() 143 } 144 urls, err := common.CreateSignedURLs(ctx, sv.gsClient, gsPaths, http.MethodGet, nil) 145 if err != nil { 146 return nil, err 147 } 148 req := &cfgcommonpb.ValidateConfigsRequest{ 149 ConfigSet: string(sv.cs), 150 Files: &cfgcommonpb.ValidateConfigsRequest_Files{ 151 Files: make([]*cfgcommonpb.ValidateConfigsRequest_File, len(sv.files)), 152 }, 153 } 154 for i, url := range urls { 155 req.Files.Files[i] = &cfgcommonpb.ValidateConfigsRequest_File{ 156 Path: sv.files[i].GetPath(), 157 Content: &cfgcommonpb.ValidateConfigsRequest_File_SignedUrl{ 158 SignedUrl: url, 159 }, 160 } 161 } 162 return req, nil 163 } 164 165 type legacyValidationRequest struct { 166 ConfigSet string `json:"config_set"` 167 Path string `json:"path"` 168 Content string `json:"content"` // base64 encoded 169 } 170 171 type legacyValidationResponse struct { 172 Messages []legacyValidationResponseMessage `json:"messages"` 173 } 174 175 type legacyValidationResponseMessage struct { 176 Severity cfgcommonpb.ValidationResult_Severity 177 Text string 178 } 179 180 // UnmarshalJSON unmarshal json string to legacyValidationResponseMessage. 181 func (msg *legacyValidationResponseMessage) UnmarshalJSON(b []byte) error { 182 var objMap map[string]*json.RawMessage 183 if err := json.Unmarshal(b, &objMap); err != nil { 184 return err 185 } 186 if text, ok := objMap["text"]; ok { 187 if err := json.Unmarshal(*text, &msg.Text); err != nil { 188 return err 189 } 190 } 191 if rawSev, ok := objMap["severity"]; ok { 192 var sevInt int32 193 var sevStr string 194 switch { 195 case json.Unmarshal(*rawSev, &sevInt) == nil: 196 if _, ok := cfgcommonpb.ValidationResult_Severity_name[sevInt]; !ok { 197 return fmt.Errorf("unrecognized severity integer %d", sevInt) 198 } 199 msg.Severity = cfgcommonpb.ValidationResult_Severity(sevInt) 200 case json.Unmarshal(*rawSev, &sevStr) == nil: 201 sevVal, ok := cfgcommonpb.ValidationResult_Severity_value[sevStr] 202 if !ok { 203 return fmt.Errorf("unrecognized severity string %q", sevStr) 204 } 205 msg.Severity = cfgcommonpb.ValidationResult_Severity(sevVal) 206 default: 207 return fmt.Errorf("unrecognized severity \"%s\"", *rawSev) 208 } 209 } 210 return nil 211 } 212 213 // validateInLegacyProtocol validates all files of the `serviceValidator` 214 // against a service using legacy protocol. 215 func (sv *serviceValidator) validateInLegacyProtocol(ctx context.Context) (*cfgcommonpb.ValidationResult, error) { 216 var allMsgs []*cfgcommonpb.ValidationResult_Message 217 var msgsMu sync.Mutex 218 eg, ectx := errgroup.WithContext(ctx) 219 eg.SetLimit(8) 220 221 for _, file := range sv.files { 222 file := file 223 eg.Go(func() error { 224 msgs, err := sv.validateFileLegacy(ectx, file) 225 if err != nil { 226 return err 227 } 228 msgsMu.Lock() 229 allMsgs = append(allMsgs, msgs...) 230 msgsMu.Unlock() 231 return nil 232 }) 233 } 234 235 if err := eg.Wait(); err != nil { 236 return nil, err 237 } 238 return &cfgcommonpb.ValidationResult{ 239 Messages: allMsgs, 240 }, nil 241 } 242 243 // validateFileLegacy validates a file against service using legacy protocol. 244 // 245 // It is an HTTP POST request. The request and response formats are defined by 246 // `legacyValidationRequest` and `legacyValidationResponse` respectively. It 247 // also respects the `support_gzip_compression` setting in the service config. 248 // It will compress any payload over 512KiB if enabled. 249 func (sv *serviceValidator) validateFileLegacy(ctx context.Context, file File) ([]*cfgcommonpb.ValidationResult_Message, error) { 250 ctx = logging.SetFields(ctx, logging.Fields{ 251 "Service": sv.service.Name, 252 "File": file.GetPath(), 253 }) 254 headers := map[string]string{ 255 "Content-Type": "application/json; charset=utf-8", 256 "User-Agent": info.AppID(ctx), 257 } 258 content, err := file.GetRawContent(ctx) 259 if err != nil { 260 return nil, err 261 } 262 req := legacyValidationRequest{ 263 ConfigSet: string(sv.cs), 264 Path: file.GetPath(), 265 Content: base64.StdEncoding.EncodeToString(content), 266 } 267 payload, err := json.Marshal(req) 268 if err != nil { 269 return nil, fmt.Errorf("failed to marshal the request to JSON: %w", err) 270 } 271 var buf bytes.Buffer 272 if sv.service.LegacyMetadata.GetSupportsGzipCompression() && len(payload) > 512*1024 { 273 gzipWriter := gzip.NewWriter(&buf) 274 if _, err := gzipWriter.Write(payload); err != nil { 275 _ = gzipWriter.Close() 276 return nil, fmt.Errorf("failed to gzip compress the request: %w", err) 277 } 278 if err := gzipWriter.Close(); err != nil { 279 return nil, fmt.Errorf("failed to close gzip writer: %w", err) 280 } 281 headers["Content-Encoding"] = "gzip" 282 } else { 283 buf = *bytes.NewBuffer(payload) 284 } 285 url := sv.service.LegacyMetadata.GetValidation().GetUrl() 286 if url == "" { 287 panic(fmt.Errorf("expect non-empty legacy validation url for service %q", sv.service.Name)) 288 } 289 httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf) 290 if err != nil { 291 return nil, fmt.Errorf("failed to create http request: %w", err) 292 } 293 for k, v := range headers { 294 httpReq.Header.Set(k, v) 295 } 296 297 client := &http.Client{} 298 if jwtAud := sv.service.Info.GetJwtAuth().GetAudience(); jwtAud != "" { 299 if client.Transport, err = common.GetSelfSignedJWTTransport(ctx, jwtAud); err != nil { 300 return nil, err 301 } 302 } else { 303 if client.Transport, err = auth.GetRPCTransport(ctx, auth.AsSelf); err != nil { 304 return nil, fmt.Errorf("failed to create transport %w", err) 305 } 306 } 307 logging.Debugf(ctx, "POST %s Content-Length: %d", url, buf.Len()) 308 resp, err := client.Do(httpReq) 309 if err != nil { 310 return nil, fmt.Errorf("failed to send request to %s: %w", url, err) 311 } 312 return sv.parseLegacyResponse(ctx, resp, url, file) 313 } 314 315 func (sv *serviceValidator) parseLegacyResponse(ctx context.Context, resp *http.Response, url string, file File) ([]*cfgcommonpb.ValidationResult_Message, error) { 316 defer func() { _ = resp.Body.Close() }() 317 switch body, err := io.ReadAll(resp.Body); { 318 case err != nil: 319 return nil, fmt.Errorf("failed to read the response from %s: %w", url, err) 320 case resp.StatusCode != http.StatusOK: 321 logging.Errorf(ctx, "validating against %s using legacy protocol fails with status code: %d. Full response body:\n\n%s", sv.service.Name, resp.StatusCode, body) 322 return nil, fmt.Errorf("%s returns %d", url, resp.StatusCode) 323 case len(body) == 0: 324 return nil, nil 325 default: 326 validationResponse := legacyValidationResponse{} 327 if err := json.Unmarshal(body, &validationResponse); err != nil { 328 logging.Errorf(ctx, "failed to unmarshal legacy validation response: %s; Full response body: %s", err, body) 329 return nil, fmt.Errorf("failed to unmarshal response from %s: %w", url, err) 330 } 331 ret := make([]*cfgcommonpb.ValidationResult_Message, 0, len(validationResponse.Messages)) 332 for _, msg := range validationResponse.Messages { 333 if msg.Severity == cfgcommonpb.ValidationResult_UNKNOWN { 334 logging.Errorf(ctx, "severity not provided; full response from %s: %q", url, body) 335 continue 336 } 337 ret = append(ret, &cfgcommonpb.ValidationResult_Message{ 338 Path: file.GetPath(), 339 Severity: msg.Severity, 340 Text: msg.Text, 341 }) 342 } 343 return ret, nil 344 } 345 }