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 }