Skip to content

Commit

Permalink
packagemanifests/icon subresource
Browse files Browse the repository at this point in the history
  • Loading branch information
alecmerdler committed Aug 16, 2019
1 parent 574aecf commit 257f4ae
Show file tree
Hide file tree
Showing 6 changed files with 223 additions and 10 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Operator Package Logos

Status: Pending
Status: In Progress

Version: Alpha

Expand Down
47 changes: 42 additions & 5 deletions pkg/package-server/apis/operators/packagemanifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ func CreateCSVDescription(csv *operatorsv1alpha1.ClusterServiceVersion) CSVDescr
Name: csv.Spec.Provider.Name,
URL: csv.Spec.Provider.URL,
},
Annotations: csv.GetAnnotations(),
LongDescription: csv.Spec.Description,
InstallModes: csv.Spec.InstallModes,
CustomResourceDefinitions: csv.Spec.CustomResourceDefinitions,
APIServiceDefinitions: csv.Spec.APIServiceDefinitions,
Annotations: csv.GetAnnotations(),
LongDescription: csv.Spec.Description,
InstallModes: csv.Spec.InstallModes,
CustomResourceDefinitions: operatorsv1alpha1.CustomResourceDefinitions{
Owned: descriptionsForCRDs(csv.Spec.CustomResourceDefinitions.Owned),
Required: descriptionsForCRDs(csv.Spec.CustomResourceDefinitions.Required),
},
APIServiceDefinitions: operatorsv1alpha1.APIServiceDefinitions{
Owned: descriptionsForAPIServices(csv.Spec.APIServiceDefinitions.Owned),
Required: descriptionsForAPIServices(csv.Spec.APIServiceDefinitions.Required),
},
}

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

return desc
}

// descriptionsForCRDs filters certain fields from provided API descriptions to reduce response size.
func descriptionsForCRDs(crds []operatorsv1alpha1.CRDDescription) []operatorsv1alpha1.CRDDescription {
descriptions := []operatorsv1alpha1.CRDDescription{}
for _, crd := range crds {
descriptions = append(descriptions, operatorsv1alpha1.CRDDescription{
Name: crd.Name,
Version: crd.Version,
Kind: crd.Kind,
DisplayName: crd.DisplayName,
Description: crd.Description,
})
}
return descriptions
}

// descriptionsForAPIServices filters certain fields from provided API descriptions to reduce response size.
func descriptionsForAPIServices(apis []operatorsv1alpha1.APIServiceDescription) []operatorsv1alpha1.APIServiceDescription {
descriptions := []operatorsv1alpha1.APIServiceDescription{}
for _, api := range apis {
descriptions = append(descriptions, operatorsv1alpha1.APIServiceDescription{
Name: api.Name,
Group: api.Group,
Version: api.Version,
Kind: api.Kind,
DisplayName: api.DisplayName,
Description: api.Description,
})
}
return descriptions
}
8 changes: 5 additions & 3 deletions pkg/package-server/apiserver/generic/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,13 @@ type ProviderConfig struct {

// BuildStorage constructs APIGroupInfo for the packages.apps.redhat.com and packages.operators.coreos.com API groups.
func BuildStorage(providers *ProviderConfig) []generic.APIGroupInfo {

// Build storage for packages.operators.coreos.com
operatorInfo := generic.NewDefaultAPIGroupInfo(v1.Group, Scheme, metav1.ParameterCodec, Codecs)
operatorStorage := storage.NewStorage(v1.Resource("packagemanifests"), providers.Provider, Scheme)
iconStorage := storage.NewLogoStorage(v1.Resource("packagemanifests/icon"), providers.Provider)
operatorResources := map[string]rest.Storage{
"packagemanifests": operatorStorage,
"packagemanifests": operatorStorage,
"packagemanifests/icon": iconStorage,
}
operatorInfo.VersionedResourcesStorageMap[v1.Version] = operatorResources

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

// Use storage for package.operators.coreos.com since types are identical
appResources := map[string]rest.Storage{
"packagemanifests": operatorStorage,
"packagemanifests": operatorStorage,
"packagemanifests/icon": iconStorage,
}
appInfo.VersionedResourcesStorageMap[v1alpha1.Version] = appResources

Expand Down
2 changes: 1 addition & 1 deletion pkg/package-server/provider/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ package provider
import "github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/apis/operators"

type PackageManifestProvider interface {
Get(name, namespace string) (*operators.PackageManifest, error)
Get(namespace, name string) (*operators.PackageManifest, error)
List(namespace string) (*operators.PackageManifestList, error)
}
92 changes: 92 additions & 0 deletions pkg/package-server/storage/subresources.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package storage

import (
"context"
"encoding/base64"
"io/ioutil"
"net/http"
"strconv"
"strings"

"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
genericreq "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"

"github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/apis/operators"
"github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/provider"
)

// LogoStorage implements Kubernetes methods needed to provide the `packagemanifests/icon` subresource
type LogoStorage struct {
groupResource schema.GroupResource
prov provider.PackageManifestProvider
}

var _ rest.Connecter = &LogoStorage{}
var _ rest.StorageMetadata = &LogoStorage{}

// NewLogoStorage returns struct which implements Kubernetes methods needed to provide the `packagemanifests/icon` subresource
func NewLogoStorage(groupResource schema.GroupResource, prov provider.PackageManifestProvider) *LogoStorage {
return &LogoStorage{groupResource, prov}
}

// New satisfies the Storage interface
func (s *LogoStorage) New() runtime.Object {
return &operators.PackageManifest{}
}

// Connect satisfies the Connector interface and returns the image icon file for a given `PackageManifest`
func (s *LogoStorage) Connect(ctx context.Context, name string, options runtime.Object, responder rest.Responder) (http.Handler, error) {
var handler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) {
if match := r.Header.Get("If-None-Match"); match != "" && r.URL.Query().Get("resourceVersion") != "" {
w.WriteHeader(http.StatusNotModified)
return
}

namespace := genericreq.NamespaceValue(ctx)
pkg, err := s.prov.Get(namespace, name)
if err != nil || pkg == nil || len(pkg.Status.Channels[0].CurrentCSVDesc.Icon) == 0 {
w.WriteHeader(http.StatusNotFound)
return
}

data := pkg.Status.Channels[0].CurrentCSVDesc.Icon[0].Base64Data
mimeType := pkg.Status.Channels[0].CurrentCSVDesc.Icon[0].Mediatype
etag := `"` + strings.Join([]string{name, pkg.Status.Channels[0].Name, pkg.Status.Channels[0].CurrentCSV}, ".") + `"`

reader := base64.NewDecoder(base64.StdEncoding, strings.NewReader(data))
imgBytes, err := ioutil.ReadAll(reader)

w.Header().Set("Content-Type", mimeType)
w.Header().Set("Content-Length", strconv.Itoa(len(imgBytes)))
w.Header().Set("Etag", etag)
_, err = w.Write(imgBytes)
}

return handler, nil
}

// NewConnectOptions satisfies the Connector interface
func (s *LogoStorage) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, ""
}

// ConnectMethods satisfies the Connector interface
func (s *LogoStorage) ConnectMethods() []string {
return []string{"GET"}
}

// ProducesMIMETypes satisfies the StorageMetadata interface and returns the supported icon image file types
func (s *LogoStorage) ProducesMIMETypes(verb string) []string {
return []string{
"image/png",
"image/jpeg",
"image/svg+xml",
}
}

// ProducesObject satisfies the StorageMetadata interface
func (s *LogoStorage) ProducesObject(verb string) interface{} {
return ""
}
82 changes: 82 additions & 0 deletions pkg/package-server/storage/subresources_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package storage

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/require"

"github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/apis/operators"
v1 "github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/apis/operators/v1"
"github.com/operator-framework/operator-lifecycle-manager/pkg/package-server/provider"
)

type fakeProvider struct{}

var getCalls = 0

func (p *fakeProvider) Get(namespace, name string) (*operators.PackageManifest, error) {
getCalls = getCalls + 1

return &operators.PackageManifest{
Status: operators.PackageManifestStatus{
Channels: []operators.PackageChannel{
{
Name: "stable",
CurrentCSV: "csv-a",
CurrentCSVDesc: operators.CSVDescription{
Icon: []operators.Icon{{Mediatype: "image/png", Base64Data: iconData}},
},
},
},
},
}, nil
}

func (p *fakeProvider) List(namespace string) (*operators.PackageManifestList, error) {
return &operators.PackageManifestList{}, nil
}

var _ provider.PackageManifestProvider = &fakeProvider{}

func TestLogoStorageConnect(t *testing.T) {
provider := fakeProvider{}
ctx, cancel := context.WithCancel(context.TODO())
defer cancel()

storage := NewLogoStorage(v1.Resource("packagemanifests/icon"), &provider)

rr := httptest.NewRecorder()
req, err := http.NewRequest("GET", "", nil)
require.NoError(t, err)

handler, err := storage.Connect(ctx, "pkg-a", nil, nil)
require.NoError(t, err)

handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
require.Equal(t, "image/png", rr.Header().Get("Content-Type"))
require.NotNil(t, rr.Body)

cachedRR := httptest.NewRecorder()
cachedReq, err := http.NewRequest("GET", "?resourceVersion=pkg-a.stable.csv-a", nil)
require.NoError(t, err)

etag := rr.Header().Get("Etag")
require.Equal(t, `"pkg-a.stable.csv-a"`, etag)
cachedReq.Header.Set("If-None-Match", etag)

handler.ServeHTTP(cachedRR, cachedReq)
require.Equal(t, http.StatusNotModified, cachedRR.Code)
require.Equal(t, 1, getCalls, "PackageManifestProvider.Get() should not be called for cached icon")

handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Code)
require.Equal(t, 2, getCalls, "PackageManifestProvider.Get() should be called again to fetch icon")
}

const iconData = `
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
`

0 comments on commit 257f4ae

Please sign in to comment.