Skip to content
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ReactNode } from 'react';
import * as _ from 'lodash';
import {
useResolvedExtensions,
Expand Down Expand Up @@ -259,10 +260,16 @@ 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) {
return { iconImg: icon.url, iconClass: null };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,9 @@ export const normalizeCatalogItem: NormalizeExtensionCatalogItem = (item) => {
descriptions: [{ value: <SyncMarkdownView content={markdownDescription || description} /> }],
},
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/<catalog-name>/<package-name> },
icon: {
url: `/api/olm/catalog-icons/${encodeURIComponent(catalog)}/${encodeURIComponent(name)}`,
},
name: displayName || name,
supportUrl: support,
provider,
Expand Down
8 changes: 8 additions & 0 deletions pkg/olm/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
28 changes: 28 additions & 0 deletions pkg/olm/catalog_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
108 changes: 108 additions & 0 deletions pkg/olm/catalog_service.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package olm

import (
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"time"

Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Loading