Skip to content

Commit 772aaa0

Browse files
Merge pull request #990 from alecmerdler/OLM-1211
Bug 1727800: Icon Subresource for PackageManifest
2 parents 574aecf + b1b5354 commit 772aaa0

File tree

6 files changed

+223
-10
lines changed

6 files changed

+223
-10
lines changed

Documentation/contributors/design-proposals/operator-logos.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Operator Package Logos
22

3-
Status: Pending
3+
Status: In Progress
44

55
Version: Alpha
66

pkg/package-server/apis/operators/packagemanifest.go

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,17 @@ func CreateCSVDescription(csv *operatorsv1alpha1.ClusterServiceVersion) CSVDescr
1111
Name: csv.Spec.Provider.Name,
1212
URL: csv.Spec.Provider.URL,
1313
},
14-
Annotations: csv.GetAnnotations(),
15-
LongDescription: csv.Spec.Description,
16-
InstallModes: csv.Spec.InstallModes,
17-
CustomResourceDefinitions: csv.Spec.CustomResourceDefinitions,
18-
APIServiceDefinitions: csv.Spec.APIServiceDefinitions,
14+
Annotations: csv.GetAnnotations(),
15+
LongDescription: csv.Spec.Description,
16+
InstallModes: csv.Spec.InstallModes,
17+
CustomResourceDefinitions: operatorsv1alpha1.CustomResourceDefinitions{
18+
Owned: descriptionsForCRDs(csv.Spec.CustomResourceDefinitions.Owned),
19+
Required: descriptionsForCRDs(csv.Spec.CustomResourceDefinitions.Required),
20+
},
21+
APIServiceDefinitions: operatorsv1alpha1.APIServiceDefinitions{
22+
Owned: descriptionsForAPIServices(csv.Spec.APIServiceDefinitions.Owned),
23+
Required: descriptionsForAPIServices(csv.Spec.APIServiceDefinitions.Required),
24+
},
1925
}
2026

2127
icons := make([]Icon, len(csv.Spec.Icon))
@@ -32,3 +38,34 @@ func CreateCSVDescription(csv *operatorsv1alpha1.ClusterServiceVersion) CSVDescr
3238

3339
return desc
3440
}
41+
42+
// descriptionsForCRDs filters certain fields from provided API descriptions to reduce response size.
43+
func descriptionsForCRDs(crds []operatorsv1alpha1.CRDDescription) []operatorsv1alpha1.CRDDescription {
44+
descriptions := []operatorsv1alpha1.CRDDescription{}
45+
for _, crd := range crds {
46+
descriptions = append(descriptions, operatorsv1alpha1.CRDDescription{
47+
Name: crd.Name,
48+
Version: crd.Version,
49+
Kind: crd.Kind,
50+
DisplayName: crd.DisplayName,
51+
Description: crd.Description,
52+
})
53+
}
54+
return descriptions
55+
}
56+
57+
// descriptionsForAPIServices filters certain fields from provided API descriptions to reduce response size.
58+
func descriptionsForAPIServices(apis []operatorsv1alpha1.APIServiceDescription) []operatorsv1alpha1.APIServiceDescription {
59+
descriptions := []operatorsv1alpha1.APIServiceDescription{}
60+
for _, api := range apis {
61+
descriptions = append(descriptions, operatorsv1alpha1.APIServiceDescription{
62+
Name: api.Name,
63+
Group: api.Group,
64+
Version: api.Version,
65+
Kind: api.Kind,
66+
DisplayName: api.DisplayName,
67+
Description: api.Description,
68+
})
69+
}
70+
return descriptions
71+
}

pkg/package-server/apiserver/generic/storage.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,13 @@ type ProviderConfig struct {
6464

6565
// BuildStorage constructs APIGroupInfo for the packages.apps.redhat.com and packages.operators.coreos.com API groups.
6666
func BuildStorage(providers *ProviderConfig) []generic.APIGroupInfo {
67-
6867
// Build storage for packages.operators.coreos.com
6968
operatorInfo := generic.NewDefaultAPIGroupInfo(v1.Group, Scheme, metav1.ParameterCodec, Codecs)
7069
operatorStorage := storage.NewStorage(v1.Resource("packagemanifests"), providers.Provider, Scheme)
70+
iconStorage := storage.NewLogoStorage(v1.Resource("packagemanifests/icon"), providers.Provider)
7171
operatorResources := map[string]rest.Storage{
72-
"packagemanifests": operatorStorage,
72+
"packagemanifests": operatorStorage,
73+
"packagemanifests/icon": iconStorage,
7374
}
7475
operatorInfo.VersionedResourcesStorageMap[v1.Version] = operatorResources
7576

@@ -78,7 +79,8 @@ func BuildStorage(providers *ProviderConfig) []generic.APIGroupInfo {
7879

7980
// Use storage for package.operators.coreos.com since types are identical
8081
appResources := map[string]rest.Storage{
81-
"packagemanifests": operatorStorage,
82+
"packagemanifests": operatorStorage,
83+
"packagemanifests/icon": iconStorage,
8284
}
8385
appInfo.VersionedResourcesStorageMap[v1alpha1.Version] = appResources
8486

pkg/package-server/provider/interfaces.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ package provider
33
import "github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/apis/operators"
44

55
type PackageManifestProvider interface {
6-
Get(name, namespace string) (*operators.PackageManifest, error)
6+
Get(namespace, name string) (*operators.PackageManifest, error)
77
List(namespace string) (*operators.PackageManifestList, error)
88
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package storage
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"io/ioutil"
7+
"net/http"
8+
"strconv"
9+
"strings"
10+
11+
"k8s.io/apimachinery/pkg/runtime"
12+
"k8s.io/apimachinery/pkg/runtime/schema"
13+
genericreq "k8s.io/apiserver/pkg/endpoints/request"
14+
"k8s.io/apiserver/pkg/registry/rest"
15+
16+
"github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/apis/operators"
17+
"github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/provider"
18+
)
19+
20+
// LogoStorage implements Kubernetes methods needed to provide the `packagemanifests/icon` subresource
21+
type LogoStorage struct {
22+
groupResource schema.GroupResource
23+
prov provider.PackageManifestProvider
24+
}
25+
26+
var _ rest.Connecter = &LogoStorage{}
27+
var _ rest.StorageMetadata = &LogoStorage{}
28+
29+
// NewLogoStorage returns struct which implements Kubernetes methods needed to provide the `packagemanifests/icon` subresource
30+
func NewLogoStorage(groupResource schema.GroupResource, prov provider.PackageManifestProvider) *LogoStorage {
31+
return &LogoStorage{groupResource, prov}
32+
}
33+
34+
// New satisfies the Storage interface
35+
func (s *LogoStorage) New() runtime.Object {
36+
return &operators.PackageManifest{}
37+
}
38+
39+
// Connect satisfies the Connector interface and returns the image icon file for a given `PackageManifest`
40+
func (s *LogoStorage) Connect(ctx context.Context, name string, options runtime.Object, responder rest.Responder) (http.Handler, error) {
41+
var handler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) {
42+
if match := r.Header.Get("If-None-Match"); match != "" && r.URL.Query().Get("resourceVersion") != "" {
43+
w.WriteHeader(http.StatusNotModified)
44+
return
45+
}
46+
47+
namespace := genericreq.NamespaceValue(ctx)
48+
pkg, err := s.prov.Get(namespace, name)
49+
if err != nil || pkg == nil || len(pkg.Status.Channels) == 0 || len(pkg.Status.Channels[0].CurrentCSVDesc.Icon) == 0 {
50+
w.WriteHeader(http.StatusNotFound)
51+
return
52+
}
53+
54+
data := pkg.Status.Channels[0].CurrentCSVDesc.Icon[0].Base64Data
55+
mimeType := pkg.Status.Channels[0].CurrentCSVDesc.Icon[0].Mediatype
56+
etag := `"` + strings.Join([]string{name, pkg.Status.Channels[0].Name, pkg.Status.Channels[0].CurrentCSV}, ".") + `"`
57+
58+
reader := base64.NewDecoder(base64.StdEncoding, strings.NewReader(data))
59+
imgBytes, err := ioutil.ReadAll(reader)
60+
61+
w.Header().Set("Content-Type", mimeType)
62+
w.Header().Set("Content-Length", strconv.Itoa(len(imgBytes)))
63+
w.Header().Set("Etag", etag)
64+
_, err = w.Write(imgBytes)
65+
}
66+
67+
return handler, nil
68+
}
69+
70+
// NewConnectOptions satisfies the Connector interface
71+
func (s *LogoStorage) NewConnectOptions() (runtime.Object, bool, string) {
72+
return nil, false, ""
73+
}
74+
75+
// ConnectMethods satisfies the Connector interface
76+
func (s *LogoStorage) ConnectMethods() []string {
77+
return []string{"GET"}
78+
}
79+
80+
// ProducesMIMETypes satisfies the StorageMetadata interface and returns the supported icon image file types
81+
func (s *LogoStorage) ProducesMIMETypes(verb string) []string {
82+
return []string{
83+
"image/png",
84+
"image/jpeg",
85+
"image/svg+xml",
86+
}
87+
}
88+
89+
// ProducesObject satisfies the StorageMetadata interface
90+
func (s *LogoStorage) ProducesObject(verb string) interface{} {
91+
return ""
92+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package storage
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/apis/operators"
12+
v1 "github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/apis/operators/v1"
13+
"github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/provider"
14+
)
15+
16+
type fakeProvider struct{}
17+
18+
var getCalls = 0
19+
20+
func (p *fakeProvider) Get(namespace, name string) (*operators.PackageManifest, error) {
21+
getCalls = getCalls + 1
22+
23+
return &operators.PackageManifest{
24+
Status: operators.PackageManifestStatus{
25+
Channels: []operators.PackageChannel{
26+
{
27+
Name: "stable",
28+
CurrentCSV: "csv-a",
29+
CurrentCSVDesc: operators.CSVDescription{
30+
Icon: []operators.Icon{{Mediatype: "image/png", Base64Data: iconData}},
31+
},
32+
},
33+
},
34+
},
35+
}, nil
36+
}
37+
38+
func (p *fakeProvider) List(namespace string) (*operators.PackageManifestList, error) {
39+
return &operators.PackageManifestList{}, nil
40+
}
41+
42+
var _ provider.PackageManifestProvider = &fakeProvider{}
43+
44+
func TestLogoStorageConnect(t *testing.T) {
45+
provider := fakeProvider{}
46+
ctx, cancel := context.WithCancel(context.TODO())
47+
defer cancel()
48+
49+
storage := NewLogoStorage(v1.Resource("packagemanifests/icon"), &provider)
50+
51+
rr := httptest.NewRecorder()
52+
req, err := http.NewRequest("GET", "", nil)
53+
require.NoError(t, err)
54+
55+
handler, err := storage.Connect(ctx, "pkg-a", nil, nil)
56+
require.NoError(t, err)
57+
58+
handler.ServeHTTP(rr, req)
59+
require.Equal(t, http.StatusOK, rr.Code)
60+
require.Equal(t, "image/png", rr.Header().Get("Content-Type"))
61+
require.NotNil(t, rr.Body)
62+
63+
cachedRR := httptest.NewRecorder()
64+
cachedReq, err := http.NewRequest("GET", "?resourceVersion=pkg-a.stable.csv-a", nil)
65+
require.NoError(t, err)
66+
67+
etag := rr.Header().Get("Etag")
68+
require.Equal(t, `"pkg-a.stable.csv-a"`, etag)
69+
cachedReq.Header.Set("If-None-Match", etag)
70+
71+
handler.ServeHTTP(cachedRR, cachedReq)
72+
require.Equal(t, http.StatusNotModified, cachedRR.Code)
73+
require.Equal(t, 1, getCalls, "PackageManifestProvider.Get() should not be called for cached icon")
74+
75+
handler.ServeHTTP(rr, req)
76+
require.Equal(t, http.StatusOK, rr.Code)
77+
require.Equal(t, 2, getCalls, "PackageManifestProvider.Get() should be called again to fetch icon")
78+
}
79+
80+
const iconData = `
81+
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
82+
`

0 commit comments

Comments
 (0)