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  }