go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/scheduler/appengine/apiservers/scheduler.go (about) 1 // Copyright 2016 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 apiservers 16 17 import ( 18 "context" 19 "errors" 20 "fmt" 21 "strings" 22 "time" 23 24 "google.golang.org/grpc/codes" 25 "google.golang.org/grpc/status" 26 "google.golang.org/protobuf/types/known/emptypb" 27 "google.golang.org/protobuf/types/known/timestamppb" 28 29 "go.chromium.org/luci/auth/identity" 30 "go.chromium.org/luci/common/clock" 31 "go.chromium.org/luci/server/auth" 32 33 "go.chromium.org/luci/scheduler/api/scheduler/v1" 34 "go.chromium.org/luci/scheduler/appengine/catalog" 35 "go.chromium.org/luci/scheduler/appengine/engine" 36 "go.chromium.org/luci/scheduler/appengine/internal" 37 "go.chromium.org/luci/scheduler/appengine/presentation" 38 ) 39 40 // SchedulerServer implements scheduler.Scheduler API. 41 type SchedulerServer struct { 42 scheduler.UnimplementedSchedulerServer 43 44 Engine engine.Engine 45 Catalog catalog.Catalog 46 } 47 48 var _ scheduler.SchedulerServer = (*SchedulerServer)(nil) 49 50 // GetJobs fetches all jobs satisfying JobsRequest and visibility ACLs. 51 func (s *SchedulerServer) GetJobs(ctx context.Context, in *scheduler.JobsRequest) (*scheduler.JobsReply, error) { 52 if in.GetCursor() != "" { 53 // Paging in GetJobs isn't implemented until we have enough jobs to care. 54 // Until then, not empty cursor implies no more jobs to return. 55 return &scheduler.JobsReply{Jobs: []*scheduler.Job{}, NextCursor: ""}, nil 56 } 57 var ejobs []*engine.Job 58 var err error 59 if in.GetProject() == "" { 60 ejobs, err = s.Engine.GetVisibleJobs(ctx) 61 } else { 62 ejobs, err = s.Engine.GetVisibleProjectJobs(ctx, in.GetProject()) 63 } 64 if err != nil { 65 return nil, status.Errorf(codes.Internal, "internal error: %s", err) 66 } 67 68 jobs := make([]*scheduler.Job, len(ejobs)) 69 for i, ej := range ejobs { 70 traits, err := presentation.GetJobTraits(ctx, s.Catalog, ej) 71 if err != nil { 72 return nil, status.Errorf(codes.Internal, "failed to get traits: %s", err) 73 } 74 jobs[i] = &scheduler.Job{ 75 JobRef: &scheduler.JobRef{ 76 Project: ej.ProjectID, 77 Job: ej.JobName(), 78 }, 79 Schedule: ej.Schedule, 80 State: &scheduler.JobState{ 81 UiStatus: string(presentation.GetPublicStateKind(ej, traits)), 82 }, 83 Paused: ej.Paused, 84 } 85 } 86 return &scheduler.JobsReply{Jobs: jobs, NextCursor: ""}, nil 87 } 88 89 func (s *SchedulerServer) GetInvocations(ctx context.Context, in *scheduler.InvocationsRequest) (*scheduler.InvocationsReply, error) { 90 job, err := s.getJob(ctx, in.GetJobRef()) 91 if err != nil { 92 return nil, err 93 } 94 95 pageSize := 50 96 if in.PageSize > 0 && int(in.PageSize) < pageSize { 97 pageSize = int(in.PageSize) 98 } 99 100 einvs, cursor, err := s.Engine.ListInvocations(ctx, job, engine.ListInvocationsOpts{ 101 PageSize: pageSize, 102 Cursor: in.Cursor, 103 }) 104 if err != nil { 105 return nil, status.Errorf(codes.Internal, "internal error: %s", err) 106 } 107 108 invs := make([]*scheduler.Invocation, len(einvs)) 109 for i, einv := range einvs { 110 invs[i] = invToProto(einv, in.JobRef) 111 } 112 return &scheduler.InvocationsReply{Invocations: invs, NextCursor: cursor}, nil 113 } 114 115 func (s *SchedulerServer) GetInvocation(ctx context.Context, in *scheduler.InvocationRef) (*scheduler.Invocation, error) { 116 job, err := s.getJob(ctx, in.GetJobRef()) 117 if err != nil { 118 return nil, err 119 } 120 switch inv, err := s.Engine.GetInvocation(ctx, job, in.InvocationId); { 121 case err == engine.ErrNoSuchInvocation: 122 return nil, status.Errorf(codes.NotFound, "no such invocation") 123 case err != nil: 124 return nil, status.Errorf(codes.Internal, "internal error: %s", err) 125 default: 126 return invToProto(inv, in.JobRef), nil 127 } 128 } 129 130 //// Actions. 131 132 func (s *SchedulerServer) PauseJob(ctx context.Context, in *scheduler.JobRef) (*emptypb.Empty, error) { 133 return s.runAction(ctx, in, func(job *engine.Job) error { 134 return s.Engine.PauseJob(ctx, job, "paused through RPC API") 135 }) 136 } 137 138 func (s *SchedulerServer) ResumeJob(ctx context.Context, in *scheduler.JobRef) (*emptypb.Empty, error) { 139 return s.runAction(ctx, in, func(job *engine.Job) error { 140 return s.Engine.ResumeJob(ctx, job, "resumed through RPC API") 141 }) 142 } 143 144 func (s *SchedulerServer) AbortJob(ctx context.Context, in *scheduler.JobRef) (*emptypb.Empty, error) { 145 return s.runAction(ctx, in, func(job *engine.Job) error { 146 return s.Engine.AbortJob(ctx, job) 147 }) 148 } 149 150 func (s *SchedulerServer) AbortInvocation(ctx context.Context, in *scheduler.InvocationRef) (*emptypb.Empty, error) { 151 return s.runAction(ctx, in.GetJobRef(), func(job *engine.Job) error { 152 return s.Engine.AbortInvocation(ctx, job, in.GetInvocationId()) 153 }) 154 } 155 156 func (s *SchedulerServer) EmitTriggers(ctx context.Context, in *scheduler.EmitTriggersRequest) (*emptypb.Empty, error) { 157 caller := auth.CurrentIdentity(ctx) 158 159 // Optionally use client-provided time if it is within reasonable margins. 160 // This is needed to make EmitTriggers idempotent (when it emits a batch). 161 now := clock.Now(ctx) 162 if in.Timestamp != 0 { 163 if in.Timestamp < 0 || in.Timestamp > (1<<53) { 164 return nil, status.Errorf(codes.InvalidArgument, 165 "the provided timestamp doesn't look like a valid number of microseconds since epoch") 166 } 167 ts := time.Unix(0, in.Timestamp*1000) 168 if ts.After(now.Add(15 * time.Minute)) { 169 return nil, status.Errorf(codes.InvalidArgument, 170 "the provided timestamp (%s) is more than 15 min in the future based on the server clock value %s", 171 ts, now) 172 } 173 if ts.Before(now.Add(-15 * time.Minute)) { 174 return nil, status.Errorf(codes.InvalidArgument, 175 "the provided timestamp (%s) is more than 15 min in the past based on the server clock value %s", 176 ts, now) 177 } 178 now = ts 179 } 180 181 // Build a mapping "jobID => list of triggers", convert public representation 182 // of a trigger into internal one, validating them. 183 triggersPerJobID := map[string][]*internal.Trigger{} 184 for index, batch := range in.Batches { 185 tr, err := internalTrigger(batch.Trigger, now, caller, index) 186 if err != nil { 187 return nil, status.Errorf(codes.InvalidArgument, "bad trigger #%d (%q) - %s", index, batch.Trigger.Id, err) 188 } 189 for _, jobRef := range batch.Jobs { 190 jobID := jobRef.GetProject() + "/" + jobRef.GetJob() 191 triggersPerJobID[jobID] = append(triggersPerJobID[jobID], tr) 192 } 193 } 194 195 // Check jobs presence and "scheduler.jobs.get" permission. 196 jobIDs := make([]string, 0, len(triggersPerJobID)) 197 for id := range triggersPerJobID { 198 jobIDs = append(jobIDs, id) 199 } 200 visible, err := s.Engine.GetVisibleJobBatch(ctx, jobIDs) 201 switch { 202 case err != nil: 203 return nil, status.Errorf(codes.Internal, "internal error: %s", err) 204 case len(visible) != len(jobIDs): 205 missing := make([]string, 0, len(jobIDs)-len(visible)) 206 for _, j := range jobIDs { 207 if visible[j] == nil { 208 missing = append(missing, j) 209 } 210 } 211 return nil, status.Errorf(codes.NotFound, 212 "no such job or no permission to see it: %s", strings.Join(missing, ", ")) 213 } 214 215 // Submit the request to the Engine. 216 triggersPerJob := make(map[*engine.Job][]*internal.Trigger, len(visible)) 217 for id, job := range visible { 218 triggersPerJob[job] = triggersPerJobID[id] 219 } 220 switch err = s.Engine.EmitTriggers(ctx, triggersPerJob); { 221 case err == engine.ErrNoPermission: 222 return nil, status.Errorf(codes.PermissionDenied, "no permission to execute the action") 223 case err != nil: 224 return nil, status.Errorf(codes.Internal, "internal error: %s", err) 225 } 226 227 return &emptypb.Empty{}, nil 228 } 229 230 //// Private helpers. 231 232 // getJob fetches a job, checking "scheduler.jobs.get" permission. 233 // 234 // Returns grpc errors that can be returned as is. 235 func (s *SchedulerServer) getJob(ctx context.Context, ref *scheduler.JobRef) (*engine.Job, error) { 236 jobID := ref.GetProject() + "/" + ref.GetJob() 237 switch job, err := s.Engine.GetVisibleJob(ctx, jobID); { 238 case err == nil: 239 return job, nil 240 case err == engine.ErrNoSuchJob: 241 return nil, status.Errorf(codes.NotFound, "no such job or no permission to see it") 242 default: 243 return nil, status.Errorf(codes.Internal, "internal error when fetching job: %s", err) 244 } 245 } 246 247 func (s *SchedulerServer) runAction(ctx context.Context, ref *scheduler.JobRef, action func(*engine.Job) error) (*emptypb.Empty, error) { 248 job, err := s.getJob(ctx, ref) 249 if err != nil { 250 return nil, err 251 } 252 switch err := action(job); { 253 case err == nil: 254 return &emptypb.Empty{}, nil 255 case err == engine.ErrNoSuchJob: 256 return nil, status.Errorf(codes.NotFound, "no such job or no permission to see it") 257 case err == engine.ErrNoPermission: 258 return nil, status.Errorf(codes.PermissionDenied, "no permission to execute the action") 259 case err == engine.ErrNoSuchInvocation: 260 return nil, status.Errorf(codes.NotFound, "no such invocation") 261 default: 262 return nil, status.Errorf(codes.Internal, "internal error: %s", err) 263 } 264 } 265 266 func internalTrigger(t *scheduler.Trigger, now time.Time, who identity.Identity, index int) (*internal.Trigger, error) { 267 if t.Id == "" { 268 return nil, fmt.Errorf("trigger id is required") 269 } 270 out := &internal.Trigger{ 271 Id: t.Id, 272 Created: timestamppb.New(now), 273 OrderInBatch: int64(index), 274 Title: t.Title, 275 Url: t.Url, 276 EmittedByUser: string(who), 277 } 278 if t.Payload != nil { 279 // Ugh... 280 switch v := t.Payload.(type) { 281 case *scheduler.Trigger_Cron: 282 return nil, errors.New("emitting cron triggers through API is not allowed") 283 case *scheduler.Trigger_Webui: 284 return nil, errors.New("emitting web UI triggers through API is not allowed") 285 case *scheduler.Trigger_Noop: 286 out.Payload = &internal.Trigger_Noop{Noop: v.Noop} 287 case *scheduler.Trigger_Gitiles: 288 out.Payload = &internal.Trigger_Gitiles{Gitiles: v.Gitiles} 289 case *scheduler.Trigger_Buildbucket: 290 out.Payload = &internal.Trigger_Buildbucket{Buildbucket: v.Buildbucket} 291 default: 292 return nil, errors.New("unrecognized trigger payload") 293 } 294 } 295 return out, nil 296 } 297 298 func invToProto(inv *engine.Invocation, jobRef *scheduler.JobRef) *scheduler.Invocation { 299 out := &scheduler.Invocation{ 300 InvocationRef: &scheduler.InvocationRef{ 301 JobRef: jobRef, 302 InvocationId: inv.ID, 303 }, 304 StartedTs: inv.Started.UnixNano() / 1000, 305 TriggeredBy: string(inv.TriggeredBy), 306 Status: string(inv.Status), 307 Final: inv.Status.Final(), 308 ConfigRevision: inv.Revision, 309 ViewUrl: inv.ViewURL, 310 } 311 if inv.Status.Final() { 312 out.FinishedTs = inv.Finished.UnixNano() / 1000 313 } 314 return out 315 }