go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/quotabeta/admin.go (about) 1 // Copyright 2022 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 quota 16 17 import ( 18 "bytes" 19 "context" 20 "crypto/sha256" 21 "fmt" 22 "strings" 23 "text/template" 24 25 "github.com/gomodule/redigo/redis" 26 "google.golang.org/grpc/codes" 27 "google.golang.org/protobuf/runtime/protoiface" 28 29 "go.chromium.org/luci/common/clock" 30 "go.chromium.org/luci/common/errors" 31 "go.chromium.org/luci/common/logging" 32 "go.chromium.org/luci/grpc/appstatus" 33 34 "go.chromium.org/luci/server/auth" 35 pb "go.chromium.org/luci/server/quotabeta/proto" 36 "go.chromium.org/luci/server/quotabeta/quotaconfig" 37 "go.chromium.org/luci/server/redisconn" 38 ) 39 40 // getEntry is a *template.Template for a Lua script which gets the given quota 41 // entry from Redis. Should be used after a single updateEntry. 42 // 43 // Template variables: 44 // Var: Name of a Lua variable holding the quota entry in memory. 45 var getEntry = template.Must(template.New("getEntry").Parse(` 46 return {{.Var}}["resources"] 47 `)) 48 49 // Ensure quotaAdmin implements QuotaAdminServer at compile-time. 50 var _ pb.QuotaAdminServer = "aAdmin{} 51 52 // quotaAdmin implements pb.QuotaAdminServer. 53 type quotaAdmin struct { 54 } 55 56 // Get returns the available resources for the given policy. 57 func (*quotaAdmin) Get(ctx context.Context, req *pb.GetRequest) (*pb.QuotaEntry, error) { 58 if req.GetPolicy() == "" { 59 return nil, appstatus.Errorf(codes.InvalidArgument, "policy is required") 60 } 61 62 now := clock.Now(ctx).Unix() 63 cfg := getInterface(ctx) 64 rsp := &pb.QuotaEntry{} 65 66 rsp.Name = req.Policy 67 if strings.Contains(req.Policy, "${user}") { 68 if req.User == "" { 69 return nil, appstatus.BadRequest(errors.New("user not specified")) 70 } 71 rsp.Name = strings.ReplaceAll(rsp.Name, "${user}", req.User) 72 } 73 rsp.DbName = fmt.Sprintf("entry:%x", sha256.Sum256([]byte(rsp.Name))) 74 def, err := cfg.Get(ctx, req.Policy) 75 switch { 76 case err == quotaconfig.ErrNotFound: 77 return nil, appstatus.Errorf(codes.NotFound, "policy %q (db name: %s) not found", rsp.Name, rsp.DbName) 78 case err != nil: 79 return nil, errors.Annotate(err, "fetching config").Err() 80 } 81 82 conn, err := redisconn.Get(ctx) 83 if err != nil { 84 return nil, errors.Annotate(err, "establishing connection").Err() 85 } 86 defer conn.Close() 87 88 s := bytes.NewBufferString("local entry = {}\n") 89 if err := updateEntry.Execute(s, map[string]any{ 90 "Var": "entry", 91 "Name": rsp.DbName, 92 "Default": def.Resources, 93 "Now": now, 94 "Replenishment": def.Replenishment, 95 "Amount": 0, 96 }); err != nil { 97 return nil, errors.Annotate(err, "rendering template %q", updateEntry.Name()).Err() 98 } 99 if err := getEntry.Execute(s, map[string]any{ 100 "Var": "entry", 101 }); err != nil { 102 return nil, errors.Annotate(err, "rendering template %q", setEntry.Name()).Err() 103 } 104 105 val, err := redis.NewScript(0, s.String()).Do(conn) 106 if err != nil { 107 return nil, err 108 } 109 res, ok := val.(int64) 110 if !ok { 111 return nil, errors.Annotate(err, "expected int64 not %T", val).Err() 112 } 113 rsp.Resources = res 114 return rsp, nil 115 } 116 117 // overwriteEntry is a *template.Template for a Lua script which sets the given 118 // quota entry in Redis, overwriting any existing entry. 119 // 120 // Template variables: 121 // Name: Name of the quota entry to update. 122 // Resources: Number of resources to set. 123 // Now: Update time in seconds since epoch. 124 var overwriteEntry = template.Must(template.New("getEntry").Parse(` 125 return redis.call("HMSET", "{{.Name}}", "resources", {{.Resources}}, "updated", {{.Now}}) 126 `)) 127 128 // Set updates the available resources for the given policy. 129 func (*quotaAdmin) Set(ctx context.Context, req *pb.SetRequest) (*pb.QuotaEntry, error) { 130 switch { 131 case req.GetPolicy() == "": 132 return nil, appstatus.Errorf(codes.InvalidArgument, "policy is required") 133 case req.Resources < 0: 134 return nil, appstatus.Errorf(codes.InvalidArgument, "resources must not be negative") 135 } 136 137 now := clock.Now(ctx).Unix() 138 cfg := getInterface(ctx) 139 rsp := &pb.QuotaEntry{} 140 141 rsp.Name = req.Policy 142 if strings.Contains(req.Policy, "${user}") { 143 if req.User == "" { 144 return nil, appstatus.BadRequest(errors.New("user not specified")) 145 } 146 rsp.Name = strings.ReplaceAll(rsp.Name, "${user}", req.User) 147 } 148 rsp.DbName = fmt.Sprintf("entry:%x", sha256.Sum256([]byte(rsp.Name))) 149 def, err := cfg.Get(ctx, req.Policy) 150 switch { 151 case err == quotaconfig.ErrNotFound: 152 return nil, appstatus.Errorf(codes.NotFound, "policy %q (db name: %s) not found", rsp.Name, rsp.DbName) 153 case err != nil: 154 return nil, errors.Annotate(err, "fetching config").Err() 155 } 156 rsp.Resources = req.Resources 157 if rsp.Resources > def.Resources { 158 rsp.Resources = def.Resources 159 } 160 161 conn, err := redisconn.Get(ctx) 162 if err != nil { 163 return nil, errors.Annotate(err, "establishing connection").Err() 164 } 165 defer conn.Close() 166 167 s := bytes.NewBufferString("") 168 if err := overwriteEntry.Execute(s, map[string]any{ 169 "Name": rsp.DbName, 170 "Resources": rsp.Resources, 171 "Now": now, 172 }); err != nil { 173 return nil, errors.Annotate(err, "rendering template %q", overwriteEntry.Name()).Err() 174 } 175 176 if _, err := redis.NewScript(0, s.String()).Do(conn); err != nil { 177 return nil, err 178 } 179 return rsp, nil 180 } 181 182 // NewQuotaAdminServer returns a pb.QuotaAdminServer with ACLs limited to the 183 // given groups. Readers have access to Get. 184 // TODO(crbug/1280055): Add more admin methods, detail access here. 185 func NewQuotaAdminServer(readerGroup, writerGroup string) pb.QuotaAdminServer { 186 writers := []string{writerGroup} 187 readers := append(writers, readerGroup) 188 return &pb.DecoratedQuotaAdmin{ 189 // Prelude restricts access to the given groups. 190 Prelude: func(ctx context.Context, methodName string, _ protoiface.MessageV1) (context.Context, error) { 191 groups := writers 192 if methodName == "Get" { 193 groups = readers 194 } 195 switch is, err := auth.IsMember(ctx, groups...); { 196 case err != nil: 197 return ctx, errors.Annotate(err, "auth.IsMember").Err() 198 case is: 199 logging.Debugf(ctx, "%s called %q", auth.CurrentIdentity(ctx), methodName) 200 return ctx, nil 201 default: 202 return ctx, appstatus.Errorf(codes.PermissionDenied, "unauthorized user %s", auth.CurrentIdentity(ctx)) 203 } 204 }, 205 206 Service: "aAdmin{}, 207 208 // Postlude logs non-GRPC errors, and returns them as gRPC internal errors. 209 Postlude: func(ctx context.Context, _ string, _ protoiface.MessageV1, err error) error { 210 return appstatus.GRPCifyAndLog(ctx, err) 211 }, 212 } 213 }