go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/scheduler/appengine/ui/job.go (about)

     1  // Copyright 2015 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 ui
    16  
    17  import (
    18  	"crypto/rand"
    19  	"crypto/sha256"
    20  	"encoding/base64"
    21  	"encoding/hex"
    22  	"fmt"
    23  	"net/http"
    24  	"sync"
    25  	"time"
    26  
    27  	"google.golang.org/protobuf/types/known/timestamppb"
    28  
    29  	"go.chromium.org/luci/common/clock"
    30  	"go.chromium.org/luci/common/errors"
    31  	mc "go.chromium.org/luci/gae/service/memcache"
    32  	"go.chromium.org/luci/server/auth"
    33  	"go.chromium.org/luci/server/router"
    34  	"go.chromium.org/luci/server/templates"
    35  
    36  	api "go.chromium.org/luci/scheduler/api/scheduler/v1"
    37  	"go.chromium.org/luci/scheduler/appengine/engine"
    38  	"go.chromium.org/luci/scheduler/appengine/internal"
    39  )
    40  
    41  func jobPage(ctx *router.Context) {
    42  	c, w, r, p := ctx.Request.Context(), ctx.Writer, ctx.Request, ctx.Params
    43  	e := config(c).Engine
    44  
    45  	projectID := p.ByName("ProjectID")
    46  	jobName := p.ByName("JobName")
    47  	cursor := r.URL.Query().Get("c")
    48  
    49  	job := jobFromEngine(ctx, projectID, jobName)
    50  	if job == nil {
    51  		return
    52  	}
    53  
    54  	wg := sync.WaitGroup{}
    55  
    56  	var triggers []*internal.Trigger
    57  	var triErr error
    58  
    59  	var triageLog *engine.JobTriageLog
    60  	var triageLogErr error
    61  
    62  	var invsActive []*engine.Invocation
    63  	var invsActiveErr error
    64  	var haveInvsActive bool
    65  
    66  	// Triggers, triage log and active invocations are shown only on the first
    67  	// page of the results.
    68  	if cursor == "" {
    69  		wg.Add(1)
    70  		go func() {
    71  			defer wg.Done()
    72  			triggers, triErr = e.ListTriggers(c, job)
    73  		}()
    74  
    75  		wg.Add(1)
    76  		go func() {
    77  			defer wg.Done()
    78  			triageLog, triageLogErr = e.GetJobTriageLog(c, job)
    79  		}()
    80  
    81  		wg.Add(1)
    82  		go func() {
    83  			defer wg.Done()
    84  			haveInvsActive = true
    85  			invsActive, _, invsActiveErr = e.ListInvocations(c, job, engine.ListInvocationsOpts{
    86  				PageSize:   100000000, // ~ ∞, UI doesn't paginate active invocations
    87  				ActiveOnly: true,
    88  			})
    89  		}()
    90  	}
    91  
    92  	// Historical invocations are shown on all pages.
    93  	var invsLog []*engine.Invocation
    94  	var nextCursor string
    95  	var invsLogErr error
    96  	wg.Add(1)
    97  	go func() {
    98  		defer wg.Done()
    99  		invsLog, nextCursor, invsLogErr = e.ListInvocations(c, job, engine.ListInvocationsOpts{
   100  			PageSize:     50,
   101  			Cursor:       cursor,
   102  			FinishedOnly: true,
   103  		})
   104  	}()
   105  
   106  	wg.Wait()
   107  
   108  	switch {
   109  	case invsActiveErr != nil:
   110  		panic(errors.Annotate(invsActiveErr, "failed to fetch active invocations").Err())
   111  	case invsLogErr != nil:
   112  		panic(errors.Annotate(invsLogErr, "failed to fetch invocation log").Err())
   113  	case triErr != nil:
   114  		panic(errors.Annotate(triErr, "failed to fetch triggers").Err())
   115  	case triageLogErr != nil:
   116  		panic(errors.Annotate(triageLogErr, "failed to fetch triage log").Err())
   117  	}
   118  
   119  	// memcacheKey hashes cursor to reduce its length, since full cursor doesn't
   120  	// fit into memcache key length limits. Use 'v2' scheme for this ('v1' was
   121  	// used before hashing was added).
   122  	memcacheKey := func(cursor string) string {
   123  		blob := sha256.Sum256([]byte(job.JobID + ":" + cursor))
   124  		encoded := base64.StdEncoding.EncodeToString(blob[:])
   125  		return "v2:cursors:list_invocations:" + encoded
   126  	}
   127  
   128  	// Cheesy way of implementing bidirectional pagination with forward-only
   129  	// datastore cursors: store mapping from a page cursor to a previous page
   130  	// cursor in the memcache.
   131  	prevCursor := ""
   132  	if cursor != "" {
   133  		if itm, err := mc.GetKey(c, memcacheKey(cursor)); err == nil {
   134  			prevCursor = string(itm.Value())
   135  		}
   136  	}
   137  	if nextCursor != "" {
   138  		itm := mc.NewItem(c, memcacheKey(nextCursor))
   139  		if cursor == "" {
   140  			itm.SetValue([]byte("NULL"))
   141  		} else {
   142  			itm.SetValue([]byte(cursor))
   143  		}
   144  		itm.SetExpiration(24 * time.Hour)
   145  		mc.Set(c, itm)
   146  	}
   147  
   148  	// List of invocations in job.ActiveInvocations may contain recently finished
   149  	// invocations not yet removed from the active list by the triage procedure.
   150  	// 'invsActive' is always more accurate, since it fetches invocations from
   151  	// the datastore and checks their status. So update the job entity to be more
   152  	// accurate if we can. This is important for reporting jobs with recently
   153  	// finished invocations as not running. Otherwise the UI page may appear
   154  	// non-consistent (no running invocations in the list, yet the job's status is
   155  	// displayed as "Running"). This is a bit of a hack...
   156  	if haveInvsActive {
   157  		ids := make([]int64, len(invsActive))
   158  		for i, inv := range invsActive {
   159  			ids[i] = inv.ID
   160  		}
   161  		job.ActiveInvocations = ids
   162  	}
   163  
   164  	jobUI := makeJob(c, job, triageLog)
   165  	invsActiveUI := make([]*invocation, len(invsActive))
   166  	for i, inv := range invsActive {
   167  		invsActiveUI[i] = makeInvocation(jobUI, inv)
   168  	}
   169  	invsLogUI := make([]*invocation, len(invsLog))
   170  	for i, inv := range invsLog {
   171  		invsLogUI[i] = makeInvocation(jobUI, inv)
   172  	}
   173  
   174  	templates.MustRender(c, w, "pages/job.html", map[string]any{
   175  		"Job":               jobUI,
   176  		"ShowJobHeader":     cursor == "",
   177  		"InvocationsActive": invsActiveUI,
   178  		"InvocationsLog":    invsLogUI,
   179  		"PendingTriggers":   makeTriggerList(jobUI.now, triggers),
   180  		"PrevCursor":        prevCursor,
   181  		"NextCursor":        nextCursor,
   182  	})
   183  }
   184  
   185  ////////////////////////////////////////////////////////////////////////////////
   186  // Actions.
   187  
   188  var errCannotTriggerPausedJob = errors.New("cannot trigger paused job")
   189  
   190  func triggerJobAction(c *router.Context) {
   191  	handleJobAction(c, func(job *engine.Job) error {
   192  		ctx := c.Request.Context()
   193  		eng := config(ctx).Engine
   194  
   195  		// Paused jobs just silently ignore triggers. Warn the user.
   196  		if job.Paused {
   197  			return errCannotTriggerPausedJob
   198  		}
   199  
   200  		// Generate random ID for the trigger, since we need one. They are usually
   201  		// used to guarantee idempotency, and thus should be provided by the
   202  		// triggering side (which in this case is end-user's browser). We could
   203  		// potentially generate the ID in Javascript and submit the trigger via URL
   204  		// fetch,  and retry on transient errors until success, but this looks like
   205  		// too much hassle for little gains.
   206  		buf := make([]byte, 8)
   207  		if _, err := rand.Read(buf); err != nil {
   208  			return err
   209  		}
   210  		id := hex.EncodeToString(buf)
   211  
   212  		// This will check the ACL and submit the trigger.
   213  		return eng.EmitTriggers(ctx, map[*engine.Job][]*internal.Trigger{
   214  			job: {
   215  				{
   216  					Id:            id,
   217  					Created:       timestamppb.New(clock.Now(ctx)),
   218  					Title:         "Triggered via web UI",
   219  					EmittedByUser: string(auth.CurrentIdentity(ctx)),
   220  					Payload: &internal.Trigger_Webui{
   221  						Webui: &api.WebUITrigger{},
   222  					},
   223  				},
   224  			},
   225  		})
   226  	})
   227  }
   228  
   229  func pauseJobAction(c *router.Context) {
   230  	handleJobAction(c, func(job *engine.Job) error {
   231  		reason := c.Request.PostForm.Get("reason")
   232  		return config(c.Request.Context()).Engine.PauseJob(c.Request.Context(), job, reason)
   233  	})
   234  }
   235  
   236  func resumeJobAction(c *router.Context) {
   237  	handleJobAction(c, func(job *engine.Job) error {
   238  		reason := c.Request.PostForm.Get("reason") // note: currently unset
   239  		return config(c.Request.Context()).Engine.ResumeJob(c.Request.Context(), job, reason)
   240  	})
   241  }
   242  
   243  func abortJobAction(c *router.Context) {
   244  	handleJobAction(c, func(job *engine.Job) error {
   245  		return config(c.Request.Context()).Engine.AbortJob(c.Request.Context(), job)
   246  	})
   247  }
   248  
   249  func handleJobAction(c *router.Context, cb func(*engine.Job) error) {
   250  	projectID := c.Params.ByName("ProjectID")
   251  	jobName := c.Params.ByName("JobName")
   252  
   253  	job := jobFromEngine(c, projectID, jobName)
   254  	if job == nil {
   255  		return
   256  	}
   257  
   258  	switch err := cb(job); {
   259  	case err == errCannotTriggerPausedJob:
   260  		uiErrCannotTriggerPausedJob.render(c)
   261  	case err == engine.ErrNoPermission:
   262  		uiErrActionForbidden.render(c)
   263  	case err != nil:
   264  		panic(err)
   265  	default:
   266  		http.Redirect(c.Writer, c.Request, fmt.Sprintf("/jobs/%s/%s", projectID, jobName), http.StatusFound)
   267  	}
   268  }