blob: cf3e3bc8095ac8e21135983ef1218d36229e4967 [file] [log] [blame]
// Copyright 2018 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package rpc
import (
"context"
"github.com/golang/protobuf/proto"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/proto/paged"
"go.chromium.org/luci/gae/service/datastore"
"go.chromium.org/luci/server/auth"
"go.chromium.org/luci/gce/api/config/v1"
"go.chromium.org/luci/gce/appengine/model"
)
// Config implements config.ConfigurationServer.
type Config struct {
}
// Ensure Config implements config.ConfigurationServer.
var _ config.ConfigurationServer = &Config{}
// Delete handles a request to delete a config.
// For app-internal use only.
func (*Config) Delete(c context.Context, req *config.DeleteRequest) (*emptypb.Empty, error) {
if req.GetId() == "" {
return nil, status.Errorf(codes.InvalidArgument, "ID is required")
}
if err := datastore.Delete(c, &model.Config{ID: req.Id}); err != nil {
return nil, errors.Annotate(err, "failed to delete config").Err()
}
return &emptypb.Empty{}, nil
}
// Ensure handles a request to create or update a config.
// For app-internal use only.
func (*Config) Ensure(c context.Context, req *config.EnsureRequest) (*config.Config, error) {
if req.GetId() == "" {
return nil, status.Errorf(codes.InvalidArgument, "ID is required")
}
cfg := &model.Config{
ID: req.Id,
}
if err := datastore.RunInTransaction(c, func(c context.Context) error {
var priorAmount int32
var priorDUTs map[string]*emptypb.Empty
switch err := datastore.Get(c, cfg); {
case err == nil:
// Don't forget potentially custom amount set via Update RPC.
priorAmount = cfg.Config.CurrentAmount
priorDUTs = cfg.Config.GetDuts()
case err == datastore.ErrNoSuchEntity:
priorAmount = 0
default:
return errors.Annotate(err, "failed to fetch config").Err()
}
cfg.Config = req.Config
cfg.Config.CurrentAmount = priorAmount
cfg.Config.Duts = priorDUTs
if err := datastore.Put(c, cfg); err != nil {
return errors.Annotate(err, "failed to store config").Err()
}
return nil
}, nil); err != nil {
return nil, err
}
return cfg.Config, nil
}
// Get handles a request to get a config.
func (*Config) Get(c context.Context, req *config.GetRequest) (*config.Config, error) {
if req.GetId() == "" {
return nil, status.Errorf(codes.InvalidArgument, "ID is required")
}
cfg, err := getConfigByID(c, req.Id)
if err != nil {
return nil, err
}
return cfg.Config, nil
}
// List handles a request to list all configs.
func (*Config) List(c context.Context, req *config.ListRequest) (*config.ListResponse, error) {
rsp := &config.ListResponse{}
if err := paged.Query(c, req.GetPageSize(), req.GetPageToken(), rsp, datastore.NewQuery(model.ConfigKind), func(cfg *model.Config) error {
rsp.Configs = append(rsp.Configs, cfg.Config)
return nil
}); err != nil {
return nil, err
}
return rsp, nil
}
// Update handles a request to update a config.
func (*Config) Update(c context.Context, req *config.UpdateRequest) (*config.Config, error) {
switch {
case req.GetId() == "":
return nil, status.Errorf(codes.InvalidArgument, "ID is required")
case len(req.UpdateMask.GetPaths()) == 0:
return nil, status.Errorf(codes.InvalidArgument, "update mask is required")
}
for _, p := range req.UpdateMask.Paths {
switch p {
case "config.current_amount":
ret, err := updateAmount(c, req)
return ret, err
case "config.duts":
ret, err := updateDUTs(c, req)
return ret, err
default:
return nil, status.Errorf(codes.InvalidArgument, "field %q is invalid or immutable", p)
}
}
return nil, nil
}
// updateDUTs updates config.Duts.
// This is used for go/cloudbots.
func updateDUTs(c context.Context, req *config.UpdateRequest) (*config.Config, error) {
logging.Debugf(c, "update config %s duts to %d", req.GetId(), req.GetConfig().GetDuts())
var ret *config.Config
if err := datastore.RunInTransaction(c, func(c context.Context) error {
cfg, err := getConfigByID(c, req.Id)
if err != nil {
return err
}
ret = cfg.Config
duts := req.Config.GetDuts()
if dutsEqual(ret.GetDuts(), duts) {
return nil
}
cfg.Config.Duts = duts
// Config.CurrentAmount serves as a second source of truth.
// The first source of truth being Config.Duts.
cfg.Config.CurrentAmount = int32(len(duts))
if err := datastore.Put(c, cfg); err != nil {
return errors.Annotate(err, "failed to store config").Err()
}
return nil
}, nil); err != nil {
return nil, err
}
return ret, nil
}
// updateAmount updates config.CurrentAmount.
func updateAmount(c context.Context, req *config.UpdateRequest) (*config.Config, error) {
logging.Debugf(c, "update config %s current_amount to %d", req.GetId(), req.GetConfig().GetCurrentAmount())
var ret *config.Config
if err := datastore.RunInTransaction(c, func(c context.Context) error {
cfg, err := getConfigByID(c, req.Id)
if err != nil {
return err
}
ret = cfg.Config
amt, err := cfg.Config.ComputeAmount(req.Config.GetCurrentAmount(), clock.Now(c))
switch {
case err != nil:
return errors.Annotate(err, "failed to parse amount").Err()
case amt == cfg.Config.CurrentAmount:
return nil
default:
cfg.Config.CurrentAmount = amt
if err := datastore.Put(c, cfg); err != nil {
return errors.Annotate(err, "failed to store config").Err()
}
return nil
}
}, nil); err != nil {
return nil, err
}
return ret, nil
}
// configPrelude ensures the user is authorized to use the config API.
func configPrelude(c context.Context, methodName string, req proto.Message) (context.Context, error) {
if methodName == "Update" || methodName == "Get" {
// Update performs its own authorization checks, so allow all callers through.
logging.Debugf(c, "%s called %q:\n%s", auth.CurrentIdentity(c), methodName, req)
return c, nil
}
if !isReadOnly(methodName) {
return c, status.Errorf(codes.PermissionDenied, "unauthorized user")
}
switch is, err := auth.IsMember(c, admins, writers, readers); {
case err != nil:
return c, err
case is:
logging.Debugf(c, "%s called %q:\n%s", auth.CurrentIdentity(c), methodName, req)
return c, nil
}
return c, status.Errorf(codes.PermissionDenied, "unauthorized user")
}
// NewConfigurationServer returns a new configuration server.
func NewConfigurationServer() config.ConfigurationServer {
return &config.DecoratedConfiguration{
Prelude: configPrelude,
Service: &Config{},
Postlude: gRPCifyAndLogErr,
}
}
func getConfigByID(c context.Context, id string) (*model.Config, error) {
cfg := &model.Config{
ID: id,
}
switch err := datastore.Get(c, cfg); err {
case nil:
case datastore.ErrNoSuchEntity:
return nil, notFoundErr(id)
default:
return nil, errors.Annotate(err, "failed to fetch config").Err()
}
switch is, err := auth.IsMember(c, cfg.Config.GetOwner()...); {
case err != nil:
return nil, err
case !is:
return nil, notFoundErr(id)
}
return cfg, nil
}
func notFoundErr(id string) error {
// To avoid revealing information about config existence to unauthorized users,
// not found and permission denied responses should be ambiguous.
return status.Errorf(codes.NotFound, "no config found with ID %q or unauthorized user", id)
}
// dutsEqual test equality between two sets of DUTs.
// The sets are equal if they have the same length and
// if the same keys are present in both sets.
func dutsEqual(x, y map[string]*emptypb.Empty) bool {
if len(x) != len(y) {
return false
}
for k := range x {
if _, ok := y[k]; !ok {
return false
}
}
return true
}