go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config/server/cfgmodule/handler.go (about)

     1  // Copyright 2017 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 cfgmodule
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"encoding/json"
    21  	"fmt"
    22  	"net/http"
    23  
    24  	"github.com/klauspost/compress/gzip"
    25  	"golang.org/x/sync/errgroup"
    26  	"google.golang.org/grpc/codes"
    27  	"google.golang.org/grpc/status"
    28  	"google.golang.org/protobuf/types/known/emptypb"
    29  
    30  	"go.chromium.org/luci/auth/identity"
    31  	"go.chromium.org/luci/common/errors"
    32  	"go.chromium.org/luci/common/logging"
    33  	cfgpb "go.chromium.org/luci/common/proto/config"
    34  	"go.chromium.org/luci/config"
    35  	"go.chromium.org/luci/config/validation"
    36  	"go.chromium.org/luci/server/auth"
    37  	"go.chromium.org/luci/server/router"
    38  )
    39  
    40  const (
    41  	// paths for handlers
    42  	metadataPath   = "/api/config/v1/metadata"
    43  	validationPath = "/api/config/v1/validate"
    44  
    45  	// Taken from
    46  	// https://chromium.googlesource.com/infra/luci/luci-py/+/3efc60daef6bf6669f9211f63e799db47a0478c0/appengine/components/components/config/endpoint.py
    47  	metaDataFormatVersion = "1.0"
    48  	adminGroup            = "administrators"
    49  )
    50  
    51  // ConsumerServer implements `cfgpb.Consumer` interface that will be called
    52  // by LUCI Config.
    53  type ConsumerServer struct {
    54  	cfgpb.UnimplementedConsumerServer
    55  	// Rules is a rule set to use for the config validation.
    56  	Rules *validation.RuleSet
    57  	// GetConfigServiceAccountFn returns a function that can fetch the service
    58  	// account of the LUCI Config service. It is used by ACL checking.
    59  	GetConfigServiceAccountFn func(context.Context) (string, error)
    60  }
    61  
    62  // GetMetadata implements cfgpb.Consumer.GetMetadata.
    63  func (srv *ConsumerServer) GetMetadata(ctx context.Context, _ *emptypb.Empty) (*cfgpb.ServiceMetadata, error) {
    64  	if err := srv.checkCaller(ctx); err != nil {
    65  		return nil, err
    66  	}
    67  	patterns, err := srv.Rules.ConfigPatterns(ctx)
    68  	if err != nil {
    69  		return nil, status.Errorf(codes.Internal, "failed to collect the list of validation patterns: %s", err)
    70  	}
    71  	ret := &cfgpb.ServiceMetadata{}
    72  	if len(patterns) > 0 {
    73  		ret.ConfigPatterns = make([]*cfgpb.ConfigPattern, len(patterns))
    74  		for i, pattern := range patterns {
    75  			ret.ConfigPatterns[i] = &cfgpb.ConfigPattern{
    76  				ConfigSet: pattern.ConfigSet.String(),
    77  				Path:      pattern.Path.String(),
    78  			}
    79  		}
    80  	}
    81  	return ret, nil
    82  }
    83  
    84  // ValidateConfigs implements cfgpb.Consumer.ValidateConfigs.
    85  func (srv *ConsumerServer) ValidateConfigs(ctx context.Context, req *cfgpb.ValidateConfigsRequest) (*cfgpb.ValidationResult, error) {
    86  	if err := srv.checkCaller(ctx); err != nil {
    87  		return nil, err
    88  	}
    89  	if err := checkValidateInput(req); err != nil {
    90  		return nil, err
    91  	}
    92  
    93  	result := make([][]*cfgpb.ValidationResult_Message, len(req.GetFiles().GetFiles()))
    94  	eg, ectx := errgroup.WithContext(ctx)
    95  	eg.SetLimit(8)
    96  	for i, file := range req.GetFiles().GetFiles() {
    97  		i, file := i, file
    98  		eg.Go(func() error {
    99  			var content []byte
   100  			switch file.GetContent().(type) {
   101  			case *cfgpb.ValidateConfigsRequest_File_RawContent:
   102  				content = file.GetRawContent()
   103  			case *cfgpb.ValidateConfigsRequest_File_SignedUrl:
   104  				tr, err := auth.GetRPCTransport(ctx, auth.NoAuth)
   105  				if err != nil {
   106  					return fmt.Errorf("failed to get the RPC transport: %w", err)
   107  				}
   108  				if content, err = config.DownloadConfigFromSignedURL(ectx, &http.Client{Transport: tr}, file.GetSignedUrl()); err != nil {
   109  					return fmt.Errorf("failed to download file %s from the signed url: %w", file.Path, err)
   110  				}
   111  			default:
   112  				panic(fmt.Errorf("unrecognized file content type: %T", file.GetContent()))
   113  			}
   114  			var err error
   115  			result[i], err = srv.validateOneFile(ectx, req.GetConfigSet(), file.GetPath(), content)
   116  			return err
   117  		})
   118  	}
   119  
   120  	if err := eg.Wait(); err != nil {
   121  		return nil, status.Errorf(codes.Internal, "encounter internal error: %s", err)
   122  	}
   123  
   124  	// Flatten the messages
   125  	ret := &cfgpb.ValidationResult{}
   126  	for _, msgs := range result {
   127  		ret.Messages = append(ret.Messages, msgs...)
   128  	}
   129  	return ret, nil
   130  }
   131  
   132  // only LUCI Config and identity in admin group is allowed to call.
   133  func (srv *ConsumerServer) checkCaller(ctx context.Context) error {
   134  	configServiceAccount, err := srv.GetConfigServiceAccountFn(ctx)
   135  	if err != nil {
   136  		logging.Errorf(ctx, "Failed to get LUCI Config service account: %s", err)
   137  		return status.Errorf(codes.Internal, "failed to get LUCI Config service account")
   138  	}
   139  	caller := auth.CurrentIdentity(ctx)
   140  	if caller.Kind() == identity.User && caller.Value() == configServiceAccount {
   141  		return nil
   142  	}
   143  	switch admin, err := auth.IsMember(ctx, adminGroup); {
   144  	case err != nil:
   145  		logging.Errorf(ctx, "Failed to check ACL: %s", err)
   146  		return status.Errorf(codes.Internal, "failed to check ACL")
   147  	case admin:
   148  		return nil
   149  	}
   150  	return status.Errorf(codes.PermissionDenied, "%q is not authorized", caller)
   151  }
   152  
   153  func checkValidateInput(req *cfgpb.ValidateConfigsRequest) error {
   154  	switch {
   155  	case req.GetConfigSet() == "":
   156  		return status.Errorf(codes.InvalidArgument, "must specify the config_set of the file to validate")
   157  	case len(req.GetFiles().GetFiles()) == 0:
   158  		return status.Errorf(codes.InvalidArgument, "must provide at least 1 file to validate")
   159  	}
   160  	for i, file := range req.GetFiles().GetFiles() {
   161  		if file.GetPath() == "" {
   162  			return status.Errorf(codes.InvalidArgument, "must specify path for file[%d]", i)
   163  		}
   164  		if file.GetRawContent() == nil && file.GetSignedUrl() == "" {
   165  			return status.Errorf(codes.InvalidArgument, "must either provide raw_content or signed_url for file %q", file.GetPath())
   166  		}
   167  	}
   168  	return nil
   169  }
   170  
   171  func (srv *ConsumerServer) validateOneFile(ctx context.Context, configSet, path string, content []byte) ([]*cfgpb.ValidationResult_Message, error) {
   172  	vc := &validation.Context{Context: ctx}
   173  	vc.SetFile(path)
   174  	if err := srv.Rules.ValidateConfig(vc, configSet, path, content); err != nil {
   175  		return nil, err
   176  	}
   177  	var vErr *validation.Error
   178  	switch err := vc.Finalize(); {
   179  	case errors.As(err, &vErr):
   180  		return vErr.ToValidationResultMsgs(ctx), nil
   181  	case err != nil:
   182  		return []*cfgpb.ValidationResult_Message{
   183  			{
   184  				Path:     path,
   185  				Severity: cfgpb.ValidationResult_ERROR,
   186  				Text:     err.Error(),
   187  			},
   188  		}, nil
   189  	default:
   190  		return nil, nil
   191  	}
   192  }
   193  
   194  // InstallHandlers installs the metadata and validation handlers that use
   195  // the given validation rules.
   196  //
   197  // It does not implement any authentication checks, thus the passed in
   198  // router.MiddlewareChain should implement any necessary authentication checks.
   199  //
   200  // Deprecated: The handlers are called by the legacy LUCI Config service. The
   201  // new LUCI Config service will make request to `cfgpb.Consumer` prpc service
   202  // instead. See `consumerServer`.
   203  func InstallHandlers(r *router.Router, base router.MiddlewareChain, rules *validation.RuleSet) {
   204  	r.GET(metadataPath, base, metadataRequestHandler(rules))
   205  	r.POST(validationPath, base, validationRequestHandler(rules))
   206  }
   207  
   208  func badRequestStatus(c context.Context, w http.ResponseWriter, msg string, err error) {
   209  	if err != nil {
   210  		logging.WithError(err).Warningf(c, "%s", msg)
   211  	} else {
   212  		logging.Warningf(c, "%s", msg)
   213  	}
   214  	w.WriteHeader(http.StatusBadRequest)
   215  	w.Write([]byte(msg))
   216  }
   217  
   218  func internalErrStatus(c context.Context, w http.ResponseWriter, msg string, err error) {
   219  	logging.WithError(err).Errorf(c, "%s", msg)
   220  	w.WriteHeader(http.StatusInternalServerError)
   221  	w.Write([]byte(msg))
   222  }
   223  
   224  // validationRequestHandler handles the validation request from luci-config and
   225  // responds with the corresponding results.
   226  func validationRequestHandler(rules *validation.RuleSet) router.Handler {
   227  	return func(ctx *router.Context) {
   228  		c, w, r := ctx.Request.Context(), ctx.Writer, ctx.Request
   229  
   230  		raw := r.Body
   231  		if r.Header.Get("Content-Encoding") == "gzip" {
   232  			logging.Infof(c, "The request is gzip compressed")
   233  			var err error
   234  			if raw, err = gzip.NewReader(r.Body); err != nil {
   235  				badRequestStatus(c, w, "Failed to start decompressing gzip request body", err)
   236  				return
   237  			}
   238  			defer raw.Close()
   239  		}
   240  
   241  		var reqBody cfgpb.ValidationRequestMessage
   242  		switch err := json.NewDecoder(raw).Decode(&reqBody); {
   243  		case err != nil:
   244  			badRequestStatus(c, w, "Validation: error decoding request body", err)
   245  			return
   246  		case reqBody.GetConfigSet() == "":
   247  			badRequestStatus(c, w, "Must specify the config_set of the file to validate", nil)
   248  			return
   249  		case reqBody.GetPath() == "":
   250  			badRequestStatus(c, w, "Must specify the path of the file to validate", nil)
   251  			return
   252  		}
   253  
   254  		vc := &validation.Context{Context: c}
   255  		vc.SetFile(reqBody.GetPath())
   256  		err := rules.ValidateConfig(vc, reqBody.GetConfigSet(), reqBody.GetPath(), reqBody.GetContent())
   257  		if err != nil {
   258  			internalErrStatus(c, w, "Validation: transient failure", err)
   259  			return
   260  		}
   261  
   262  		var errors errors.MultiError
   263  		verdict := vc.Finalize()
   264  		if verr, _ := verdict.(*validation.Error); verr != nil {
   265  			errors = verr.Errors
   266  		} else if verdict != nil {
   267  			errors = append(errors, verdict)
   268  		}
   269  
   270  		w.Header().Set("Content-Type", "application/json")
   271  		var msgList []*cfgpb.ValidationResponseMessage_Message
   272  		if len(errors) == 0 {
   273  			logging.Infof(c, "No validation errors")
   274  		} else {
   275  			var errorBuffer bytes.Buffer
   276  			for _, error := range errors {
   277  				// validation.Context supports just 2 severities now,
   278  				// but defensively default to ERROR level in unexpected cases.
   279  				msgSeverity := cfgpb.ValidationResponseMessage_ERROR
   280  				switch severity, ok := validation.SeverityTag.In(error); {
   281  				case !ok:
   282  					logging.Errorf(c, "unset validation.Severity in %s", error)
   283  				case severity == validation.Warning:
   284  					msgSeverity = cfgpb.ValidationResponseMessage_WARNING
   285  				case severity != validation.Blocking:
   286  					logging.Errorf(c, "unrecognized validation.Severity %d in %s", severity, error)
   287  				}
   288  
   289  				err := error.Error()
   290  				msgList = append(msgList, &cfgpb.ValidationResponseMessage_Message{
   291  					Severity: msgSeverity,
   292  					Text:     err,
   293  				})
   294  				errorBuffer.WriteString("\n  " + err)
   295  			}
   296  			logging.Warningf(c, "Validation errors%s", errorBuffer.String())
   297  		}
   298  		if err := json.NewEncoder(w).Encode(cfgpb.ValidationResponseMessage{Messages: msgList}); err != nil {
   299  			internalErrStatus(c, w, "Validation: failed to JSON encode output", err)
   300  		}
   301  	}
   302  }
   303  
   304  // metadataRequestHandler handles the metadata request from luci-config and
   305  // responds with the necessary metadata defined by the given Validator.
   306  func metadataRequestHandler(rules *validation.RuleSet) router.Handler {
   307  	return func(ctx *router.Context) {
   308  		c, w := ctx.Request.Context(), ctx.Writer
   309  
   310  		patterns, err := rules.ConfigPatterns(c)
   311  		if err != nil {
   312  			internalErrStatus(c, w, "Metadata: failed to collect the list of validation patterns", err)
   313  			return
   314  		}
   315  
   316  		meta := cfgpb.ServiceDynamicMetadata{
   317  			Version:                 metaDataFormatVersion,
   318  			SupportsGzipCompression: true,
   319  			Validation: &cfgpb.Validator{
   320  				Url: fmt.Sprintf("https://%s%s", ctx.Request.Host, validationPath),
   321  			},
   322  		}
   323  		for _, p := range patterns {
   324  			meta.Validation.Patterns = append(meta.Validation.Patterns, &cfgpb.ConfigPattern{
   325  				ConfigSet: p.ConfigSet.String(),
   326  				Path:      p.Path.String(),
   327  			})
   328  		}
   329  
   330  		w.Header().Set("Content-Type", "application/json")
   331  		if err := json.NewEncoder(w).Encode(&meta); err != nil {
   332  			internalErrStatus(c, w, "Metadata: failed to JSON encode output", err)
   333  		}
   334  	}
   335  }