blob: 32a6a661e61b63f482852f1fe7904b02712f5e56 [file] [log] [blame]
// Copyright 2015 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"sync"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/errors"
mc "go.chromium.org/luci/gae/service/memcache"
"go.chromium.org/luci/server/auth"
"go.chromium.org/luci/server/router"
"go.chromium.org/luci/server/templates"
api "go.chromium.org/luci/scheduler/api/scheduler/v1"
"go.chromium.org/luci/scheduler/appengine/engine"
"go.chromium.org/luci/scheduler/appengine/internal"
)
func jobPage(ctx *router.Context) {
c, w, r, p := ctx.Context, ctx.Writer, ctx.Request, ctx.Params
e := config(c).Engine
projectID := p.ByName("ProjectID")
jobName := p.ByName("JobName")
cursor := r.URL.Query().Get("c")
job := jobFromEngine(ctx, projectID, jobName)
if job == nil {
return
}
wg := sync.WaitGroup{}
var triggers []*internal.Trigger
var triErr error
var triageLog *engine.JobTriageLog
var triageLogErr error
var invsActive []*engine.Invocation
var invsActiveErr error
var haveInvsActive bool
// Triggers, triage log and active invocations are shown only on the first
// page of the results.
if cursor == "" {
wg.Add(1)
go func() {
defer wg.Done()
triggers, triErr = e.ListTriggers(c, job)
}()
wg.Add(1)
go func() {
defer wg.Done()
triageLog, triageLogErr = e.GetJobTriageLog(c, job)
}()
wg.Add(1)
go func() {
defer wg.Done()
haveInvsActive = true
invsActive, _, invsActiveErr = e.ListInvocations(c, job, engine.ListInvocationsOpts{
PageSize: 100000000, // ~ ∞, UI doesn't paginate active invocations
ActiveOnly: true,
})
}()
}
// Historical invocations are shown on all pages.
var invsLog []*engine.Invocation
var nextCursor string
var invsLogErr error
wg.Add(1)
go func() {
defer wg.Done()
invsLog, nextCursor, invsLogErr = e.ListInvocations(c, job, engine.ListInvocationsOpts{
PageSize: 50,
Cursor: cursor,
FinishedOnly: true,
})
}()
wg.Wait()
switch {
case invsActiveErr != nil:
panic(errors.Annotate(invsActiveErr, "failed to fetch active invocations").Err())
case invsLogErr != nil:
panic(errors.Annotate(invsLogErr, "failed to fetch invocation log").Err())
case triErr != nil:
panic(errors.Annotate(triErr, "failed to fetch triggers").Err())
case triageLogErr != nil:
panic(errors.Annotate(triageLogErr, "failed to fetch triage log").Err())
}
// memcacheKey hashes cursor to reduce its length, since full cursor doesn't
// fit into memcache key length limits. Use 'v2' scheme for this ('v1' was
// used before hashing was added).
memcacheKey := func(cursor string) string {
blob := sha256.Sum256([]byte(job.JobID + ":" + cursor))
encoded := base64.StdEncoding.EncodeToString(blob[:])
return "v2:cursors:list_invocations:" + encoded
}
// Cheesy way of implementing bidirectional pagination with forward-only
// datastore cursors: store mapping from a page cursor to a previous page
// cursor in the memcache.
prevCursor := ""
if cursor != "" {
if itm, err := mc.GetKey(c, memcacheKey(cursor)); err == nil {
prevCursor = string(itm.Value())
}
}
if nextCursor != "" {
itm := mc.NewItem(c, memcacheKey(nextCursor))
if cursor == "" {
itm.SetValue([]byte("NULL"))
} else {
itm.SetValue([]byte(cursor))
}
itm.SetExpiration(24 * time.Hour)
mc.Set(c, itm)
}
// List of invocations in job.ActiveInvocations may contain recently finished
// invocations not yet removed from the active list by the triage procedure.
// 'invsActive' is always more accurate, since it fetches invocations from
// the datastore and checks their status. So update the job entity to be more
// accurate if we can. This is important for reporting jobs with recently
// finished invocations as not running. Otherwise the UI page may appear
// non-consistent (no running invocations in the list, yet the job's status is
// displayed as "Running"). This is a bit of a hack...
if haveInvsActive {
ids := make([]int64, len(invsActive))
for i, inv := range invsActive {
ids[i] = inv.ID
}
job.ActiveInvocations = ids
}
jobUI := makeJob(c, job, triageLog)
invsActiveUI := make([]*invocation, len(invsActive))
for i, inv := range invsActive {
invsActiveUI[i] = makeInvocation(jobUI, inv)
}
invsLogUI := make([]*invocation, len(invsLog))
for i, inv := range invsLog {
invsLogUI[i] = makeInvocation(jobUI, inv)
}
templates.MustRender(c, w, "pages/job.html", map[string]interface{}{
"Job": jobUI,
"ShowJobHeader": cursor == "",
"InvocationsActive": invsActiveUI,
"InvocationsLog": invsLogUI,
"PendingTriggers": makeTriggerList(jobUI.now, triggers),
"PrevCursor": prevCursor,
"NextCursor": nextCursor,
})
}
////////////////////////////////////////////////////////////////////////////////
// Actions.
var errCannotTriggerPausedJob = errors.New("cannot trigger paused job")
func triggerJobAction(c *router.Context) {
handleJobAction(c, func(job *engine.Job) error {
ctx := c.Context
eng := config(ctx).Engine
// Paused jobs just silently ignore triggers. Warn the user.
if job.Paused {
return errCannotTriggerPausedJob
}
// Generate random ID for the trigger, since we need one. They are usually
// used to guarantee idempotency, and thus should be provided by the
// triggering side (which in this case is end-user's browser). We could
// potentially generate the ID in Javascript and submit the trigger via URL
// fetch, and retry on transient errors until success, but this looks like
// too much hassle for little gains.
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return err
}
id := hex.EncodeToString(buf)
// This will check the ACL and submit the trigger.
return eng.EmitTriggers(ctx, map[*engine.Job][]*internal.Trigger{
job: {
{
Id: id,
Created: timestamppb.New(clock.Now(ctx)),
Title: "Triggered via web UI",
EmittedByUser: string(auth.CurrentIdentity(ctx)),
Payload: &internal.Trigger_Webui{
Webui: &api.WebUITrigger{},
},
},
},
})
})
}
func pauseJobAction(c *router.Context) {
handleJobAction(c, func(job *engine.Job) error {
return config(c.Context).Engine.PauseJob(c.Context, job)
})
}
func resumeJobAction(c *router.Context) {
handleJobAction(c, func(job *engine.Job) error {
return config(c.Context).Engine.ResumeJob(c.Context, job)
})
}
func abortJobAction(c *router.Context) {
handleJobAction(c, func(job *engine.Job) error {
return config(c.Context).Engine.AbortJob(c.Context, job)
})
}
func handleJobAction(c *router.Context, cb func(*engine.Job) error) {
projectID := c.Params.ByName("ProjectID")
jobName := c.Params.ByName("JobName")
job := jobFromEngine(c, projectID, jobName)
if job == nil {
return
}
switch err := cb(job); {
case err == errCannotTriggerPausedJob:
uiErrCannotTriggerPausedJob.render(c)
case err == engine.ErrNoPermission:
uiErrActionForbidden.render(c)
case err != nil:
panic(err)
default:
http.Redirect(c.Writer, c.Request, fmt.Sprintf("/jobs/%s/%s", projectID, jobName), http.StatusFound)
}
}