Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 112 additions & 42 deletions test/e2e/steps/demo_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
Expand Down Expand Up @@ -41,6 +42,10 @@ func bash(ctx context.Context, script string) (string, error) {

if err != nil {
logger.V(1).Info("Failed to run", "command", script, "stderr", stderr, "error", err)
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitErr.Stderr = stderrBuf.Bytes()
}
}
logger.V(1).Info("Output", "command", script, "output", stdout)

Expand Down Expand Up @@ -81,79 +86,144 @@ func CatalogReportsConditionWithoutReason(ctx context.Context, catalogUserName,
func ensureCatalogPortForward(ctx context.Context) (string, error) {
sc := scenarioCtx(ctx)
if sc.catalogAddr != "" {
return sc.catalogAddr, nil
if catalogPortForwardAlive(sc.catalogAddr) {
return sc.catalogAddr, nil
}
logger.V(1).Info("Catalog port-forward is dead, re-establishing", "addr", sc.catalogAddr)
resetCatalogPortForward(ctx)
}

ns := componentNamespaces["catalogd"]
target, err := catalogdLeaderPod(ctx, ns)
if err != nil {
logger.V(1).Info("Could not resolve catalogd leader pod, falling back to service", "error", err)
target = "service/catalogd-service"
}

addr, cleanup, err := portForward(ctx, componentNamespaces["catalogd"], "service/catalogd-service", 443)
addr, cleanup, err := portForward(ctx, ns, target, 443)
if err != nil {
return "", fmt.Errorf("failed to start catalog port-forward: %w", err)
return "", fmt.Errorf("failed to start catalog port-forward to %s: %w", target, err)
}
sc.catalogAddr = addr
sc.catalogCleanup = cleanup

waitFor(ctx, func() bool {
client := &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
DialContext: (&net.Dialer{Timeout: 2 * time.Second}).DialContext,
},
}
resp, err := client.Get(fmt.Sprintf("https://%s/", addr))
if err != nil {
return false
}
resp.Body.Close()
return true
return catalogPortForwardAlive(addr)
})
return addr, nil
}

func catalogdLeaderPod(ctx context.Context, ns string) (string, error) {
holder, err := k8sClient(ctx, "get", "lease", "catalogd-operator-lock", "-n", ns,
"-o", "jsonpath={.spec.holderIdentity}")
if err != nil {
return "", fmt.Errorf("failed to get catalogd leader lease: %w", err)
}
holder = strings.TrimSpace(holder)
podName := holder
if idx := strings.LastIndex(holder, "_"); idx >= 0 {
podName = holder[:idx]
}
if podName == "" {
return "", fmt.Errorf("catalogd leader lease has empty holderIdentity")
}
logger.Info("Resolved catalogd leader pod", "holder", holder, "pod", podName)
return fmt.Sprintf("pod/%s", podName), nil
}

func catalogPortForwardAlive(addr string) bool {
client := &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
DialContext: (&net.Dialer{Timeout: 2 * time.Second}).DialContext,
},
}
resp, err := client.Get(fmt.Sprintf("https://%s/", addr))
if err != nil {
return false
}
resp.Body.Close()
return true
}

// resetCatalogPortForward tears down the cached port-forward so the next
// call to ensureCatalogPortForward establishes a fresh connection. With
// CatalogdHA, non-leader pods return 404 (empty local cache); resetting
// lets the next retry potentially reach the leader pod.
func resetCatalogPortForward(ctx context.Context) {
sc := scenarioCtx(ctx)
if sc.catalogCleanup != nil {
sc.catalogCleanup()
}
sc.catalogAddr = ""
sc.catalogCleanup = nil
}

func catalogCurlJq(ctx context.Context, catalogName, jqFilter string) (string, error) {
addr, err := ensureCatalogPortForward(ctx)
if err != nil {
return "", err
}
script := fmt.Sprintf(
`curl -s -k https://%s/catalogs/%s/api/v1/all | jq -s '%s'`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need jq -s because the catalog format is in JSONLines (jsonl) format, not monolithic JSON, and slurp-mode forces it to re-represent it inside a monolithic object. Without this, jq will error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jq handles JSON Lines (JSONL/NDJSON) natively — each JSON object on a separate line is read as a separate input and the filter is applied to each one independently. No -s needed:

$ echo '{"schema":"olm.package","name":"foo"}
{"schema":"olm.channel","name":"bar"}' | jq 'select(.schema == "olm.package") | .name'
"foo"

The filters were adjusted to remove the .[] | prefix that was only needed in slurp mode (where jq wraps all inputs into a single array). Without -s, jq iterates over inputs automatically, so select(...) applies directly to each object.

The benefit of dropping -s is constant-memory processing — jq processes each JSON object as it arrives from the pipe instead of buffering the entire catalog (~100+ MB for operatorhubio) into memory before processing. The original exit status 5 (jq system error) was likely caused by this memory pressure. The full test suite (all 4 scenarios, 30 steps) passes locally with this change.

`set -o pipefail; curl -sS -k --compressed --fail https://%s/catalogs/%s/api/v1/all | jq '%s'`,
addr, catalogName, jqFilter,
)
Comment on lines 168 to 171

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — updated to curl -sS so transport/TLS errors are surfaced on stderr while keeping normal output quiet. This pairs well with the new stderr-to-ExitError propagation in bash().

return bash(ctx, script)
out, err := bash(ctx, script)
if err != nil {
resetCatalogPortForward(ctx)
}
return out, err
}

func CatalogContainsSomePackages(ctx context.Context, catalogName string) error {
out, err := catalogCurlJq(ctx, catalogName,
`.[] | select(.schema == "olm.package") | .name`)
if err != nil {
return err
}
if strings.TrimSpace(out) == "" {
return fmt.Errorf("catalog %q contains no packages", catalogName)
}
waitFor(ctx, func() bool {
out, err := catalogCurlJq(ctx, catalogName,
`objects | select(.schema == "olm.package") | .name`)
if err != nil {
logger.Info("Catalog query failed, retrying", "catalog", catalogName, "error", err, "stderr", stderrOutput(err))
return false
}
if strings.TrimSpace(out) == "" {
logger.Info("Catalog returned no packages, retrying", "catalog", catalogName)
return false
}
return true
})
Comment on lines +180 to +192

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changed functions will no longer return an error. Should the context be checked for a timeout so that an error can be returned?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The waitFor function uses require.Eventually(godog.T(ctx), ..., timeout, tick) which calls t.FailNow() on timeout — the function never returns on failure, it hard-fails the test via runtime.Goexit(). This matches the established pattern used by PodHasContainerCount in the same file (line 209) and dozens of other step functions throughout the test suite. So from a test perspective, the behavior is: either the condition is met within 5 minutes, or the scenario fails immediately.

return nil
}

func PackageHasSomeChannels(ctx context.Context, packageName, catalogName string) error {
out, err := catalogCurlJq(ctx, catalogName,
fmt.Sprintf(`.[] | select(.schema == "olm.channel") | select(.package == "%s") | .name`, packageName))
if err != nil {
return err
}
if strings.TrimSpace(out) == "" {
return fmt.Errorf("package %q in catalog %q has no channels", packageName, catalogName)
}
waitFor(ctx, func() bool {
out, err := catalogCurlJq(ctx, catalogName,
fmt.Sprintf(`objects | select(.schema == "olm.channel") | select(.package == "%s") | .name`, packageName))
if err != nil {
logger.Info("Catalog query failed, retrying", "catalog", catalogName, "package", packageName, "error", err, "stderr", stderrOutput(err))
return false
}
if strings.TrimSpace(out) == "" {
logger.Info("Package has no channels, retrying", "catalog", catalogName, "package", packageName)
return false
}
return true
})
return nil
}

func PackageHasSomeBundles(ctx context.Context, packageName, catalogName string) error {
out, err := catalogCurlJq(ctx, catalogName,
fmt.Sprintf(`.[] | select(.schema == "olm.bundle") | select(.package == "%s") | .name`, packageName))
if err != nil {
return err
}
if strings.TrimSpace(out) == "" {
return fmt.Errorf("package %q in catalog %q has no bundles", packageName, catalogName)
}
waitFor(ctx, func() bool {
out, err := catalogCurlJq(ctx, catalogName,
fmt.Sprintf(`objects | select(.schema == "olm.bundle") | select(.package == "%s") | .name`, packageName))
if err != nil {
logger.Info("Catalog query failed, retrying", "catalog", catalogName, "package", packageName, "error", err, "stderr", stderrOutput(err))
return false
}
if strings.TrimSpace(out) == "" {
logger.Info("Package has no bundles, retrying", "catalog", catalogName, "package", packageName)
return false
}
return true
})
return nil
}

Expand Down