Skip to content

Commit d6d9832

Browse files
Merge pull request #323 from raelga/raelg/fix-ev2-retry-metadata-lookup
fix(prow-job-executor): locate ev2 retry metadata in step-level finished.json
2 parents 04a8968 + 8f6db47 commit d6d9832

2 files changed

Lines changed: 416 additions & 21 deletions

File tree

tools/prow-job-executor/prowjob/retrymarker.go

Lines changed: 217 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@ import (
3131
// ARO-HCP E2E test binary (aro-hcp-tests, see AROSLSRE-1721) always writes once a run
3232
// finishes: the full list of failed spec names, and the subset of those that were labeled
3333
// allow-retry (a known, tracked issue with a fix already committed to). aro-hcp-tests
34-
// writes both keys into $ARTIFACT_DIR/metadata.json, which Prow's sidecar merges into the
35-
// job's finished.json under the top-level "metadata" object - the standard Prow
36-
// custom-metadata mechanism, so no log scraping is involved.
34+
// writes both keys into $ARTIFACT_DIR/metadata.json, which Prow's sidecar merges into
35+
// that specific step's own finished.json under the top-level "metadata" object - the
36+
// standard Prow custom-metadata mechanism, so no log scraping is involved. For ARO-HCP's
37+
// multi-stage e2e jobs that step-level finished.json lives nested under the build's
38+
// artifacts/ tree (<build>/artifacts/<workflow>/<step>/finished.json), not at the
39+
// job-level <build>/finished.json - see jobAllowsEV2Retry below for how we locate it.
3740
//
3841
// aro-hcp-tests only reports these raw facts, even when nothing failed (both lists empty);
3942
// it is prow-job-executor's job (ev2RetryEligible below) to decide whether the shape of a
@@ -70,6 +73,14 @@ type finishedJSON struct {
7073
Metadata map[string]interface{} `json:"metadata"`
7174
}
7275

76+
// gcsObjectBaseURL and gcsJSONAPIBaseURL are the public GCS endpoints this file talks to.
77+
// They're package-level vars (rather than inlined literals) so tests can point them at a
78+
// local httptest server instead of stubbing an HTTP client.
79+
var (
80+
gcsObjectBaseURL = "https://storage.googleapis.com"
81+
gcsJSONAPIBaseURL = "https://storage.googleapis.com/storage/v1/b"
82+
)
83+
7384
// finishedJSONURLFromViewURL converts a Prow Deck "view" URL (the one
7485
// reported in ProwJob.Status.URL, e.g.
7586
// https://prow.ci.openshift.org/view/gs/origin-ci-test/logs/<job>/<build>)
@@ -94,68 +105,253 @@ func finishedJSONURLFromViewURL(viewURL string) (string, error) {
94105
if gcsPath == "" {
95106
return "", fmt.Errorf("job status URL %q has an empty GCS path after the %q prefix", viewURL, viewPrefix)
96107
}
97-
return fmt.Sprintf("https://storage.googleapis.com/%s/finished.json", gcsPath), nil
108+
return fmt.Sprintf("%s/%s/finished.json", gcsObjectBaseURL, gcsPath), nil
98109
}
99110

100111
// jobAllowsEV2Retry fetches finished.json for the job reported at viewURL and reports
101112
// whether its ev2FailedTestsKey/ev2AllowRetryTestsKey metadata qualifies for an automatic
102113
// EV2 gating retry, per ev2RetryEligible.
114+
//
115+
// ARO-HCP e2e jobs are multi-stage ci-operator tests (lease-acquire, write-config, the
116+
// actual test container, gather-*, lease-release, ...). Prow's sidecar merges each step's
117+
// own $ARTIFACT_DIR/metadata.json into THAT STEP's OWN finished.json
118+
// (<build>/artifacts/<workflow>/<step>/finished.json) - never into the job-level
119+
// finished.json (<build>/finished.json) that viewURL itself resolves to, whose "metadata"
120+
// object is ci-operator's own job bookkeeping (pod, revision, repo, ...) and never carries
121+
// per-step custom keys. Checking only the job-level finished.json therefore always found
122+
// ev2FailedTestsKey absent on every real multi-stage job and silently reported "not
123+
// eligible" with no error - verified empirically against real ARO-HCP prod/stage/PR e2e
124+
// jobs (AROSLSRE-1721 postmortem). We check the job-level finished.json first - cheap, and
125+
// sufficient on its own for any job shape where ci-operator does aggregate it there - and
126+
// only pay for listing the build's artifacts/ tree when that candidate doesn't carry
127+
// ev2FailedTestsKey at all.
103128
func jobAllowsEV2Retry(ctx context.Context, viewURL string, maxAutoRetryFailures int) (bool, error) {
104-
rawURL, err := finishedJSONURLFromViewURL(viewURL)
129+
jobFinishedURL, err := finishedJSONURLFromViewURL(viewURL)
130+
if err != nil {
131+
return false, err
132+
}
133+
134+
if eligible, done, err := ev2RetryEligibleFromFinishedJSON(ctx, jobFinishedURL, maxAutoRetryFailures); done {
135+
return eligible, err
136+
}
137+
138+
bucket, buildPath, err := gcsBucketAndBuildPath(jobFinishedURL)
105139
if err != nil {
106140
return false, err
107141
}
108-
return fetchFinishedJSONAllowsRetry(ctx, rawURL, maxAutoRetryFailures)
142+
stepURLs, err := listStepFinishedJSONURLs(ctx, bucket, buildPath)
143+
if err != nil {
144+
return false, err
145+
}
146+
for _, rawURL := range stepURLs {
147+
if eligible, done, err := ev2RetryEligibleFromFinishedJSON(ctx, rawURL, maxAutoRetryFailures); done {
148+
return eligible, err
149+
}
150+
}
151+
// No candidate finished.json carried ev2FailedTestsKey at all - the aro-hcp-tests
152+
// step either never ran or its metadata write failed. Nothing to retry.
153+
return false, nil
109154
}
110155

111-
// fetchFinishedJSONAllowsRetry downloads rawURL as a finished.json document and reports
112-
// whether its metadata qualifies for an automatic EV2 gating retry. Split out from
113-
// jobAllowsEV2Retry so the HTTP fetch/parse logic can be tested against a local httptest
114-
// server, independent of GCS URL construction.
115-
func fetchFinishedJSONAllowsRetry(ctx context.Context, rawURL string, maxAutoRetryFailures int) (bool, error) {
156+
// ev2RetryEligibleFromFinishedJSON fetches rawURL and, only if its metadata carries
157+
// ev2FailedTestsKey, decides EV2 retry eligibility for it. done is true whenever the
158+
// caller should stop trying further candidate URLs: either this one had a definitive
159+
// answer (found the key, or hit a real error), or false when rawURL had nothing to say
160+
// (404, or metadata present but missing ev2FailedTestsKey) and the caller should move on
161+
// to the next candidate.
162+
func ev2RetryEligibleFromFinishedJSON(ctx context.Context, rawURL string, maxAutoRetryFailures int) (eligible, done bool, err error) {
163+
metadata, found, err := fetchFinishedJSONMetadata(ctx, rawURL)
164+
if err != nil {
165+
return false, true, err
166+
}
167+
if !found {
168+
return false, false, nil // 404: this candidate doesn't exist for this job shape.
169+
}
170+
if _, present := metadata[ev2FailedTestsKey]; !present {
171+
return false, false, nil // not the step that ran aro-hcp-tests.
172+
}
173+
174+
failed, ok := stringSliceFromMetadata(metadata, ev2FailedTestsKey)
175+
if !ok {
176+
return false, true, fmt.Errorf("finished.json %q metadata key %q is present but not a list of strings", rawURL, ev2FailedTestsKey)
177+
}
178+
allowRetry, ok := stringSliceFromMetadata(metadata, ev2AllowRetryTestsKey)
179+
if !ok {
180+
return false, true, fmt.Errorf("finished.json %q metadata key %q is present but not a list of strings", rawURL, ev2AllowRetryTestsKey)
181+
}
182+
return ev2RetryEligible(failed, allowRetry, maxAutoRetryFailures), true, nil
183+
}
184+
185+
// gcsBucketAndBuildPath splits a storage.googleapis.com finished.json URL (as produced by
186+
// finishedJSONURLFromViewURL) back into its bucket and build-directory object path, so
187+
// listStepFinishedJSONURLs can enumerate that build's artifacts/ tree.
188+
func gcsBucketAndBuildPath(finishedURL string) (bucket, buildPath string, err error) {
189+
prefix := gcsObjectBaseURL + "/"
190+
if !strings.HasPrefix(finishedURL, prefix) {
191+
return "", "", fmt.Errorf("finished.json URL %q is not a %s URL", finishedURL, gcsObjectBaseURL)
192+
}
193+
rest := strings.TrimSuffix(strings.TrimPrefix(finishedURL, prefix), "/finished.json")
194+
bucket, buildPath, ok := strings.Cut(rest, "/")
195+
if !ok || bucket == "" || buildPath == "" {
196+
return "", "", fmt.Errorf("finished.json URL %q does not contain both a bucket and a build path", finishedURL)
197+
}
198+
return bucket, buildPath, nil
199+
}
200+
201+
// gcsListTimeout bounds each GCS directory-listing call used to discover per-step
202+
// finished.json files.
203+
const gcsListTimeout = 15 * time.Second
204+
205+
// gcsListMaxPrefixes caps how many directory entries we'll walk at each artifacts/ tree
206+
// level. ARO-HCP e2e jobs have a handful of multi-stage steps (lease-acquire,
207+
// write-config, the test container, a couple of gather-*, lease-release); anything near
208+
// this cap means the tree looks nothing like what we expect, or the listing was
209+
// paginated, so we fail closed rather than silently missing a remainder page.
210+
const gcsListMaxPrefixes = 100
211+
212+
// gcsListResponse is the subset of the GCS JSON API's objects.list response
213+
// (https://cloud.google.com/storage/docs/json_api/v1/objects/list) we need: the
214+
// "directory" entries found at the requested prefix+delimiter.
215+
type gcsListResponse struct {
216+
Prefixes []string `json:"prefixes"`
217+
NextPageToken string `json:"nextPageToken"`
218+
}
219+
220+
// listGCSPrefixes lists the immediate "subdirectories" under prefix in bucket, using
221+
// GCS's public, unauthenticated JSON API with delimiter=/ - the same trick gsutil/gcsweb
222+
// use to browse a GCS "directory" without listing every object beneath it recursively.
223+
func listGCSPrefixes(ctx context.Context, bucket, prefix string) ([]string, error) {
224+
ctx, cancel := context.WithTimeout(ctx, gcsListTimeout)
225+
defer cancel()
226+
227+
listURL := fmt.Sprintf("%s/%s/o?prefix=%s&delimiter=%s&fields=%s",
228+
gcsJSONAPIBaseURL, url.QueryEscape(bucket), url.QueryEscape(prefix), url.QueryEscape("/"), url.QueryEscape("prefixes,nextPageToken"))
229+
230+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
231+
if err != nil {
232+
return nil, fmt.Errorf("failed to create GCS list request for %q: %w", prefix, err)
233+
}
234+
resp, err := http.DefaultClient.Do(req)
235+
if err != nil {
236+
return nil, fmt.Errorf("failed to list GCS prefix %q: %w", prefix, err)
237+
}
238+
defer func() {
239+
if cerr := resp.Body.Close(); cerr != nil {
240+
logr.FromContextOrDiscard(ctx).Error(cerr, "failed to close body")
241+
}
242+
}()
243+
if resp.StatusCode != http.StatusOK {
244+
return nil, fmt.Errorf("failed to list GCS prefix %q: unexpected status %d", prefix, resp.StatusCode)
245+
}
246+
247+
body, err := io.ReadAll(io.LimitReader(resp.Body, maxFinishedJSONBytes+1))
248+
if err != nil {
249+
return nil, fmt.Errorf("failed to read GCS list response for %q: %w", prefix, err)
250+
}
251+
if len(body) > maxFinishedJSONBytes {
252+
return nil, fmt.Errorf("GCS list response for %q exceeds %d byte limit", prefix, maxFinishedJSONBytes)
253+
}
254+
255+
var listResp gcsListResponse
256+
if err := json.Unmarshal(body, &listResp); err != nil {
257+
return nil, fmt.Errorf("failed to decode GCS list response for %q: %w", prefix, err)
258+
}
259+
if listResp.NextPageToken != "" || len(listResp.Prefixes) > gcsListMaxPrefixes {
260+
return nil, fmt.Errorf("GCS prefix %q has more than %d entries or is paginated - refusing to guess which step ran the tests", prefix, gcsListMaxPrefixes)
261+
}
262+
return listResp.Prefixes, nil
263+
}
264+
265+
// listStepFinishedJSONURLs finds every per-step finished.json nested under a build's
266+
// artifacts/ directory, by walking the two directory levels ci-operator always creates
267+
// there (the workflow name, then one directory per step) - without assuming any
268+
// particular step name, since that varies by job (e.g. aro-hcp-test-persistent for
269+
// postsubmits, aro-hcp-test-local for presubmits).
270+
func listStepFinishedJSONURLs(ctx context.Context, bucket, buildPath string) ([]string, error) {
271+
workflowPrefixes, err := listGCSPrefixes(ctx, bucket, buildPath+"/artifacts/")
272+
if err != nil {
273+
return nil, err
274+
}
275+
276+
var stepURLs []string
277+
for _, workflowPrefix := range workflowPrefixes {
278+
stepPrefixes, err := listGCSPrefixes(ctx, bucket, workflowPrefix)
279+
if err != nil {
280+
return nil, err
281+
}
282+
for _, stepPrefix := range stepPrefixes {
283+
stepURLs = append(stepURLs, fmt.Sprintf("%s/%s/%sfinished.json", gcsObjectBaseURL, bucket, stepPrefix))
284+
}
285+
}
286+
return stepURLs, nil
287+
}
288+
289+
// fetchFinishedJSONMetadata downloads rawURL as a finished.json document and returns its
290+
// free-form "metadata" object. found is false (with a nil error) only when rawURL doesn't
291+
// exist (404) - callers walking multiple candidate step URLs should treat that as "this
292+
// step doesn't exist for this job shape" and move on, rather than as an error.
293+
func fetchFinishedJSONMetadata(ctx context.Context, rawURL string) (metadata map[string]interface{}, found bool, err error) {
116294
ctx, cancel := context.WithTimeout(ctx, finishedJSONFetchTimeout)
117295
defer cancel()
118296

119297
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
120298
if err != nil {
121-
return false, fmt.Errorf("failed to create finished.json request: %w", err)
299+
return nil, false, fmt.Errorf("failed to create finished.json request: %w", err)
122300
}
123301

124302
resp, err := http.DefaultClient.Do(req)
125303
if err != nil {
126-
return false, fmt.Errorf("failed to fetch finished.json %q: %w", rawURL, err)
304+
return nil, false, fmt.Errorf("failed to fetch finished.json %q: %w", rawURL, err)
127305
}
128306
defer func() {
129-
if err := resp.Body.Close(); err != nil {
130-
logr.FromContextOrDiscard(ctx).Error(err, "failed to close body")
307+
if cerr := resp.Body.Close(); cerr != nil {
308+
logr.FromContextOrDiscard(ctx).Error(cerr, "failed to close body")
131309
}
132310
}()
133311

312+
if resp.StatusCode == http.StatusNotFound {
313+
return nil, false, nil
314+
}
134315
if resp.StatusCode != http.StatusOK {
135-
return false, fmt.Errorf("failed to fetch finished.json %q: unexpected status %d", rawURL, resp.StatusCode)
316+
return nil, false, fmt.Errorf("failed to fetch finished.json %q: unexpected status %d", rawURL, resp.StatusCode)
136317
}
137318

138319
// Read one byte past the cap so we can distinguish "fits within the cap" from
139320
// "was truncated" - io.LimitReader alone would silently accept an oversized body as
140321
// long as valid JSON appears before the limit, which defeats the fail-closed intent.
141322
body, err := io.ReadAll(io.LimitReader(resp.Body, maxFinishedJSONBytes+1))
142323
if err != nil {
143-
return false, fmt.Errorf("failed to read finished.json %q: %w", rawURL, err)
324+
return nil, false, fmt.Errorf("failed to read finished.json %q: %w", rawURL, err)
144325
}
145326
if len(body) > maxFinishedJSONBytes {
146-
return false, fmt.Errorf("finished.json %q exceeds %d byte limit", rawURL, maxFinishedJSONBytes)
327+
return nil, false, fmt.Errorf("finished.json %q exceeds %d byte limit", rawURL, maxFinishedJSONBytes)
147328
}
148329

149330
var finished finishedJSON
150331
if err := json.Unmarshal(body, &finished); err != nil {
151-
return false, fmt.Errorf("failed to decode finished.json %q: %w", rawURL, err)
332+
return nil, false, fmt.Errorf("failed to decode finished.json %q: %w", rawURL, err)
333+
}
334+
return finished.Metadata, true, nil
335+
}
336+
337+
// fetchFinishedJSONAllowsRetry downloads rawURL as a finished.json document and reports
338+
// whether its metadata qualifies for an automatic EV2 gating retry. This is the
339+
// single-known-URL building block underneath fetchFinishedJSONMetadata; jobAllowsEV2Retry
340+
// itself walks multiple candidate URLs (see above) rather than trusting a single one.
341+
func fetchFinishedJSONAllowsRetry(ctx context.Context, rawURL string, maxAutoRetryFailures int) (bool, error) {
342+
metadata, found, err := fetchFinishedJSONMetadata(ctx, rawURL)
343+
if err != nil {
344+
return false, err
345+
}
346+
if !found {
347+
return false, fmt.Errorf("failed to fetch finished.json %q: unexpected status %d", rawURL, http.StatusNotFound)
152348
}
153349

154-
failed, ok := stringSliceFromMetadata(finished.Metadata, ev2FailedTestsKey)
350+
failed, ok := stringSliceFromMetadata(metadata, ev2FailedTestsKey)
155351
if !ok {
156352
return false, fmt.Errorf("finished.json %q metadata key %q is present but not a list of strings", rawURL, ev2FailedTestsKey)
157353
}
158-
allowRetry, ok := stringSliceFromMetadata(finished.Metadata, ev2AllowRetryTestsKey)
354+
allowRetry, ok := stringSliceFromMetadata(metadata, ev2AllowRetryTestsKey)
159355
if !ok {
160356
return false, fmt.Errorf("finished.json %q metadata key %q is present but not a list of strings", rawURL, ev2AllowRetryTestsKey)
161357
}

0 commit comments

Comments
 (0)