sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/spyglass/spyglass.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 spyglass creates views for Prow job artifacts. 18 package spyglass 19 20 import ( 21 "context" 22 "encoding/json" 23 "fmt" 24 "io" 25 "net/url" 26 "path" 27 "sort" 28 "strconv" 29 "strings" 30 31 "github.com/sirupsen/logrus" 32 33 "github.com/GoogleCloudPlatform/testgrid/metadata" 34 35 prowapi "sigs.k8s.io/prow/pkg/apis/prowjobs/v1" 36 "sigs.k8s.io/prow/pkg/config" 37 "sigs.k8s.io/prow/pkg/deck/jobs" 38 pkgio "sigs.k8s.io/prow/pkg/io" 39 "sigs.k8s.io/prow/pkg/io/providers" 40 "sigs.k8s.io/prow/pkg/pod-utils/gcs" 41 "sigs.k8s.io/prow/pkg/spyglass/api" 42 "sigs.k8s.io/prow/pkg/spyglass/lenses" 43 ) 44 45 // Key types specify the way Spyglass will fetch artifact handles 46 const ( 47 gcsKeyType = api.GCSKeyType 48 prowKeyType = api.ProwKeyType 49 ) 50 51 // Spyglass records which sets of artifacts need views for a Prow job. The metaphor 52 // can be understood as follows: A spyglass receives light from a source through 53 // an eyepiece, which has a lens that ultimately presents a view of the light source 54 // to the observer. Spyglass receives light (artifacts) via a 55 // source (src) through the eyepiece (Eyepiece) and presents the view (what you see 56 // in your browser) via a lens (Lens). 57 type Spyglass struct { 58 // JobAgent contains information about the current jobs in deck 59 JobAgent *jobs.JobAgent 60 61 config config.Getter 62 testgrid *TestGrid 63 64 *StorageArtifactFetcher 65 *PodLogArtifactFetcher 66 } 67 68 // LensRequest holds data sent by a view 69 type LensRequest struct { 70 Source string `json:"src"` 71 Index int `json:"index"` 72 Artifacts []string `json:"artifacts"` 73 } 74 75 // ExtraLink represents an extra link to be added to the Spyglass page. 76 type ExtraLink struct { 77 Name string 78 Description string 79 URL string 80 } 81 82 // New constructs a Spyglass object from a JobAgent, a config.Agent, and a storage Client. 83 func New(ctx context.Context, ja *jobs.JobAgent, cfg config.Getter, opener pkgio.Opener, useCookieAuth bool) *Spyglass { 84 return &Spyglass{ 85 JobAgent: ja, 86 config: cfg, 87 PodLogArtifactFetcher: NewPodLogArtifactFetcher(ja), 88 StorageArtifactFetcher: NewStorageArtifactFetcher(opener, cfg, useCookieAuth), 89 testgrid: &TestGrid{ 90 conf: cfg, 91 opener: opener, 92 ctx: ctx, 93 }, 94 } 95 } 96 97 func (sg *Spyglass) Start() { 98 sg.testgrid.Start() 99 } 100 101 type LensConfig interface { 102 Config() lenses.LensConfig 103 } 104 105 // Lenses gets all views of all artifact files matching each regexp with a registered lens 106 func (sg *Spyglass) Lenses(lensConfigIndexes []int) (orderedIndexes []int, lensMap map[int]LensConfig) { 107 type ld struct { 108 lens LensConfig 109 index int 110 } 111 var ls []ld 112 for _, lensIndex := range lensConfigIndexes { 113 lfc := sg.config().Deck.Spyglass.Lenses[lensIndex] 114 lens, err := getLensConfig(lfc) 115 if err != nil { 116 logrus.WithField("lensName", lfc.Lens.Name).WithError(err).Error("Could not find artifact lens") 117 } else { 118 ls = append(ls, ld{lens, lensIndex}) 119 } 120 } 121 // Make sure lenses are rendered in order by ascending priority 122 sort.Slice(ls, func(i, j int) bool { 123 iconf := ls[i].lens.Config() 124 jconf := ls[j].lens.Config() 125 iname := iconf.Name 126 jname := jconf.Name 127 pi := iconf.Priority 128 pj := jconf.Priority 129 if pi == pj { 130 return iname < jname 131 } 132 return pi < pj 133 }) 134 135 lensMap = map[int]LensConfig{} 136 for _, l := range ls { 137 orderedIndexes = append(orderedIndexes, l.index) 138 lensMap[l.index] = l.lens 139 } 140 141 return orderedIndexes, lensMap 142 } 143 144 type lensConfigWrapper struct { 145 lensConfig lenses.LensConfig 146 } 147 148 func (l lensConfigWrapper) Config() lenses.LensConfig { 149 return l.lensConfig 150 } 151 152 func getLensConfig(lensFileConfig config.LensFileConfig) (LensConfig, error) { 153 lens, err := lenses.GetLens(lensFileConfig.Lens.Name) 154 if err != nil && err != lenses.ErrInvalidLensName { 155 return nil, err 156 } 157 if err == nil { 158 return lens, nil 159 } 160 // we couldn't find a local lens (err==lenses.ErrInvalidLensName) so let's search for a remote lens 161 if lensFileConfig.RemoteConfig != nil { 162 lc := lenses.LensConfig{ 163 Name: lensFileConfig.Lens.Name, 164 Title: lensFileConfig.RemoteConfig.Title, 165 } 166 if lensFileConfig.RemoteConfig.Priority != nil { 167 lc.Priority = *lensFileConfig.RemoteConfig.Priority 168 } 169 if lensFileConfig.RemoteConfig.HideTitle != nil { 170 lc.HideTitle = *lensFileConfig.RemoteConfig.HideTitle 171 } 172 return lensConfigWrapper{lc}, nil 173 } 174 return nil, fmt.Errorf("could not find lens") 175 } 176 177 func (sg *Spyglass) ResolveSymlink(src string) (string, error) { 178 src = strings.TrimSuffix(src, "/") 179 keyType, key, err := splitSrc(src) 180 if err != nil { 181 return "", fmt.Errorf("error parsing src: %w", err) 182 } 183 switch keyType { 184 case prowKeyType: 185 return src, nil // prowjob keys cannot be symlinks. 186 default: 187 if keyType == api.GCSKeyType { 188 keyType = providers.GS 189 } 190 potentialAlias := strings.Split(key, "/")[0] 191 if bucket, exists := sg.cfg().Deck.Spyglass.BucketAliases[potentialAlias]; exists { 192 key = strings.Replace(key, potentialAlias, bucket, 1) 193 } 194 reader, err := sg.opener.Reader(context.TODO(), fmt.Sprintf("%s://%s.txt", keyType, key)) 195 if err != nil { 196 if pkgio.IsNotExist(err) { 197 return fmt.Sprintf("%s/%s", keyType, key), nil 198 } 199 return "", err 200 } 201 // Avoid using ReadAll here to prevent an attacker forcing us to read a giant file into memory. 202 bytes := make([]byte, 4096) // assume we won't get more than 4 kB of symlink to read 203 n, err := reader.Read(bytes) 204 if err != nil && err != io.EOF { 205 return "", fmt.Errorf("failed to read symlink file (which does seem to exist): %w", err) 206 } 207 if n == len(bytes) { 208 return "", fmt.Errorf("symlink destination exceeds length limit of %d bytes", len(bytes)-1) 209 } 210 u, err := url.Parse(string(bytes[:n])) 211 if err != nil { 212 return "", fmt.Errorf("failed to parse URL: %w", err) 213 } 214 return path.Join(keyType, u.Host, u.Path), nil 215 } 216 } 217 218 // JobPath returns a link to the directory for the job specified in src 219 func (sg *Spyglass) JobPath(src string) (string, error) { 220 src = strings.TrimSuffix(src, "/") 221 keyType, key, err := splitSrc(src) 222 if err != nil { 223 return "", fmt.Errorf("error parsing src: %w", err) 224 } 225 split := strings.Split(key, "/") 226 switch keyType { 227 case prowKeyType: 228 if len(split) < 2 { 229 return "", fmt.Errorf("invalid key %s: expected <job-name>/<build-id>", key) 230 } 231 jobName := split[0] 232 buildID := split[1] 233 job, err := sg.jobAgent.GetProwJob(jobName, buildID) 234 if err != nil { 235 return "", fmt.Errorf("failed to get prow job from src %q: %w", key, err) 236 } 237 if job.Spec.DecorationConfig == nil { 238 return "", fmt.Errorf("failed to locate GCS upload bucket for %s: job is undecorated", jobName) 239 } 240 if job.Spec.DecorationConfig.GCSConfiguration == nil { 241 return "", fmt.Errorf("failed to locate GCS upload bucket for %s: missing GCS configuration", jobName) 242 } 243 bktName := job.Spec.DecorationConfig.GCSConfiguration.Bucket 244 if strings.Contains(bktName, "://") { 245 // handle :// in bucket name if necessary 246 bktName = strings.Replace(bktName, "://", "/", 1) 247 } else { 248 // fallback to gs/ if bucket name is given without storage type 249 bktName = fmt.Sprintf("%s/%s", providers.GS, bktName) 250 } 251 if job.Spec.Type == prowapi.PresubmitJob { 252 return path.Join(bktName, gcs.PRLogs, "directory", jobName), nil 253 } 254 return path.Join(bktName, gcs.NonPRLogs, jobName), nil 255 default: 256 if keyType == gcsKeyType { 257 keyType = providers.GS 258 } 259 if len(split) < 4 { 260 return "", fmt.Errorf("invalid key %s: expected <bucket-name>/<log-type>/.../<job-name>/<build-id>", key) 261 } 262 // see https://github.com/kubernetes/test-infra/tree/master/gubernator 263 bktName := split[0] 264 logType := split[1] 265 jobName := split[len(split)-2] 266 if logType == gcs.NonPRLogs { 267 return path.Join(keyType, path.Dir(key)), nil 268 } else if logType == gcs.PRLogs { 269 return path.Join(keyType, bktName, gcs.PRLogs, "directory", jobName), nil 270 } 271 return "", fmt.Errorf("unrecognized GCS key: %s", key) 272 } 273 } 274 275 // ProwJob returns a link and state to the YAML for the job specified in src. 276 // If no job is found, it returns empty strings and nil error. 277 func (sg *Spyglass) ProwJob(src string) (string, string, prowapi.ProwJobState, error) { 278 src = strings.TrimSuffix(src, "/") 279 keyType, key, err := splitSrc(src) 280 if err != nil { 281 return "", "", "", fmt.Errorf("error parsing src: %v", src) 282 } 283 split := strings.Split(key, "/") 284 var jobName string 285 var buildID string 286 switch keyType { 287 case prowKeyType: 288 if len(split) < 2 { 289 return "", "", "", fmt.Errorf("invalid key %s: expected <job-name>/<build-id>", key) 290 } 291 jobName = split[0] 292 buildID = split[1] 293 default: 294 if len(split) < 4 { 295 return "", "", "", fmt.Errorf("invalid key %s: expected <bucket-name>/<log-type>/.../<job-name>/<build-id>", key) 296 } 297 jobName = split[len(split)-2] 298 buildID = split[len(split)-1] 299 } 300 job, err := sg.jobAgent.GetProwJob(jobName, buildID) 301 if err != nil { 302 if jobs.IsErrProwJobNotFound(err) { 303 return "", "", "", nil 304 } 305 return "", "", "", err 306 } 307 return job.Spec.Job, job.Name, job.Status.State, nil 308 } 309 310 // RunPath returns the path to the directory for the job run specified in src. 311 func (sg *Spyglass) RunPath(src string) (string, error) { 312 src = strings.TrimSuffix(src, "/") 313 keyType, key, err := splitSrc(src) 314 if err != nil { 315 return "", fmt.Errorf("error parsing src: %v", src) 316 } 317 switch keyType { 318 case prowKeyType: 319 _, gcsKey, err := sg.prowToGCS(key) 320 if err != nil { 321 return "", err 322 } 323 return gcsKey, nil 324 default: 325 return key, nil 326 } 327 } 328 329 // RunToPR returns the (org, repo, pr#) tuple referenced by the provided src. 330 // Returns an error if src does not reference a job with an associated PR. 331 func (sg *Spyglass) RunToPR(src string) (string, string, int, error) { 332 src = strings.TrimSuffix(src, "/") 333 keyType, key, err := splitSrc(src) 334 if err != nil { 335 return "", "", 0, fmt.Errorf("error parsing src: %w", err) 336 } 337 split := strings.Split(key, "/") 338 if len(split) < 2 { 339 return "", "", 0, fmt.Errorf("expected more URL components in %q", src) 340 } 341 switch keyType { 342 case prowKeyType: 343 if len(split) < 2 { 344 return "", "", 0, fmt.Errorf("invalid key %s: expected <job-name>/<build-id>", key) 345 } 346 jobName := split[0] 347 buildID := split[1] 348 job, err := sg.jobAgent.GetProwJob(jobName, buildID) 349 if err != nil { 350 return "", "", 0, fmt.Errorf("failed to get prow job from src %q: %w", key, err) 351 } 352 if job.Spec.Refs == nil || len(job.Spec.Refs.Pulls) == 0 { 353 return "", "", 0, fmt.Errorf("no PRs on job %q", job.Name) 354 } 355 return job.Spec.Refs.Org, job.Spec.Refs.Repo, job.Spec.Refs.Pulls[0].Number, nil 356 default: 357 // In theory, we could derive this information without trying to parse the URL by instead fetching the 358 // data from uploaded artifacts. In practice, that would not be a great solution: it would require us 359 // to try pulling two different metadata files (one for bootstrap and one for podutils), then parse them 360 // in unintended ways to infer the original PR. Aside from this being some work to do, it's also slow: we would 361 // like to be able to always answer this request without needing to call out to GCS. 362 logType := split[1] 363 if logType == gcs.NonPRLogs { 364 return "", "", 0, fmt.Errorf("not a PR URL: %q", key) 365 } else if logType == gcs.PRLogs { 366 if len(split) < 3 { 367 return "", "", 0, fmt.Errorf("malformed %s key %q should have at least three components", gcs.PRLogs, key) 368 } 369 prNumStr := split[len(split)-3] 370 prNum, err := strconv.Atoi(prNumStr) 371 if err != nil { 372 return "", "", 0, fmt.Errorf("couldn't parse PR number %q in %q: %w", prNumStr, key, err) 373 } 374 // We don't actually attempt to look up the job's own configuration. 375 // In practice, this shouldn't matter: we only want to read DefaultOrg and DefaultRepo, and overriding those 376 // per job would probably be a bad idea (indeed, not even the tests try to do this). 377 // This decision should probably be revisited if we ever want other information from it. 378 // TODO (droslean): we should get the default decoration config depending on the org/repo. 379 ddc := sg.config().Plank.GuessDefaultDecorationConfig("", "") 380 if ddc == nil || ddc.GCSConfiguration == nil { 381 return "", "", 0, fmt.Errorf("couldn't look up a GCS configuration") 382 } 383 c := ddc.GCSConfiguration 384 // Assumption: we can derive the type of URL from how many components it has, without worrying much about 385 // what the actual path configuration is. 386 switch len(split) { 387 case 7: 388 // In this case we suffer an ambiguity when using 'path_strategy: legacy', and the repo 389 // is in the default repo, and the repo name contains an underscore. 390 // Currently this affects no actual repo. Hopefully we will soon deprecate 'legacy' and 391 // ensure it never does. 392 parts := strings.SplitN(split[3], "_", 2) 393 if len(parts) == 1 { 394 return c.DefaultOrg, parts[0], prNum, nil 395 } 396 return parts[0], parts[1], prNum, nil 397 case 6: 398 return c.DefaultOrg, c.DefaultRepo, prNum, nil 399 default: 400 return "", "", 0, fmt.Errorf("didn't understand the GCS URL %q", key) 401 } 402 } else { 403 return "", "", 0, fmt.Errorf("unknown log type: %q", logType) 404 } 405 } 406 } 407 408 // ExtraLinks fetches started.json and extracts links from metadata.links. 409 func (sg *Spyglass) ExtraLinks(ctx context.Context, src string) ([]ExtraLink, error) { 410 artifacts, err := sg.FetchArtifacts(ctx, src, "", 1000000, []string{prowapi.StartedStatusFile}) 411 // Failing to parse src, that's an error. 412 if err != nil { 413 return nil, err 414 } 415 416 // Failing to find started.json is okay, just return nothing quietly. 417 if len(artifacts) == 0 { 418 logrus.Debug("Failed to find started.json while looking for extra links.") 419 return nil, nil 420 } 421 // Failing to read an artifact we already know to exist shouldn't happen, so that's an error. 422 content, err := artifacts[0].ReadAll() 423 if err != nil { 424 // Swallow the error if this file is empty 425 if size, sizeErr := artifacts[0].Size(); sizeErr != nil && size == 0 { 426 logrus.Debug("Started.json is empty.") 427 err = nil 428 } 429 return nil, err 430 } 431 // Being unable to parse a successfully fetched started.json correctly is also an error. 432 started := metadata.Started{} 433 if err := json.Unmarshal(content, &started); err != nil { 434 return nil, err 435 } 436 // Not having any links is fine. 437 links, ok := started.Metadata.Meta("links") 438 if !ok { 439 return nil, nil 440 } 441 extraLinks := make([]ExtraLink, 0, len(*links)) 442 for _, name := range links.Keys() { 443 m, ok := links.Meta(name) 444 if !ok { 445 // This should never happen, because Keys() should only return valid Metas. 446 logrus.Debugf("Got bad link key %q from %s, but that should be impossible.", name, artifacts[0].CanonicalLink()) 447 continue 448 } 449 s := m.Strings() 450 link := ExtraLink{ 451 Name: name, 452 URL: s["url"], 453 Description: s["description"], 454 } 455 if link.URL == "" || link.Name == "" { 456 continue 457 } 458 extraLinks = append(extraLinks, link) 459 } 460 return extraLinks, nil 461 } 462 463 // TestGridLink returns a link to a relevant TestGrid tab for the given source string. 464 // Because there is a one-to-many mapping from job names to TestGrid tabs, the returned tab 465 // link may not be deterministic. 466 func (sg *Spyglass) TestGridLink(src string) (string, error) { 467 if !sg.testgrid.Ready() || sg.config().Deck.Spyglass.TestGridRoot == "" { 468 return "", fmt.Errorf("testgrid is not configured") 469 } 470 471 src = strings.TrimSuffix(src, "/") 472 split := strings.Split(src, "/") 473 if len(split) < 2 { 474 return "", fmt.Errorf("couldn't parse src %q", src) 475 } 476 jobName := split[len(split)-2] 477 q, err := sg.testgrid.FindQuery(jobName) 478 if err != nil { 479 return "", fmt.Errorf("failed to find query: %w", err) 480 } 481 return sg.config().Deck.Spyglass.TestGridRoot + q, nil 482 }