@@ -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.
103128func 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