diff --git a/Documentation/contributors/design-proposals/operator-logos.md b/Documentation/contributors/design-proposals/operator-logos.md index 01c41b5d48..2e9139fcfb 100644 --- a/Documentation/contributors/design-proposals/operator-logos.md +++ b/Documentation/contributors/design-proposals/operator-logos.md @@ -1,6 +1,6 @@ # Operator Package Logos -Status: Pending +Status: In Progress Version: Alpha diff --git a/pkg/package-server/apis/operators/packagemanifest.go b/pkg/package-server/apis/operators/packagemanifest.go index 734f8ba0c4..56a31bd206 100644 --- a/pkg/package-server/apis/operators/packagemanifest.go +++ b/pkg/package-server/apis/operators/packagemanifest.go @@ -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)) @@ -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 +} diff --git a/pkg/package-server/apiserver/generic/storage.go b/pkg/package-server/apiserver/generic/storage.go index daf1298394..3e3bfa8203 100644 --- a/pkg/package-server/apiserver/generic/storage.go +++ b/pkg/package-server/apiserver/generic/storage.go @@ -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 @@ -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 diff --git a/pkg/package-server/provider/interfaces.go b/pkg/package-server/provider/interfaces.go index 980ac4031b..e4429deece 100644 --- a/pkg/package-server/provider/interfaces.go +++ b/pkg/package-server/provider/interfaces.go @@ -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) } diff --git a/pkg/package-server/storage/subresources.go b/pkg/package-server/storage/subresources.go new file mode 100644 index 0000000000..540776b05e --- /dev/null +++ b/pkg/package-server/storage/subresources.go @@ -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 || 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 "" +} diff --git a/pkg/package-server/storage/subresources_test.go b/pkg/package-server/storage/subresources_test.go new file mode 100644 index 0000000000..bbf702e640 --- /dev/null +++ b/pkg/package-server/storage/subresources_test.go @@ -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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 +`