From 5e2dad905705d47b73c4b99633f023811d2469ab Mon Sep 17 00:00:00 2001 From: Leo6Leo <36619969+Leo6Leo@users.noreply.github.com> Date: Mon, 5 Jan 2026 16:52:14 -0500 Subject: [PATCH 1/8] CONSOLE-4728: Add icon caching service for OLMv1 catalog packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds infrastructure to cache operator icons from catalogd: - CachedIcon struct to store icon data with ETag/LastModified headers - FetchPackageIcon client method to query catalogd metas endpoint - GetPackageIcon service method with in-memory caching - parsePackageIcon to extract icon data from FBC package metadata 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- pkg/olm/catalog.go | 8 ++ pkg/olm/catalog_client.go | 28 +++++ pkg/olm/catalog_service.go | 108 ++++++++++++++++++++ pkg/olm/catalog_service_test.go | 176 +++++++++++++++++++++++++++++++- 4 files changed, 317 insertions(+), 3 deletions(-) diff --git a/pkg/olm/catalog.go b/pkg/olm/catalog.go index dab9c1abf52..dfcc4e500ee 100644 --- a/pkg/olm/catalog.go +++ b/pkg/olm/catalog.go @@ -12,6 +12,14 @@ import ( "k8s.io/klog/v2" ) +// CachedIcon represents a cached operator icon. +type CachedIcon struct { + Data []byte + MediaType string + LastModified string + ETag string +} + // ConsoleCatalogItem represents a single item in the catalog. type ConsoleCatalogItem struct { ID string `json:"id"` diff --git a/pkg/olm/catalog_client.go b/pkg/olm/catalog_client.go index 667f3f70d6b..6cd6d27551a 100644 --- a/pkg/olm/catalog_client.go +++ b/pkg/olm/catalog_client.go @@ -13,6 +13,7 @@ import ( type CatalogdClientInterface interface { FetchAll(catalog, baseUrl, ifModifiedSince string, maxAge time.Duration) (*http.Response, error) FetchMetas(catalogName string, baseURL string, r *http.Request) (*http.Response, error) + FetchPackageIcon(catalog, baseURL, packageName string) (*http.Response, error) } // CatalogFetcher is responsible for fetching catalog data. @@ -89,3 +90,30 @@ func (c *CatalogdClient) buildCatalogdURL(catalog, baseURL, endpoint string) (st } return url.JoinPath(baseURL, endpoint) } + +// FetchPackageIcon fetches package metadata from catalogd to extract icon data. +// It queries the metas endpoint with schema=olm.package and name=packageName filters. +func (c *CatalogdClient) FetchPackageIcon(catalog, baseURL, packageName string) (*http.Response, error) { + requestURL, err := c.buildCatalogdURL(catalog, baseURL, CatalogdMetasEndpoint) + if err != nil { + return nil, err + } + + // Add query parameters to filter for the specific package + parsedURL, err := url.Parse(requestURL) + if err != nil { + return nil, err + } + query := parsedURL.Query() + query.Set("schema", "olm.package") + query.Set("name", packageName) + parsedURL.RawQuery = query.Encode() + + req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil) + if err != nil { + return nil, err + } + + klog.V(4).Infof("Fetching package icon: %s %s", req.Method, req.URL.String()) + return c.httpClient.Do(req) +} diff --git a/pkg/olm/catalog_service.go b/pkg/olm/catalog_service.go index db71f181e4c..f3b725b68ad 100644 --- a/pkg/olm/catalog_service.go +++ b/pkg/olm/catalog_service.go @@ -1,8 +1,12 @@ package olm import ( + "bytes" + "crypto/md5" + "encoding/hex" "encoding/json" "fmt" + "io" "net/http" "time" @@ -48,6 +52,10 @@ func getCatalogBaseURLKey(catalog string) string { return keyPrefix + catalog + ":baseURL" } +func getCatalogIconKey(catalog, packageName string) string { + return keyPrefix + catalog + ":icon:" + packageName +} + func NewCatalogService(serviceClient *http.Client, proxyConfig *proxy.Config, cache *cache.Cache) *CatalogService { c := &CatalogService{ cache: cache, @@ -68,6 +76,106 @@ func (s *CatalogService) GetMetas(catalog string, r *http.Request) (*http.Respon return s.client.FetchMetas(catalog, baseURL, r) } +// GetPackageIcon retrieves the icon for a package from the cache or fetches it from catalogd. +// Returns the cached icon data, or nil if the package or icon is not found. +func (s *CatalogService) GetPackageIcon(catalog, packageName string) (*CachedIcon, error) { + iconKey := getCatalogIconKey(catalog, packageName) + + // Check cache first + if cachedIcon, ok := s.cache.Get(iconKey); ok { + if icon, ok := cachedIcon.(*CachedIcon); ok { + klog.V(4).Infof("Cache hit for icon: %s/%s", catalog, packageName) + return icon, nil + } + // Malformed cache entry, remove it + s.cache.Delete(iconKey) + } + + // Cache miss - fetch from catalogd + klog.V(4).Infof("Cache miss for icon: %s/%s, fetching from catalogd", catalog, packageName) + + var baseURL string + baseURLKey := getCatalogBaseURLKey(catalog) + if cachedBaseURL, ok := s.cache.Get(baseURLKey); ok { + if baseURL, ok = cachedBaseURL.(string); !ok { + return nil, fmt.Errorf("cached base URL for catalog %s is not a string", catalog) + } + } + + resp, err := s.client.FetchPackageIcon(catalog, baseURL, packageName) + if err != nil { + return nil, fmt.Errorf("failed to fetch package icon: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("catalogd request failed with status: %d", resp.StatusCode) + } + + // Parse the response to extract the package icon + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + icon, err := s.parsePackageIcon(body) + if err != nil { + return nil, err + } + + if icon == nil { + // Package found but has no icon + klog.V(4).Infof("Package %s/%s has no icon", catalog, packageName) + return nil, nil + } + + // Cache the icon + s.cache.Set(iconKey, icon, cacheExpiration) + klog.V(4).Infof("Cached icon for %s/%s", catalog, packageName) + + return icon, nil +} + +// parsePackageIcon extracts icon data from the catalogd metas response. +func (s *CatalogService) parsePackageIcon(body []byte) (*CachedIcon, error) { + var pkg declcfg.Package + + // The response is a JSON stream, we need to parse the first (and only) package + reader := io.NopCloser(bytes.NewReader(body)) + if err := declcfg.WalkMetasReader(reader, func(meta *declcfg.Meta, err error) error { + if err != nil { + return err + } + if meta.Schema == declcfg.SchemaPackage { + if err := json.Unmarshal(meta.Blob, &pkg); err != nil { + return fmt.Errorf("failed to unmarshal package: %w", err) + } + } + return nil + }); err != nil { + return nil, fmt.Errorf("failed to parse package metadata: %w", err) + } + + if pkg.Icon == nil || len(pkg.Icon.Data) == 0 { + return nil, nil + } + + // Generate ETag from icon data + hash := md5.Sum(pkg.Icon.Data) + etag := hex.EncodeToString(hash[:]) + + return &CachedIcon{ + Data: pkg.Icon.Data, + MediaType: pkg.Icon.MediaType, + LastModified: time.Now().UTC().Format(http.TimeFormat), + ETag: etag, + }, nil +} + // Start begins the polling process. func (s *CatalogService) UpdateCatalog(catalog string, baseURL string) error { itemsKey := getCatalogItemsKey(catalog) diff --git a/pkg/olm/catalog_service_test.go b/pkg/olm/catalog_service_test.go index 88de31dbe13..fc84280302c 100644 --- a/pkg/olm/catalog_service_test.go +++ b/pkg/olm/catalog_service_test.go @@ -18,9 +18,12 @@ import ( ) type mockCatalogdClient struct { - packages []declcfg.Package - bundles []declcfg.Bundle - err error + packages []declcfg.Package + bundles []declcfg.Bundle + packageIconMap map[string]*declcfg.Package // packageName -> package with icon + err error + fetchPackageErr error + fetchPackageCode int } func (m *mockCatalogdClient) FetchMetas(catalog string, baseURL string, r *http.Request) (*http.Response, error) { @@ -28,6 +31,44 @@ func (m *mockCatalogdClient) FetchMetas(catalog string, baseURL string, r *http. return nil, fmt.Errorf("FetchMetas not implemented in mock") } +func (m *mockCatalogdClient) FetchPackageIcon(catalog, baseURL, packageName string) (*http.Response, error) { + if m.fetchPackageErr != nil { + return nil, m.fetchPackageErr + } + + if m.fetchPackageCode == http.StatusNotFound { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewReader([]byte{})), + }, nil + } + + pkg, ok := m.packageIconMap[packageName] + if !ok { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewReader([]byte{})), + }, nil + } + + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + pkgMap := map[string]any{ + "schema": declcfg.SchemaPackage, + "name": pkg.Name, + "defaultChannel": pkg.DefaultChannel, + "icon": pkg.Icon, + } + if err := encoder.Encode(pkgMap); err != nil { + return nil, err + } + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(buf.Bytes())), + }, nil +} + func (m *mockCatalogdClient) FetchAll(catalog, baseURL, ifNotModifiedSince string, maxAge time.Duration) (*http.Response, error) { if m.err != nil { return nil, m.err @@ -228,3 +269,132 @@ func TestGetCatalogItems(t *testing.T) { assert.Equal(t, items, returnedItems) }) } + +func TestGetPackageIcon(t *testing.T) { + t.Run("should return icon from cache", func(t *testing.T) { + c := cache.New(5*time.Minute, 10*time.Minute) + cachedIcon := &CachedIcon{ + Data: []byte("cached-icon-data"), + MediaType: "image/svg+xml", + LastModified: time.Now().UTC().Format(http.TimeFormat), + ETag: "cached-etag", + } + c.Set(getCatalogIconKey("test-catalog", "test-package"), cachedIcon, cache.NoExpiration) + + service := &CatalogService{ + cache: c, + index: make(map[string]struct{}), + } + + icon, err := service.GetPackageIcon("test-catalog", "test-package") + + require.NoError(t, err) + assert.Equal(t, cachedIcon, icon) + }) + + t.Run("should fetch icon from catalogd on cache miss", func(t *testing.T) { + c := cache.New(5*time.Minute, 10*time.Minute) + // Set the base URL so the service knows where to fetch from + c.Set(getCatalogBaseURLKey("test-catalog"), "http://catalogd.test", cache.NoExpiration) + + iconData := []byte("test-icon-data") + client := &mockCatalogdClient{ + packageIconMap: map[string]*declcfg.Package{ + "test-package": { + Name: "test-package", + Icon: &declcfg.Icon{ + Data: iconData, + MediaType: "image/png", + }, + }, + }, + } + + service := &CatalogService{ + cache: c, + client: client, + index: make(map[string]struct{}), + } + + icon, err := service.GetPackageIcon("test-catalog", "test-package") + + require.NoError(t, err) + require.NotNil(t, icon) + assert.Equal(t, iconData, icon.Data) + assert.Equal(t, "image/png", icon.MediaType) + assert.NotEmpty(t, icon.ETag) + assert.NotEmpty(t, icon.LastModified) + + // Verify the icon was cached + cachedIcon, found := c.Get(getCatalogIconKey("test-catalog", "test-package")) + assert.True(t, found) + assert.Equal(t, icon, cachedIcon) + }) + + t.Run("should return nil when package not found", func(t *testing.T) { + c := cache.New(5*time.Minute, 10*time.Minute) + c.Set(getCatalogBaseURLKey("test-catalog"), "http://catalogd.test", cache.NoExpiration) + + client := &mockCatalogdClient{ + packageIconMap: map[string]*declcfg.Package{}, + fetchPackageCode: http.StatusNotFound, + } + + service := &CatalogService{ + cache: c, + client: client, + index: make(map[string]struct{}), + } + + icon, err := service.GetPackageIcon("test-catalog", "nonexistent-package") + + require.NoError(t, err) + assert.Nil(t, icon) + }) + + t.Run("should return nil when package has no icon", func(t *testing.T) { + c := cache.New(5*time.Minute, 10*time.Minute) + c.Set(getCatalogBaseURLKey("test-catalog"), "http://catalogd.test", cache.NoExpiration) + + client := &mockCatalogdClient{ + packageIconMap: map[string]*declcfg.Package{ + "test-package": { + Name: "test-package", + Icon: nil, // No icon + }, + }, + } + + service := &CatalogService{ + cache: c, + client: client, + index: make(map[string]struct{}), + } + + icon, err := service.GetPackageIcon("test-catalog", "test-package") + + require.NoError(t, err) + assert.Nil(t, icon) + }) + + t.Run("should return error when fetch fails", func(t *testing.T) { + c := cache.New(5*time.Minute, 10*time.Minute) + c.Set(getCatalogBaseURLKey("test-catalog"), "http://catalogd.test", cache.NoExpiration) + + client := &mockCatalogdClient{ + fetchPackageErr: fmt.Errorf("network error"), + } + + service := &CatalogService{ + cache: c, + client: client, + index: make(map[string]struct{}), + } + + icon, err := service.GetPackageIcon("test-catalog", "test-package") + + require.Error(t, err) + assert.Nil(t, icon) + assert.Contains(t, err.Error(), "network error") + }) +} From cc15504d92fcbba9856eeaf4c82547061cea57d1 Mon Sep 17 00:00:00 2001 From: Leo6Leo <36619969+Leo6Leo@users.noreply.github.com> Date: Mon, 5 Jan 2026 16:52:23 -0500 Subject: [PATCH 2/8] CONSOLE-4728: Add catalog icon HTTP handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /api/olm/catalog-icons/{catalogName}/{packageName} endpoint that: - Serves cached operator icons by package name - Supports conditional requests with ETag and If-Modified-Since headers - Returns 304 Not Modified when cache is still valid - Sets appropriate Cache-Control headers for browser caching 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- pkg/olm/handler.go | 57 +++++++++++++++ pkg/olm/handler_test.go | 157 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+) diff --git a/pkg/olm/handler.go b/pkg/olm/handler.go index 947aa7f7963..f017ce1ecd3 100644 --- a/pkg/olm/handler.go +++ b/pkg/olm/handler.go @@ -32,6 +32,7 @@ func NewOLMHandler(apiServerURL string, client *http.Client, service *CatalogSer mux := http.NewServeMux() mux.HandleFunc("/api/olm/catalog-items/", o.catalogItemsHandler) mux.HandleFunc("/api/olm/catalogd/metas/{catalogName}", middleware.AllowMethod(http.MethodGet, o.catalogdMetasHandler)) + mux.HandleFunc("/api/olm/catalog-icons/{catalogName}/{packageName}", middleware.AllowMethod(http.MethodGet, o.catalogIconHandler)) mux.HandleFunc("/api/olm/list-operands/", o.operandsListHandler) mux.HandleFunc("/api/olm/check-package-manifests/", o.checkPackageManifestHandler) o.mux = mux @@ -95,6 +96,62 @@ func (o *OLMHandler) catalogItemsHandler(w http.ResponseWriter, r *http.Request) } } +// catalogIconHandler serves operator icons by package name with caching support. +// It returns 304 Not Modified if the client's cached version is still valid. +func (o *OLMHandler) catalogIconHandler(w http.ResponseWriter, r *http.Request) { + catalogName := r.PathValue("catalogName") + packageName := r.PathValue("packageName") + + if catalogName == "" || packageName == "" { + serverutils.SendResponse(w, http.StatusBadRequest, serverutils.ApiError{Err: "catalog name and package name are required"}) + return + } + + icon, err := o.catalogService.GetPackageIcon(catalogName, packageName) + if err != nil { + klog.Errorf("Failed to get icon for %s/%s: %v", catalogName, packageName, err) + serverutils.SendResponse(w, http.StatusInternalServerError, serverutils.ApiError{Err: fmt.Sprintf("Failed to get icon: %v", err)}) + return + } + + if icon == nil { + serverutils.SendResponse(w, http.StatusNotFound, serverutils.ApiError{Err: "icon not found"}) + return + } + + // Check If-None-Match header for ETag validation + ifNoneMatch := r.Header.Get("If-None-Match") + if ifNoneMatch != "" && ifNoneMatch == fmt.Sprintf(`"%s"`, icon.ETag) { + klog.V(4).Infof("Icon not modified (ETag match) for %s/%s", catalogName, packageName) + w.WriteHeader(http.StatusNotModified) + return + } + + // Check If-Modified-Since header + modified, err := serverutils.ModifiedSince(r, icon.LastModified) + if err != nil { + klog.Warningf("Invalid If-Modified-Since header: %v", err) + // Continue serving the icon even if the header is invalid + } else if !modified { + klog.V(4).Infof("Icon not modified for %s/%s", catalogName, packageName) + w.WriteHeader(http.StatusNotModified) + return + } + + // Set cache headers + w.Header().Set("Content-Type", icon.MediaType) + w.Header().Set("Cache-Control", "public, max-age=86400") // 24 hours + w.Header().Set("ETag", fmt.Sprintf(`"%s"`, icon.ETag)) + if icon.LastModified != "" { + w.Header().Set("Last-Modified", icon.LastModified) + } + + // Write icon data + if _, err := w.Write(icon.Data); err != nil { + klog.Errorf("Failed to write icon data: %v", err) + } +} + func (o *OLMHandler) checkPackageManifestHandler(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { w.Header().Set("Allow", "GET") diff --git a/pkg/olm/handler_test.go b/pkg/olm/handler_test.go index c6ae5a674a9..b19e50e4a44 100644 --- a/pkg/olm/handler_test.go +++ b/pkg/olm/handler_test.go @@ -1,7 +1,9 @@ package olm import ( + "encoding/base64" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -114,3 +116,158 @@ func TestOLMHandler_catalogdMetasHandler(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, rr.Code) }) } + +func TestOLMHandler_catalogIconHandler(t *testing.T) { + t.Run("should return icon when found in cache", func(t *testing.T) { + c := cache.New(5*time.Minute, 10*time.Minute) + icon := &CachedIcon{ + Data: []byte("test-icon-data"), + MediaType: "image/png", + LastModified: time.Now().UTC().Format(http.TimeFormat), + ETag: "abc123", + } + c.Set(getCatalogIconKey("test-catalog", "test-package"), icon, cache.NoExpiration) + + service := NewCatalogService(&http.Client{}, nil, c) + handler := NewOLMHandler("", nil, service) + + req := httptest.NewRequest("GET", "/api/olm/catalog-icons/test-catalog/test-package", nil) + req.SetPathValue("catalogName", "test-catalog") + req.SetPathValue("packageName", "test-package") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "image/png", rr.Header().Get("Content-Type")) + assert.Equal(t, `"abc123"`, rr.Header().Get("ETag")) + assert.Equal(t, "public, max-age=86400", rr.Header().Get("Cache-Control")) + assert.Equal(t, "test-icon-data", rr.Body.String()) + }) + + t.Run("should return 404 when icon not found", func(t *testing.T) { + // Create a mock catalogd server that returns no icon + catalogdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer catalogdServer.Close() + + c := cache.New(5*time.Minute, 10*time.Minute) + c.Set(getCatalogBaseURLKey("test-catalog"), catalogdServer.URL, cache.NoExpiration) + + service := NewCatalogService(&http.Client{}, nil, c) + handler := NewOLMHandler("", nil, service) + + req := httptest.NewRequest("GET", "/api/olm/catalog-icons/test-catalog/test-package", nil) + req.SetPathValue("catalogName", "test-catalog") + req.SetPathValue("packageName", "test-package") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusNotFound, rr.Code) + }) + + t.Run("should return 304 when ETag matches", func(t *testing.T) { + c := cache.New(5*time.Minute, 10*time.Minute) + icon := &CachedIcon{ + Data: []byte("test-icon-data"), + MediaType: "image/png", + LastModified: time.Now().UTC().Format(http.TimeFormat), + ETag: "abc123", + } + c.Set(getCatalogIconKey("test-catalog", "test-package"), icon, cache.NoExpiration) + + service := NewCatalogService(&http.Client{}, nil, c) + handler := NewOLMHandler("", nil, service) + + req := httptest.NewRequest("GET", "/api/olm/catalog-icons/test-catalog/test-package", nil) + req.SetPathValue("catalogName", "test-catalog") + req.SetPathValue("packageName", "test-package") + req.Header.Set("If-None-Match", `"abc123"`) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusNotModified, rr.Code) + }) + + t.Run("should return 304 when If-Modified-Since is after LastModified", func(t *testing.T) { + lastModified := time.Now().Add(-1 * time.Hour) + c := cache.New(5*time.Minute, 10*time.Minute) + icon := &CachedIcon{ + Data: []byte("test-icon-data"), + MediaType: "image/png", + LastModified: lastModified.UTC().Format(http.TimeFormat), + ETag: "abc123", + } + c.Set(getCatalogIconKey("test-catalog", "test-package"), icon, cache.NoExpiration) + + service := NewCatalogService(&http.Client{}, nil, c) + handler := NewOLMHandler("", nil, service) + + req := httptest.NewRequest("GET", "/api/olm/catalog-icons/test-catalog/test-package", nil) + req.SetPathValue("catalogName", "test-catalog") + req.SetPathValue("packageName", "test-package") + // Set If-Modified-Since to after the icon's LastModified time + req.Header.Set("If-Modified-Since", time.Now().UTC().Format(http.TimeFormat)) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusNotModified, rr.Code) + }) + + t.Run("should return icon when fetched from catalogd on cache miss", func(t *testing.T) { + // Create a mock catalogd server that returns a package with icon + iconData := []byte("mock-icon-data") + base64IconData := base64.StdEncoding.EncodeToString(iconData) + catalogdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify the query parameters + assert.Equal(t, "olm.package", r.URL.Query().Get("schema")) + assert.Equal(t, "test-package", r.URL.Query().Get("name")) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + // Write a package with icon in FBC format (icon data must be base64 encoded) + pkgJSON := fmt.Sprintf(`{"schema":"olm.package","name":"test-package","icon":{"base64data":"%s","mediatype":"image/png"}}`, + base64IconData) + w.Write([]byte(pkgJSON + "\n")) + })) + defer catalogdServer.Close() + + c := cache.New(5*time.Minute, 10*time.Minute) + c.Set(getCatalogBaseURLKey("test-catalog"), catalogdServer.URL, cache.NoExpiration) + + service := NewCatalogService(&http.Client{}, nil, c) + handler := NewOLMHandler("", nil, service) + + req := httptest.NewRequest("GET", "/api/olm/catalog-icons/test-catalog/test-package", nil) + req.SetPathValue("catalogName", "test-catalog") + req.SetPathValue("packageName", "test-package") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "image/png", rr.Header().Get("Content-Type")) + assert.NotEmpty(t, rr.Header().Get("ETag")) + assert.NotEmpty(t, rr.Header().Get("Cache-Control")) + assert.Equal(t, iconData, rr.Body.Bytes()) + }) + + t.Run("should return 404 when URL does not match pattern", func(t *testing.T) { + c := cache.New(5*time.Minute, 10*time.Minute) + service := NewCatalogService(&http.Client{}, nil, c) + handler := NewOLMHandler("", nil, service) + + // URL with missing package name doesn't match the route pattern + req := httptest.NewRequest("GET", "/api/olm/catalog-icons/test-catalog", nil) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + // Router returns 404 when URL doesn't match pattern + assert.Equal(t, http.StatusNotFound, rr.Code) + }) +} From c5951b66ad26c48b281269fb10052c3613256862 Mon Sep 17 00:00:00 2001 From: Leo6Leo <36619969+Leo6Leo@users.noreply.github.com> Date: Mon, 5 Jan 2026 16:52:32 -0500 Subject: [PATCH 3/8] CONSOLE-4728: Enable lazy loading icons for OLMv1 catalog items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates frontend to use the new icon caching endpoint: - Add icon URL pointing to /api/olm/catalog-icons/{catalog}/{package} - Use lazy loading (loading="lazy") for efficient icon loading - Return custom icon node from getIconProps for URL-based icons 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/components/catalog/utils/catalog-utils.tsx | 5 ++++- .../operator-lifecycle-manager-v1/src/utils/catalog-item.tsx | 4 +--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx b/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx index d82b9aa23d2..b5c9f38aee0 100644 --- a/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx +++ b/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx @@ -265,7 +265,10 @@ export const getIconProps = (item: CatalogItem) => { return {}; } if (icon.url) { - return { iconImg: icon.url, iconClass: null }; + // Use a custom icon node with loading="lazy" for efficient loading + // eslint-disable-next-line jsx-a11y/alt-text + const iconNode = ; + return { iconImg: null, iconClass: null, icon: iconNode }; } if (icon.class) { return { iconImg: null, iconClass: normalizeIconClass(icon.class) }; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx b/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx index ff6dfeafd52..93a913e1f2f 100644 --- a/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx +++ b/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx @@ -99,9 +99,7 @@ export const normalizeCatalogItem: NormalizeExtensionCatalogItem = (item) => { descriptions: [{ value: }], }, displayName, - // Remove icon until we have an endpoint to lazy load cached icons. - // TODO Add icon back once https://issues.redhat.com/browse/CONSOLE-4728 is completed. - // icon: { url: '/api/olm/catalog-icons// }, + icon: { url: `/api/olm/catalog-icons/${catalog}/${name}` }, name: displayName || name, supportUrl: support, provider, From b25ff6958ae635ba53041e6310a7a9ecc50ef67c Mon Sep 17 00:00:00 2001 From: Leo Li <36619969+Leo6Leo@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:40:52 -0500 Subject: [PATCH 4/8] Update frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../src/components/catalog/utils/catalog-utils.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx b/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx index b5c9f38aee0..24c72c19149 100644 --- a/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx +++ b/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx @@ -266,8 +266,7 @@ export const getIconProps = (item: CatalogItem) => { } if (icon.url) { // Use a custom icon node with loading="lazy" for efficient loading - // eslint-disable-next-line jsx-a11y/alt-text - const iconNode = ; + const iconNode = ; return { iconImg: null, iconClass: null, icon: iconNode }; } if (icon.class) { From d356fa8a433e05c92b1d52a73819a98977b5b0ed Mon Sep 17 00:00:00 2001 From: Leo Li <36619969+Leo6Leo@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:41:01 -0500 Subject: [PATCH 5/8] Update frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../operator-lifecycle-manager-v1/src/utils/catalog-item.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx b/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx index 93a913e1f2f..7f7d2c586e9 100644 --- a/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx +++ b/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx @@ -99,7 +99,7 @@ export const normalizeCatalogItem: NormalizeExtensionCatalogItem = (item) => { descriptions: [{ value: }], }, displayName, - icon: { url: `/api/olm/catalog-icons/${catalog}/${name}` }, + icon: { url: `/api/olm/catalog-icons/${encodeURIComponent(catalog)}/${encodeURIComponent(name)}` }, name: displayName || name, supportUrl: support, provider, From e66c049a13074748989ba886c31b194be737bb68 Mon Sep 17 00:00:00 2001 From: Leo6Leo <36619969+Leo6Leo@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:15:14 -0500 Subject: [PATCH 6/8] nit: run prettier --- .../operator-lifecycle-manager-v1/src/utils/catalog-item.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx b/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx index 7f7d2c586e9..6d81a76d089 100644 --- a/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx +++ b/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx @@ -99,7 +99,9 @@ export const normalizeCatalogItem: NormalizeExtensionCatalogItem = (item) => { descriptions: [{ value: }], }, displayName, - icon: { url: `/api/olm/catalog-icons/${encodeURIComponent(catalog)}/${encodeURIComponent(name)}` }, + icon: { + url: `/api/olm/catalog-icons/${encodeURIComponent(catalog)}/${encodeURIComponent(name)}`, + }, name: displayName || name, supportUrl: support, provider, From 7c16ca3ca16cf436562cfe4236e3cf54dac15b47 Mon Sep 17 00:00:00 2001 From: Leo Li <36619969+Leo6Leo@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:46:00 -0500 Subject: [PATCH 7/8] Update frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx Co-authored-by: Jakub Hadvig --- .../src/components/catalog/utils/catalog-utils.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx b/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx index 24c72c19149..b3f5502ed0b 100644 --- a/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx +++ b/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx @@ -266,7 +266,10 @@ export const getIconProps = (item: CatalogItem) => { } if (icon.url) { // Use a custom icon node with loading="lazy" for efficient loading - const iconNode = ; + onError={(e) => { + e.currentTarget.src = catalogImg; + e.currentTarget.onerror = null; // Prevent infinite loop + }} return { iconImg: null, iconClass: null, icon: iconNode }; } if (icon.class) { From 0ff556313a6c4fc5d5cea56ea6fe49e6c0a81864 Mon Sep 17 00:00:00 2001 From: Leo6Leo <36619969+Leo6Leo@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:02:05 -0500 Subject: [PATCH 8/8] Enhance getIconProps function in catalog-utils.tsx to include TypeScript interface for icon properties. This update improves type safety and ensures proper handling of icon attributes. --- .../components/catalog/utils/catalog-utils.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx b/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx index b3f5502ed0b..7180f8468db 100644 --- a/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx +++ b/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx @@ -1,3 +1,4 @@ +import { ReactNode } from 'react'; import * as _ from 'lodash'; import { useResolvedExtensions, @@ -259,18 +260,19 @@ export const sortCatalogItems = ( } }; -export const getIconProps = (item: CatalogItem) => { +interface IconProps { + iconImg?: string | null; + iconClass?: string | null; + icon?: ReactNode; +} + +export const getIconProps = (item: CatalogItem): IconProps => { const { icon } = item; if (!icon) { - return {}; + return { iconImg: catalogImg, iconClass: null }; } if (icon.url) { - // Use a custom icon node with loading="lazy" for efficient loading - onError={(e) => { - e.currentTarget.src = catalogImg; - e.currentTarget.onerror = null; // Prevent infinite loop - }} - return { iconImg: null, iconClass: null, icon: iconNode }; + return { iconImg: icon.url, iconClass: null }; } if (icon.class) { return { iconImg: null, iconClass: normalizeIconClass(icon.class) };