diff --git a/openshift/catalogd/manifests-experimental.yaml b/openshift/catalogd/manifests-experimental.yaml index 2d18bfd3c4..ddf8a93d0f 100644 --- a/openshift/catalogd/manifests-experimental.yaml +++ b/openshift/catalogd/manifests-experimental.yaml @@ -1005,7 +1005,7 @@ spec: type: Image image: pollIntervalMinutes: 10 - ref: registry.redhat.io/redhat/certified-operator-index:v4.22 + ref: registry.redhat.io/redhat/certified-operator-index:v5.0 --- # Source: olmv1/templates/openshift-catalogs/clustercatalog-openshift-community-operators.yml apiVersion: olm.operatorframework.io/v1 @@ -1018,7 +1018,7 @@ spec: type: Image image: pollIntervalMinutes: 10 - ref: registry.redhat.io/redhat/community-operator-index:v4.22 + ref: registry.redhat.io/redhat/community-operator-index:v5.0 --- # Source: olmv1/templates/openshift-catalogs/clustercatalog-openshift-redhat-operators.yml apiVersion: olm.operatorframework.io/v1 @@ -1031,7 +1031,7 @@ spec: type: Image image: pollIntervalMinutes: 10 - ref: registry.redhat.io/redhat/redhat-operator-index:v4.22 + ref: registry.redhat.io/redhat/redhat-operator-index:v5.0 --- # Source: olmv1/templates/mutatingwebhookconfiguration-catalogd-mutating-webhook-configuration.yml apiVersion: admissionregistration.k8s.io/v1 diff --git a/openshift/catalogd/manifests.yaml b/openshift/catalogd/manifests.yaml index 7521eea6d8..3165aaaf1e 100644 --- a/openshift/catalogd/manifests.yaml +++ b/openshift/catalogd/manifests.yaml @@ -1004,7 +1004,7 @@ spec: type: Image image: pollIntervalMinutes: 10 - ref: registry.redhat.io/redhat/certified-operator-index:v4.22 + ref: registry.redhat.io/redhat/certified-operator-index:v5.0 --- # Source: olmv1/templates/openshift-catalogs/clustercatalog-openshift-community-operators.yml apiVersion: olm.operatorframework.io/v1 @@ -1017,7 +1017,7 @@ spec: type: Image image: pollIntervalMinutes: 10 - ref: registry.redhat.io/redhat/community-operator-index:v4.22 + ref: registry.redhat.io/redhat/community-operator-index:v5.0 --- # Source: olmv1/templates/openshift-catalogs/clustercatalog-openshift-redhat-operators.yml apiVersion: olm.operatorframework.io/v1 @@ -1030,7 +1030,7 @@ spec: type: Image image: pollIntervalMinutes: 10 - ref: registry.redhat.io/redhat/redhat-operator-index:v4.22 + ref: registry.redhat.io/redhat/redhat-operator-index:v5.0 --- # Source: olmv1/templates/mutatingwebhookconfiguration-catalogd-mutating-webhook-configuration.yml apiVersion: admissionregistration.k8s.io/v1 diff --git a/openshift/default-catalog-consistency/test/validate/suite_test.go b/openshift/default-catalog-consistency/test/validate/suite_test.go index 48a05f7c1e..fbfbcd1d20 100644 --- a/openshift/default-catalog-consistency/test/validate/suite_test.go +++ b/openshift/default-catalog-consistency/test/validate/suite_test.go @@ -31,7 +31,7 @@ var _ = Describe("OLM-Catalog-Validation", func() { sysCtx := &types.SystemContext{} if authPath != "" { - fmt.Println("Using registry auth file:", authPath) + fmt.Fprintf(os.Stderr, "Using registry auth file\n") sysCtx.AuthFilePath = authPath } diff --git a/openshift/helm/catalogd.yaml b/openshift/helm/catalogd.yaml index 0c2954300e..c25b729d38 100644 --- a/openshift/helm/catalogd.yaml +++ b/openshift/helm/catalogd.yaml @@ -16,7 +16,7 @@ options: openshift: enabled: true catalogs: - version: v4.22 + version: v5.0 # The set of namespaces namespaces: diff --git a/openshift/tests-extension/pkg/helpers/catalog_discovery.go b/openshift/tests-extension/pkg/helpers/catalog_discovery.go new file mode 100644 index 0000000000..53b608740a --- /dev/null +++ b/openshift/tests-extension/pkg/helpers/catalog_discovery.go @@ -0,0 +1,332 @@ +package helpers + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "sort" + "strings" + "time" + + //nolint:staticcheck // ST1001: dot-imports for readability + . "github.com/onsi/ginkgo/v2" + //nolint:staticcheck // ST1001: dot-imports for readability + . "github.com/onsi/gomega" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + + olmv1 "github.com/operator-framework/operator-controller/api/v1" + + "github.com/openshift/operator-framework-operator-controller/openshift/tests-extension/pkg/env" +) + +// preferredPackages is tried in order across all serving catalogs; the first one +// found that satisfies the OLMv1 GA install requirements wins. +// Falls back to the first catalog package that satisfies the requirements. +// +// OLMv1 GA install requirements: +// - AllNamespaces install mode is supported +// - No dependencies (olm.gvk.required / olm.package.required) +// +// Verified against v4.22 redhat-operator-index: +// +// quay-operator AllNamespaces=true deps=0 +// cluster-logging AllNamespaces=true deps=0 +// serverless-operator AllNamespaces=true deps=0 +// logic-operator AllNamespaces=true deps=0 +var preferredPackages = []string{ + "quay-operator", + "cluster-logging", + "serverless-operator", + "logic-operator", +} + +const ( + // catalogReadyTimeout is how long we wait for a catalog's HTTP content to become + // available after its Kubernetes Serving condition is True. The catalogd controller + // may set Serving=True slightly before the HTTP server has indexed the content. + catalogReadyTimeout = 2 * time.Minute + + // catalogRetryInterval is the pause between 404 retries when waiting for a catalog. + catalogRetryInterval = 5 * time.Second +) + +// FindInstallablePackage searches all serving ClusterCatalogs for an installable package, +// favouring preferredPackages in order. Returns the catalog name and package name, +// or fails the test if no packages are found in any serving catalog. +// +// It queries /api/v1/all (always available, no feature gate required) and reads the +// full catalog response to find the highest-priority preferred package that satisfies +// the OLMv1 GA install requirements (AllNamespaces=true, no dependencies). +func FindInstallablePackage(ctx context.Context) (string, string) { + cfg := env.Get().RestCfg + httpClient, err := rest.HTTPClientFor(cfg) + Expect(err).ToNot(HaveOccurred(), "failed to build HTTP client from REST config") + + k8sClient := env.Get().K8sClient + catalogList := &olmv1.ClusterCatalogList{} + Expect(k8sClient.List(ctx, catalogList)).To(Succeed(), "failed to list ClusterCatalogs") + + // Build a rank map: package name → index in preferredPackages (lower = higher priority). + wantedRank := make(map[string]int, len(preferredPackages)) + for i, p := range preferredPackages { + wantedRank[p] = i + } + + // Wait until at least one catalog is Serving before attempting package discovery. + // Catalogs may briefly lag behind at cluster startup even after the Serving condition + // has been written, so a short Eventually avoids a startup race. + Eventually(func(g Gomega) { + g.Expect(k8sClient.List(ctx, catalogList)).To(Succeed(), "failed to list ClusterCatalogs") + serving := 0 + for i := range catalogList.Items { + if meta.IsStatusConditionPresentAndEqual(catalogList.Items[i].Status.Conditions, olmv1.TypeServing, metav1.ConditionTrue) { + serving++ + } + } + g.Expect(serving).To(BeNumerically(">", 0), "no ClusterCatalogs are Serving yet") + }).WithContext(ctx).WithTimeout(DefaultTimeout).WithPolling(DefaultPolling).Should(Succeed()) + + bestCatalog, bestPkg, bestRank := "", "", math.MaxInt + + for i := range catalogList.Items { + cc := &catalogList.Items[i] + if !meta.IsStatusConditionPresentAndEqual(cc.Status.Conditions, olmv1.TypeServing, metav1.ConditionTrue) { + fmt.Fprintf(GinkgoWriter, "Catalog %q is not serving, skipping\n", cc.Name) + continue + } + + pkg, rank, qErr := findPackageInCatalog(ctx, httpClient, cfg.Host, cc.Name, wantedRank) + if qErr != nil { + fmt.Fprintf(GinkgoWriter, "Warning: could not query catalog %q: %v\n", cc.Name, qErr) + continue + } + if pkg == "" { + fmt.Fprintf(GinkgoWriter, "Catalog %q: no suitable packages found\n", cc.Name) + continue + } + + fmt.Fprintf(GinkgoWriter, "Catalog %q: found %q (rank %d)\n", cc.Name, pkg, rank) + if bestPkg == "" || rank < bestRank { + bestCatalog, bestPkg, bestRank = cc.Name, pkg, rank + } + } + + if bestPkg != "" { + fmt.Fprintf(GinkgoWriter, "Selected package %q from catalog %q\n", bestPkg, bestCatalog) + return bestCatalog, bestPkg + } + + Fail("no installable packages found in any serving catalog") + return "", "" // unreachable +} + +// findPackageInCatalog queries the catalog's /api/v1/all endpoint (always available, +// unlike /api/v1/metas which requires the NewOLMCatalogdAPIV1Metas feature gate), +// reads the full response, and returns the highest-ranked preferred package that +// satisfies the OLMv1 GA install requirements (AllNamespaces=true, no dependencies). +// +// A 404 response means the catalogd HTTP server has not yet indexed the catalog content +// even though the Kubernetes Serving condition is True; the call is retried with backoff +// up to catalogReadyTimeout before giving up. +// +// rank == math.MaxInt means the returned package is a fallback (not in wantedRank). +// Returns ("", 0, nil) when no suitable packages are found. +func findPackageInCatalog(ctx context.Context, httpClient *http.Client, apiServerHost, catalogName string, wantedRank map[string]int) (string, int, error) { + url := strings.TrimRight(apiServerHost, "/") + + fmt.Sprintf("/api/v1/namespaces/openshift-catalogd/services/https:catalogd-service:443/proxy/catalogs/%s/api/v1/all", + catalogName) + + deadline := time.Now().Add(catalogReadyTimeout) + for { + resp, err := doGet(ctx, httpClient, url) + if err != nil { + return "", 0, err + } + + if resp.StatusCode == http.StatusOK { + defer resp.Body.Close() + return scanPackages(resp, wantedRank) + } + + resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound || time.Now().After(deadline) { + return "", 0, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, url) + } + + // 404: catalogd has not yet indexed this catalog's content; wait and retry. + fmt.Fprintf(GinkgoWriter, "Catalog %q not yet indexed by catalogd (404), retrying in %s\n", + catalogName, catalogRetryInterval) + select { + case <-ctx.Done(): + return "", 0, ctx.Err() + case <-time.After(catalogRetryInterval): + } + } +} + +func doGet(ctx context.Context, httpClient *http.Client, url string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("GET: %w", err) + } + return resp, nil +} + +// fbcEntry is one entry in an olm.channel entries list. +type fbcEntry struct { + Name string `json:"name"` + Replaces string `json:"replaces"` + Skips []string `json:"skips"` +} + +// fbcProp is one property in an olm.bundle properties list. +type fbcProp struct { + Type string `json:"type"` + Value json.RawMessage `json:"value"` +} + +// scanPackages reads the full /api/v1/all JSONL response in a single pass, +// collecting olm.package / olm.channel / olm.bundle objects into in-memory maps, +// then resolves each package's default-channel head bundle and validates it against +// the OLMv1 GA install requirements (AllNamespaces=true, no dependencies). +// +// Returns the highest-ranked preferred package that passes, or the first valid +// fallback, or ("", math.MaxInt, nil) when nothing suitable is found. +func scanPackages(resp *http.Response, wantedRank map[string]int) (string, int, error) { + const maxCatalogLineBytes = 16 * 1024 * 1024 + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 1024*1024), maxCatalogLineBytes) + + // Accumulate all three FBC object types in one pass. + type pkgRecord struct{ defaultChannel string } + pkgs := map[string]pkgRecord{} // pkg name → record + chans := map[string][]fbcEntry{} // "pkg/channel" → entries + bundles := map[string]map[string][]fbcProp{} // pkg → bundleName → props + + for scanner.Scan() { + var obj struct { + Schema string `json:"schema"` + Name string `json:"name"` + Package string `json:"package"` + DefaultChannel string `json:"defaultChannel"` + Entries []fbcEntry `json:"entries"` + Properties []fbcProp `json:"properties"` + } + if err := json.Unmarshal(scanner.Bytes(), &obj); err != nil || obj.Name == "" { + continue + } + switch obj.Schema { + case "olm.package": + pkgs[obj.Name] = pkgRecord{defaultChannel: obj.DefaultChannel} + case "olm.channel": + chans[obj.Package+"/"+obj.Name] = obj.Entries + case "olm.bundle": + if bundles[obj.Package] == nil { + bundles[obj.Package] = map[string][]fbcProp{} + } + bundles[obj.Package][obj.Name] = obj.Properties + } + } + if err := scanner.Err(); err != nil { + return "", 0, err + } + + // Sort package names so fallback selection is deterministic across runs. + pkgNames := make([]string, 0, len(pkgs)) + for pkgName := range pkgs { + pkgNames = append(pkgNames, pkgName) + } + sort.Strings(pkgNames) + + // Resolve each package to its default-channel head bundle and validate. + bestPkg, bestRank, fallback := "", math.MaxInt, "" + for _, pkgName := range pkgNames { + rec := pkgs[pkgName] + entries := chans[pkgName+"/"+rec.defaultChannel] + head := channelHead(entries) + if head == "" { + continue + } + props := bundles[pkgName][head] + allNS, hasDeps := checkBundleProps(props) + if !allNS || hasDeps { + continue // does not satisfy GA requirements + } + if fallback == "" { + fallback = pkgName + } + if rank, ok := wantedRank[pkgName]; ok && (bestPkg == "" || rank < bestRank) { + bestPkg, bestRank = pkgName, rank + } + } + + if bestPkg != "" { + return bestPkg, bestRank, nil + } + return fallback, math.MaxInt, nil +} + +// channelHead returns the name of the head bundle for a channel's entry list. +// The head is the entry not referenced by any other entry's replaces/skips fields. +func channelHead(entries []fbcEntry) string { + all := make(map[string]struct{}, len(entries)) + for _, e := range entries { + all[e.Name] = struct{}{} + } + replaced := make(map[string]struct{}) + for _, e := range entries { + if e.Replaces != "" { + replaced[e.Replaces] = struct{}{} + } + for _, s := range e.Skips { + replaced[s] = struct{}{} + } + } + for name := range all { + if _, ok := replaced[name]; !ok { + return name + } + } + if len(entries) > 0 { + return entries[len(entries)-1].Name + } + return "" +} + +// checkBundleProps inspects olm.bundle properties and returns whether AllNamespaces +// is supported and whether any dependencies are declared. +func checkBundleProps(props []fbcProp) (bool, bool) { + allNamespaces, hasDeps := false, false + for _, prop := range props { + switch prop.Type { + case "olm.csv.metadata": + var meta struct { + InstallModes []struct { + Type string `json:"type"` + Supported bool `json:"supported"` + } `json:"installModes"` + } + if err := json.Unmarshal(prop.Value, &meta); err == nil { + for _, m := range meta.InstallModes { + if m.Type == "AllNamespaces" { + allNamespaces = m.Supported + } + } + } + case "olm.gvk.required", "olm.package.required": + hasDeps = true + } + } + return allNamespaces, hasDeps +} diff --git a/openshift/tests-extension/test/olmv1.go b/openshift/tests-extension/test/olmv1.go index e8089f4918..082917cb52 100644 --- a/openshift/tests-extension/test/olmv1.go +++ b/openshift/tests-extension/test/olmv1.go @@ -107,14 +107,17 @@ var _ = Describe("[sig-olmv1][OCPFeatureGate:NewOLM][Skipped:Disconnected] OLMv1 }) It("should install an openshift catalog cluster extension", Label("original-name:[sig-olmv1][OCPFeatureGate:NewOLM][Skipped:Disconnected] OLMv1 operator installation should install an openshift catalog cluster extension"), func(ctx SpecContext) { - By("ensuring no ClusterExtension and CRD for quay-operator") - helpers.EnsureCleanupClusterExtension(context.Background(), "quay-operator", "quayregistries.quay.redhat.com") + catalog, pkg := helpers.FindInstallablePackage(ctx) - By("applying the ClusterExtension resource") - name, cleanup := helpers.CreateClusterExtension("quay-operator", "3.13.10", namespace, "") + By(fmt.Sprintf("ensuring no existing ClusterExtension for %q", pkg)) + helpers.EnsureCleanupClusterExtension(context.Background(), pkg, "") + + By(fmt.Sprintf("applying ClusterExtension for %q from catalog %q", pkg, catalog)) + name, cleanup := helpers.CreateClusterExtension(pkg, "", namespace, "", + helpers.WithCatalogNameSelector(catalog)) DeferCleanup(cleanup) - By("waiting for the quay-operator ClusterExtension to be installed") + By(fmt.Sprintf("waiting for %q to be installed", pkg)) helpers.ExpectClusterExtensionToBeInstalled(ctx, name) }) })