github.com/munnerz/test-infra@v0.0.0-20190108210205-ce3d181dc989/prow/cmd/deck/job_history.go (about) 1 /* 2 Copyright 2018 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package main 18 19 import ( 20 "context" 21 "encoding/json" 22 "fmt" 23 "io/ioutil" 24 "net/url" 25 "path" 26 "regexp" 27 "sort" 28 "strconv" 29 "strings" 30 "time" 31 32 "cloud.google.com/go/storage" 33 "github.com/sirupsen/logrus" 34 "google.golang.org/api/iterator" 35 "k8s.io/test-infra/prow/config" 36 "k8s.io/test-infra/prow/deck/jobs" 37 ) 38 39 const ( 40 resultsPerPage = 20 41 idParam = "buildId" 42 latestBuildFile = "latest-build.txt" 43 44 // ** Job history assumes the GCS layout specified here: 45 // https://github.com/kubernetes/test-infra/tree/master/gubernator#gcs-bucket-layout 46 logsPrefix = "logs" 47 symLinkPrefix = "pr-logs/directory" 48 spyglassPrefix = "/view/gcs" 49 emptyID = int64(-1) // indicates no build id was specified 50 ) 51 52 var ( 53 prefixRe = regexp.MustCompile("gs://.*?/") 54 linkRe = regexp.MustCompile("/([0-9]+)\\.txt$") 55 ) 56 57 type buildData struct { 58 index int 59 jobName string 60 prefix string 61 SpyglassLink string 62 ID string 63 Started time.Time 64 Duration time.Duration 65 Result string 66 commitHash string 67 } 68 69 // storageBucket is an abstraction for unit testing 70 type storageBucket interface { 71 getName() string 72 listSubDirs(prefix string) ([]string, error) 73 listAll(prefix string) ([]string, error) 74 readObject(key string) ([]byte, error) 75 } 76 77 // gcsBucket is our real implementation of storageBucket 78 type gcsBucket struct { 79 name string 80 *storage.BucketHandle 81 } 82 83 type jobHistoryTemplate struct { 84 OlderLink string 85 NewerLink string 86 LatestLink string 87 Name string 88 ResultsShown int 89 ResultsTotal int 90 Builds []buildData 91 } 92 93 func (bucket gcsBucket) readObject(key string) ([]byte, error) { 94 obj := bucket.Object(key) 95 rc, err := obj.NewReader(context.Background()) 96 if err != nil { 97 return []byte{}, fmt.Errorf("failed to get reader for GCS object: %v", err) 98 } 99 return ioutil.ReadAll(rc) 100 } 101 102 func (bucket gcsBucket) getName() string { 103 return bucket.name 104 } 105 106 func readLatestBuild(bucket storageBucket, root string) (int64, error) { 107 key := path.Join(root, latestBuildFile) 108 data, err := bucket.readObject(key) 109 if err != nil { 110 return -1, fmt.Errorf("failed to read %s: %v", key, err) 111 } 112 n, err := strconv.ParseInt(string(data), 10, 64) 113 if err != nil { 114 return -1, fmt.Errorf("failed to parse %s: %v", key, err) 115 } 116 return n, nil 117 } 118 119 // resolve sym links into the actual log directory for a particular test run 120 func (bucket gcsBucket) resolveSymLink(symLink string) (string, error) { 121 data, err := bucket.readObject(symLink) 122 if err != nil { 123 return "", fmt.Errorf("failed to read %s: %v", symLink, err) 124 } 125 // strip gs://<bucket-name> from global address `u` 126 u := string(data) 127 return prefixRe.ReplaceAllString(u, ""), nil 128 } 129 130 func (bucket gcsBucket) spyglassLink(root, id string) (string, error) { 131 p, err := bucket.getPath(root, id, "") 132 if err != nil { 133 return "", fmt.Errorf("failed to get path: %v", err) 134 } 135 return path.Join(spyglassPrefix, bucket.getName(), p), nil 136 } 137 138 func (bucket gcsBucket) getPath(root, id, fname string) (string, error) { 139 if strings.HasPrefix(root, logsPrefix) { 140 return path.Join(root, id, fname), nil 141 } 142 symLink := path.Join(root, id+".txt") 143 dir, err := bucket.resolveSymLink(symLink) 144 if err != nil { 145 return "", fmt.Errorf("failed to resolve sym link: %v", err) 146 } 147 return path.Join(dir, fname), nil 148 } 149 150 // reads specified JSON file in to `data` 151 func readJSON(bucket storageBucket, key string, data interface{}) error { 152 rawData, err := bucket.readObject(key) 153 if err != nil { 154 return fmt.Errorf("failed to read %s: %v", key, err) 155 } 156 err = json.Unmarshal(rawData, &data) 157 if err != nil { 158 return fmt.Errorf("failed to parse %s: %v", key, err) 159 } 160 return nil 161 } 162 163 // Lists the GCS "directory paths" immediately under prefix. 164 func (bucket gcsBucket) listSubDirs(prefix string) ([]string, error) { 165 if !strings.HasSuffix(prefix, "/") { 166 prefix += "/" 167 } 168 dirs := []string{} 169 it := bucket.Objects(context.Background(), &storage.Query{ 170 Prefix: prefix, 171 Delimiter: "/", 172 }) 173 for { 174 attrs, err := it.Next() 175 if err == iterator.Done { 176 break 177 } 178 if err != nil { 179 return dirs, err 180 } 181 if attrs.Prefix != "" { 182 dirs = append(dirs, attrs.Prefix) 183 } 184 } 185 return dirs, nil 186 } 187 188 // Lists all GCS keys with given prefix. 189 func (bucket gcsBucket) listAll(prefix string) ([]string, error) { 190 keys := []string{} 191 it := bucket.Objects(context.Background(), &storage.Query{ 192 Prefix: prefix, 193 }) 194 for { 195 attrs, err := it.Next() 196 if err == iterator.Done { 197 break 198 } 199 if err != nil { 200 return keys, err 201 } 202 keys = append(keys, attrs.Name) 203 } 204 return keys, nil 205 } 206 207 // Gets all build ids for a job. 208 func (bucket gcsBucket) listBuildIDs(root string) ([]int64, error) { 209 ids := []int64{} 210 if strings.HasPrefix(root, logsPrefix) { 211 dirs, err := bucket.listSubDirs(root) 212 if err != nil { 213 return ids, fmt.Errorf("failed to list GCS directories: %v", err) 214 } 215 for _, dir := range dirs { 216 i, err := strconv.ParseInt(path.Base(dir), 10, 64) 217 if err == nil { 218 ids = append(ids, i) 219 } else { 220 logrus.Warningf("unrecognized directory name (expected int64): %s", dir) 221 } 222 } 223 } else { 224 keys, err := bucket.listAll(root) 225 if err != nil { 226 return ids, fmt.Errorf("failed to list GCS keys: %v", err) 227 } 228 for _, key := range keys { 229 matches := linkRe.FindStringSubmatch(key) 230 if len(matches) == 2 { 231 i, err := strconv.ParseInt(matches[1], 10, 64) 232 if err == nil { 233 ids = append(ids, i) 234 } else { 235 logrus.Warningf("unrecognized file name (expected <int64>.txt): %s", key) 236 } 237 } 238 } 239 } 240 return ids, nil 241 } 242 243 func parseJobHistURL(url *url.URL) (bucketName, root string, buildID int64, err error) { 244 buildID = emptyID 245 p := strings.TrimPrefix(url.Path, "/job-history/") 246 s := strings.SplitN(p, "/", 2) 247 if len(s) < 2 { 248 err = fmt.Errorf("invalid path (expected /job-history/<gcs-path>): %v", url.Path) 249 return 250 } 251 bucketName = s[0] 252 root = s[1] // `root` is the root GCS "directory" prefix for this job's results 253 if bucketName == "" { 254 err = fmt.Errorf("missing GCS bucket name: %v", url.Path) 255 return 256 } 257 if root == "" { 258 err = fmt.Errorf("invalid GCS path for job: %v", url.Path) 259 return 260 } 261 262 if idVals := url.Query()[idParam]; len(idVals) >= 1 && idVals[0] != "" { 263 buildID, err = strconv.ParseInt(idVals[0], 10, 64) 264 if err != nil { 265 err = fmt.Errorf("invalid value for %s: %v", idParam, err) 266 return 267 } 268 if buildID < 0 { 269 err = fmt.Errorf("invalid value %s = %d", idParam, buildID) 270 return 271 } 272 } 273 274 return 275 } 276 277 func linkID(url *url.URL, id int64) string { 278 u := *url 279 q := u.Query() 280 var val string 281 if id != emptyID { 282 val = strconv.FormatInt(id, 10) 283 } 284 q.Set(idParam, val) 285 u.RawQuery = q.Encode() 286 return u.String() 287 } 288 289 func getBuildData(bucket storageBucket, dir string) (buildData, error) { 290 b := buildData{ 291 Result: "Unknown", 292 commitHash: "Unknown", 293 } 294 started := jobs.Started{} 295 err := readJSON(bucket, path.Join(dir, "started.json"), &started) 296 if err != nil { 297 return b, fmt.Errorf("failed to read started.json: %v", err) 298 } 299 b.Started = time.Unix(started.Timestamp, 0) 300 commitHash, err := getPullCommitHash(started.Pull) 301 if err == nil { 302 b.commitHash = commitHash 303 } 304 finished := jobs.Finished{} 305 err = readJSON(bucket, path.Join(dir, "finished.json"), &finished) 306 if err != nil { 307 logrus.Infof("failed to read finished.json (job might be unfinished): %v", err) 308 } 309 if finished.Timestamp != 0 { 310 b.Duration = time.Unix(finished.Timestamp, 0).Sub(b.Started) 311 } 312 if finished.Result != "" { 313 b.Result = finished.Result 314 } 315 return b, nil 316 } 317 318 // assumes a to be sorted in descending order 319 // returns a subslice of a along with its indices (inclusive) 320 func cropResults(a []int64, max int64) ([]int64, int, int) { 321 res := []int64{} 322 firstIndex := -1 323 lastIndex := 0 324 for i, v := range a { 325 if v <= max { 326 res = append(res, v) 327 if firstIndex == -1 { 328 firstIndex = i 329 } 330 lastIndex = i 331 if len(res) >= resultsPerPage { 332 break 333 } 334 } 335 } 336 return res, firstIndex, lastIndex 337 } 338 339 // golang <3 340 type int64slice []int64 341 342 func (a int64slice) Len() int { return len(a) } 343 func (a int64slice) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 344 func (a int64slice) Less(i, j int) bool { return a[i] < a[j] } 345 346 // Gets job history from the GCS bucket specified in config. 347 func getJobHistory(url *url.URL, config *config.Config, gcsClient *storage.Client) (jobHistoryTemplate, error) { 348 start := time.Now() 349 tmpl := jobHistoryTemplate{} 350 351 bucketName, root, top, err := parseJobHistURL(url) 352 if err != nil { 353 return tmpl, fmt.Errorf("invalid url %s: %v", url.String(), err) 354 } 355 tmpl.Name = root 356 bucket := gcsBucket{bucketName, gcsClient.Bucket(bucketName)} 357 358 latest, err := readLatestBuild(bucket, root) 359 if err != nil { 360 return tmpl, fmt.Errorf("failed to locate build data: %v", err) 361 } 362 if top == emptyID || top > latest { 363 top = latest 364 } 365 if top != latest { 366 tmpl.LatestLink = linkID(url, emptyID) 367 } 368 369 buildIDs, err := bucket.listBuildIDs(root) 370 if err != nil { 371 return tmpl, fmt.Errorf("failed to get build ids: %v", err) 372 } 373 sort.Sort(sort.Reverse(int64slice(buildIDs))) 374 375 // determine which results to display on this page 376 shownIDs, firstIndex, lastIndex := cropResults(buildIDs, top) 377 378 // get links to the neighboring pages 379 if firstIndex > 0 { 380 nextIndex := firstIndex - resultsPerPage 381 // here emptyID indicates the most recent build, which will not necessarily be buildIDs[0] 382 next := emptyID 383 if nextIndex >= 0 { 384 next = buildIDs[nextIndex] 385 } 386 tmpl.NewerLink = linkID(url, next) 387 } 388 if lastIndex < len(buildIDs)-1 { 389 tmpl.OlderLink = linkID(url, buildIDs[lastIndex+1]) 390 } 391 392 tmpl.Builds = make([]buildData, len(shownIDs)) 393 tmpl.ResultsShown = len(shownIDs) 394 tmpl.ResultsTotal = len(buildIDs) 395 396 // concurrently fetch data for all of the builds to be shown 397 bch := make(chan buildData) 398 for i, buildID := range shownIDs { 399 go func(i int, buildID int64) { 400 id := strconv.FormatInt(buildID, 10) 401 dir, err := bucket.getPath(root, id, "") 402 if err != nil { 403 logrus.Errorf("failed to get path: %v", err) 404 bch <- buildData{} 405 return 406 } 407 b, err := getBuildData(bucket, dir) 408 if err != nil { 409 logrus.Warningf("build %d information incomplete: %v", buildID, err) 410 } 411 b.index = i 412 b.ID = id 413 b.SpyglassLink, err = bucket.spyglassLink(root, id) 414 if err != nil { 415 logrus.Errorf("failed to get spyglass link: %v", err) 416 } 417 bch <- b 418 }(i, buildID) 419 } 420 for i := 0; i < len(shownIDs); i++ { 421 b := <-bch 422 tmpl.Builds[b.index] = b 423 } 424 425 elapsed := time.Now().Sub(start) 426 logrus.Infof("loaded %s in %v", url.Path, elapsed) 427 return tmpl, nil 428 }