go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/gce/appengine/rpc/instances.go (about) 1 // Copyright 2019 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 rpc 16 17 import ( 18 "context" 19 "fmt" 20 "strings" 21 22 "github.com/golang/protobuf/proto" 23 "google.golang.org/grpc/codes" 24 "google.golang.org/grpc/status" 25 "google.golang.org/protobuf/types/known/emptypb" 26 "google.golang.org/protobuf/types/known/timestamppb" 27 28 "go.chromium.org/luci/common/errors" 29 "go.chromium.org/luci/common/logging" 30 "go.chromium.org/luci/common/proto/paged" 31 "go.chromium.org/luci/gae/service/datastore" 32 "go.chromium.org/luci/gce/api/instances/v1" 33 "go.chromium.org/luci/gce/appengine/model" 34 "go.chromium.org/luci/gce/appengine/rpc/internal/metrics" 35 "go.chromium.org/luci/gce/vmtoken" 36 "go.chromium.org/luci/server/auth" 37 ) 38 39 // Instances implements instances.InstancesServer. 40 type Instances struct { 41 } 42 43 // Ensure Instances implements instances.InstancesServer. 44 var _ instances.InstancesServer = &Instances{} 45 46 // deleteByID asynchronously deletes the instance matching the given ID. 47 func deleteByID(c context.Context, id string) (*emptypb.Empty, error) { 48 vm := &model.VM{ 49 ID: id, 50 } 51 if err := datastore.RunInTransaction(c, func(c context.Context) error { 52 switch err := datastore.Get(c, vm); { 53 case err == datastore.ErrNoSuchEntity: 54 return nil 55 case err != nil: 56 return errors.Annotate(err, "failed to fetch VM").Err() 57 case vm.Drained: 58 return nil 59 } 60 vm.Drained = true 61 if err := datastore.Put(c, vm); err != nil { 62 return errors.Annotate(err, "failed to store VM").Err() 63 } 64 return nil 65 }, nil); err != nil { 66 return nil, err 67 } 68 return &emptypb.Empty{}, nil 69 } 70 71 // deleteByHostname asynchronously deletes the instance matching the given 72 // hostname. 73 func deleteByHostname(c context.Context, hostname string) (*emptypb.Empty, error) { 74 var vms []*model.VM 75 // Hostnames are globally unique, so there should be at most one match. 76 q := datastore.NewQuery(model.VMKind).Eq("hostname", hostname).Limit(1) 77 switch err := datastore.GetAll(c, q, &vms); { 78 case err != nil: 79 return nil, errors.Annotate(err, "failed to fetch VM").Err() 80 case len(vms) == 0: 81 return &emptypb.Empty{}, nil 82 default: 83 return deleteByID(c, vms[0].ID) 84 } 85 } 86 87 // Delete handles a request to delete an instance asynchronously. 88 func (*Instances) Delete(c context.Context, req *instances.DeleteRequest) (*emptypb.Empty, error) { 89 switch { 90 case req.GetId() == "" && req.GetHostname() == "": 91 return nil, status.Errorf(codes.InvalidArgument, "ID or hostname is required") 92 case req.Id != "" && req.Hostname != "": 93 return nil, status.Errorf(codes.InvalidArgument, "exactly one of ID or hostname is required") 94 case req.Id != "": 95 return deleteByID(c, req.Id) 96 default: 97 return deleteByHostname(c, req.Hostname) 98 } 99 } 100 101 // toInstance returns an *instances.Instance representation of the given 102 // *model.VM. 103 func toInstance(vm *model.VM) *instances.Instance { 104 inst := &instances.Instance{ 105 Id: vm.ID, 106 ConfigRevision: vm.Revision, 107 Disks: make([]*instances.Disk, len(vm.Attributes.Disk)), 108 Drained: vm.Drained, 109 Hostname: vm.Hostname, 110 NetworkInterfaces: make([]*instances.NetworkInterface, len(vm.NetworkInterfaces)), 111 Lifetime: vm.Lifetime, 112 Prefix: vm.Prefix, 113 Project: vm.Attributes.Project, 114 Swarming: vm.Swarming, 115 Timeout: vm.Timeout, 116 Zone: vm.Attributes.Zone, 117 } 118 for i, d := range vm.Attributes.Disk { 119 inst.Disks[i] = &instances.Disk{ 120 Image: d.Image, 121 } 122 } 123 for i, n := range vm.NetworkInterfaces { 124 inst.NetworkInterfaces[i] = &instances.NetworkInterface{ 125 InternalIp: n.InternalIP, 126 } 127 // GCE currently supports at most one external IP address per network interface. 128 if n.ExternalIP != "" { 129 inst.NetworkInterfaces[i].ExternalIps = []string{n.ExternalIP} 130 } 131 } 132 if vm.Created > 0 { 133 inst.Created = ×tamppb.Timestamp{ 134 Seconds: vm.Created, 135 } 136 } 137 if vm.Connected > 0 { 138 inst.Connected = ×tamppb.Timestamp{ 139 Seconds: vm.Connected, 140 } 141 } 142 return inst 143 } 144 145 // getByID returns the *instances.Instance matching the given ID. Always returns 146 // permission denied when called by VMs. 147 func getByID(c context.Context, id string) (*instances.Instance, error) { 148 if vmtoken.Has(c) { 149 return nil, status.Errorf(codes.PermissionDenied, "unauthorized user") 150 } 151 vm := &model.VM{ 152 ID: id, 153 } 154 switch err := datastore.Get(c, vm); { 155 case err == datastore.ErrNoSuchEntity: 156 return nil, status.Errorf(codes.NotFound, "no VM found with ID %q", id) 157 case err != nil: 158 return nil, errors.Annotate(err, "failed to fetch VM").Err() 159 default: 160 return toInstance(vm), nil 161 } 162 } 163 164 // getByHostname returns the *instances.Instance matching the given hostname. 165 // May be called by VMs. 166 func getByHostname(c context.Context, hostname string) (*instances.Instance, error) { 167 if vmtoken.Has(c) && vmtoken.Hostname(c) != hostname { 168 logging.Warningf(c, "VM %q trying to get host %q", vmtoken.Hostname(c), hostname) 169 return nil, status.Errorf(codes.PermissionDenied, "unauthorized user") 170 } 171 var vms []*model.VM 172 // Hostnames are globally unique, so there should be at most one match. 173 q := datastore.NewQuery(model.VMKind).Eq("hostname", hostname).Limit(1) 174 switch err := datastore.GetAll(c, q, &vms); { 175 case err != nil: 176 return nil, errors.Annotate(err, "failed to fetch VM").Err() 177 case len(vms) == 0: 178 if vmtoken.Has(c) { 179 metrics.UpdateUntrackedGets(c, hostname) 180 logging.Warningf(c, "no VMs found by hostname %q", hostname) 181 return nil, status.Errorf(codes.PermissionDenied, "unauthorized user") 182 } 183 return nil, status.Errorf(codes.NotFound, "no VM found with hostname %q", hostname) 184 default: 185 inst := toInstance(vms[0]) 186 if vmtoken.Has(c) { 187 if !vmtoken.Matches(c, inst.Hostname, inst.Zone, inst.Project) { 188 return nil, status.Errorf(codes.PermissionDenied, "unauthorized user") 189 } 190 // Allow VMs to view minimal self-information. 191 inst = &instances.Instance{ 192 Hostname: inst.Hostname, 193 Swarming: inst.Swarming, 194 } 195 } 196 return inst, nil 197 } 198 } 199 200 // Get handles a request to get an existing instance. 201 func (*Instances) Get(c context.Context, req *instances.GetRequest) (*instances.Instance, error) { 202 switch { 203 case req.GetId() == "" && req.GetHostname() == "": 204 return nil, status.Errorf(codes.InvalidArgument, "ID or hostname is required") 205 case req.Id != "" && req.Hostname != "": 206 return nil, status.Errorf(codes.InvalidArgument, "exactly one of ID or hostname is required") 207 case req.Id != "": 208 return getByID(c, req.Id) 209 default: 210 return getByHostname(c, req.Hostname) 211 } 212 } 213 214 // List handles a request to list instances. 215 func (*Instances) List(c context.Context, req *instances.ListRequest) (*instances.ListResponse, error) { 216 q := datastore.NewQuery(model.VMKind) 217 if req.GetPrefix() != "" { 218 q = q.Eq("prefix", req.Prefix) 219 } 220 if req.GetFilter() != "" { 221 // TODO(crbug/964591): Support other filters. 222 // No compound indices exist, so only simple filters are supported: 223 // https://cloud.google.com/datastore/docs/concepts/indexes#index_configuration. 224 if !strings.HasPrefix(req.Filter, "instances.disks.image=") { 225 return nil, status.Errorf(codes.InvalidArgument, "invalid filter expression %q", req.Filter) 226 } 227 img := strings.TrimPrefix(req.Filter, "instances.disks.image=") 228 q = q.Eq("attributes_indexed", fmt.Sprintf("disk.image:%s", img)) 229 } 230 lim := req.GetPageSize() 231 if lim < 1 || lim > 5000 { 232 lim = 5000 233 } 234 235 rsp := &instances.ListResponse{} 236 if err := paged.Query(c, lim, req.GetPageToken(), rsp, q, func(vm *model.VM) error { 237 rsp.Instances = append(rsp.Instances, toInstance(vm)) 238 return nil 239 }); err != nil { 240 return nil, err 241 } 242 return rsp, nil 243 } 244 245 // instancesPrelude ensures the user is authorized to use the instances API. VMs 246 // have limited access to Get. 247 func instancesPrelude(c context.Context, methodName string, req proto.Message) (context.Context, error) { 248 groups := []string{admins, writers} 249 if isReadOnly(methodName) { 250 groups = append(groups, readers) 251 } 252 switch is, err := auth.IsMember(c, groups...); { 253 case err != nil: 254 return c, err 255 case is: 256 logging.Debugf(c, "%s called %q:\n%s", auth.CurrentIdentity(c), methodName, req) 257 // Remove the VM token if the caller also has OAuth2-based access. 258 // This is because VMs have additional restrictions when using the API. 259 return vmtoken.Clear(c), nil 260 case methodName == "Get" && vmtoken.Has(c): 261 // Get applies additional restrictions when the caller is a VM. 262 logging.Debugf(c, "%s called %q:\n%s", vmtoken.CurrentIdentity(c), methodName, req) 263 return c, nil 264 } 265 return c, status.Errorf(codes.PermissionDenied, "unauthorized user") 266 } 267 268 // NewInstancesServer returns a new instances server. 269 func NewInstancesServer() instances.InstancesServer { 270 return &instances.DecoratedInstances{ 271 Prelude: instancesPrelude, 272 Service: &Instances{}, 273 Postlude: gRPCifyAndLogErr, 274 } 275 }