Skip to content

Commit ae7bb45

Browse files
pedjakclaude
andcommitted
fix: make demo e2e catalog queries resilient to transient failures
The generate-demos CI job fails ~45% of the time with jq exit status 5 on the ClusterCatalog Quickstart scenario. Several issues contribute: - jq -s (slurp mode) buffers the entire operatorhubio FBC response in memory before processing, risking system errors on large catalogs - catalog content queries run exactly once with no retry, so any transient port-forward or network hiccup fails the step immediately - bash() does not attach stderr to ExitError, making failures opaque - with CatalogdHA, kubectl port-forward to the service may connect to a non-leader pod that returns 404 (empty local cache) and sticks with it for the entire retry window Remove jq slurp mode so each JSON object is processed in constant memory, prefixing filters with 'objects' to skip non-object values in the FBC stream. Wrap CatalogContainsSomePackages, PackageHasSomeChannels, and PackageHasSomeBundles in waitFor for retry on transient errors. Add curl --compressed to handle gzip-encoded responses and --fail with pipefail to detect HTTP errors. Re-establish dead port-forwards via liveness checks and reset port-forwards on query failure so retries can reach the leader pod. Inject stderr into ExitError in bash() to match k8sClient diagnostics. Log catalog query errors at V(0) so CI timeout failures are diagnosable. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7ec6a0e commit ae7bb45

1 file changed

Lines changed: 89 additions & 40 deletions

File tree

‎test/e2e/steps/demo_steps.go‎

Lines changed: 89 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"crypto/tls"
77
"encoding/json"
8+
"errors"
89
"fmt"
910
"net"
1011
"net/http"
@@ -41,6 +42,10 @@ func bash(ctx context.Context, script string) (string, error) {
4142

4243
if err != nil {
4344
logger.V(1).Info("Failed to run", "command", script, "stderr", stderr, "error", err)
45+
var exitErr *exec.ExitError
46+
if errors.As(err, &exitErr) {
47+
exitErr.Stderr = stderrBuf.Bytes()
48+
}
4449
}
4550
logger.V(1).Info("Output", "command", script, "output", stdout)
4651

@@ -81,7 +86,15 @@ func CatalogReportsConditionWithoutReason(ctx context.Context, catalogUserName,
8186
func ensureCatalogPortForward(ctx context.Context) (string, error) {
8287
sc := scenarioCtx(ctx)
8388
if sc.catalogAddr != "" {
84-
return sc.catalogAddr, nil
89+
if catalogPortForwardAlive(sc.catalogAddr) {
90+
return sc.catalogAddr, nil
91+
}
92+
logger.V(1).Info("Catalog port-forward is dead, re-establishing", "addr", sc.catalogAddr)
93+
if sc.catalogCleanup != nil {
94+
sc.catalogCleanup()
95+
}
96+
sc.catalogAddr = ""
97+
sc.catalogCleanup = nil
8598
}
8699

87100
addr, cleanup, err := portForward(ctx, componentNamespaces["catalogd"], "service/catalogd-service", 443)
@@ -92,68 +105,104 @@ func ensureCatalogPortForward(ctx context.Context) (string, error) {
92105
sc.catalogCleanup = cleanup
93106

94107
waitFor(ctx, func() bool {
95-
client := &http.Client{
96-
Timeout: 3 * time.Second,
97-
Transport: &http.Transport{
98-
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
99-
DialContext: (&net.Dialer{Timeout: 2 * time.Second}).DialContext,
100-
},
101-
}
102-
resp, err := client.Get(fmt.Sprintf("https://%s/", addr))
103-
if err != nil {
104-
return false
105-
}
106-
resp.Body.Close()
107-
return true
108+
return catalogPortForwardAlive(addr)
108109
})
109110
return addr, nil
110111
}
111112

113+
func catalogPortForwardAlive(addr string) bool {
114+
client := &http.Client{
115+
Timeout: 3 * time.Second,
116+
Transport: &http.Transport{
117+
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
118+
DialContext: (&net.Dialer{Timeout: 2 * time.Second}).DialContext,
119+
},
120+
}
121+
resp, err := client.Get(fmt.Sprintf("https://%s/", addr))
122+
if err != nil {
123+
return false
124+
}
125+
resp.Body.Close()
126+
return true
127+
}
128+
129+
// resetCatalogPortForward tears down the cached port-forward so the next
130+
// call to ensureCatalogPortForward establishes a fresh connection. With
131+
// CatalogdHA, non-leader pods return 404 (empty local cache); resetting
132+
// lets the next retry potentially reach the leader pod.
133+
func resetCatalogPortForward(ctx context.Context) {
134+
sc := scenarioCtx(ctx)
135+
if sc.catalogCleanup != nil {
136+
sc.catalogCleanup()
137+
}
138+
sc.catalogAddr = ""
139+
sc.catalogCleanup = nil
140+
}
141+
112142
func catalogCurlJq(ctx context.Context, catalogName, jqFilter string) (string, error) {
113143
addr, err := ensureCatalogPortForward(ctx)
114144
if err != nil {
115145
return "", err
116146
}
117147
script := fmt.Sprintf(
118-
`curl -s -k https://%s/catalogs/%s/api/v1/all | jq -s '%s'`,
148+
`set -o pipefail; curl -sS -k --compressed --fail https://%s/catalogs/%s/api/v1/all | jq '%s'`,
119149
addr, catalogName, jqFilter,
120150
)
121-
return bash(ctx, script)
151+
out, err := bash(ctx, script)
152+
if err != nil {
153+
resetCatalogPortForward(ctx)
154+
}
155+
return out, err
122156
}
123157

124158
func CatalogContainsSomePackages(ctx context.Context, catalogName string) error {
125-
out, err := catalogCurlJq(ctx, catalogName,
126-
`.[] | select(.schema == "olm.package") | .name`)
127-
if err != nil {
128-
return err
129-
}
130-
if strings.TrimSpace(out) == "" {
131-
return fmt.Errorf("catalog %q contains no packages", catalogName)
132-
}
159+
waitFor(ctx, func() bool {
160+
out, err := catalogCurlJq(ctx, catalogName,
161+
`objects | select(.schema == "olm.package") | .name`)
162+
if err != nil {
163+
logger.Info("Catalog query failed, retrying", "catalog", catalogName, "error", err, "stderr", stderrOutput(err))
164+
return false
165+
}
166+
if strings.TrimSpace(out) == "" {
167+
logger.Info("Catalog returned no packages, retrying", "catalog", catalogName)
168+
return false
169+
}
170+
return true
171+
})
133172
return nil
134173
}
135174

136175
func PackageHasSomeChannels(ctx context.Context, packageName, catalogName string) error {
137-
out, err := catalogCurlJq(ctx, catalogName,
138-
fmt.Sprintf(`.[] | select(.schema == "olm.channel") | select(.package == "%s") | .name`, packageName))
139-
if err != nil {
140-
return err
141-
}
142-
if strings.TrimSpace(out) == "" {
143-
return fmt.Errorf("package %q in catalog %q has no channels", packageName, catalogName)
144-
}
176+
waitFor(ctx, func() bool {
177+
out, err := catalogCurlJq(ctx, catalogName,
178+
fmt.Sprintf(`objects | select(.schema == "olm.channel") | select(.package == "%s") | .name`, packageName))
179+
if err != nil {
180+
logger.Info("Catalog query failed, retrying", "catalog", catalogName, "package", packageName, "error", err, "stderr", stderrOutput(err))
181+
return false
182+
}
183+
if strings.TrimSpace(out) == "" {
184+
logger.Info("Package has no channels, retrying", "catalog", catalogName, "package", packageName)
185+
return false
186+
}
187+
return true
188+
})
145189
return nil
146190
}
147191

148192
func PackageHasSomeBundles(ctx context.Context, packageName, catalogName string) error {
149-
out, err := catalogCurlJq(ctx, catalogName,
150-
fmt.Sprintf(`.[] | select(.schema == "olm.bundle") | select(.package == "%s") | .name`, packageName))
151-
if err != nil {
152-
return err
153-
}
154-
if strings.TrimSpace(out) == "" {
155-
return fmt.Errorf("package %q in catalog %q has no bundles", packageName, catalogName)
156-
}
193+
waitFor(ctx, func() bool {
194+
out, err := catalogCurlJq(ctx, catalogName,
195+
fmt.Sprintf(`objects | select(.schema == "olm.bundle") | select(.package == "%s") | .name`, packageName))
196+
if err != nil {
197+
logger.Info("Catalog query failed, retrying", "catalog", catalogName, "package", packageName, "error", err, "stderr", stderrOutput(err))
198+
return false
199+
}
200+
if strings.TrimSpace(out) == "" {
201+
logger.Info("Package has no bundles, retrying", "catalog", catalogName, "package", packageName)
202+
return false
203+
}
204+
return true
205+
})
157206
return nil
158207
}
159208

0 commit comments

Comments
 (0)