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  }