From 09df3bafc24f82df0ca767fdc9eebf4ea7278b82 Mon Sep 17 00:00:00 2001 From: alecmerdler Date: Wed, 6 Feb 2019 00:01:15 -0500 Subject: [PATCH] improve workflow for installing single-namespace Operators from Marketplace --- frontend/__mocks__/k8sResourcesMocks.ts | 3 + frontend/__mocks__/operatorHubItemsMocks.ts | 6 + .../subscription-channel-modal.spec.tsx | 2 + .../operator-group.spec.tsx | 114 ++++++++- .../package-manifest.spec.tsx | 15 +- frontend/integration-tests/protractor.conf.ts | 3 +- .../tests/olm/catalog.scenario.ts | 24 +- .../tests/olm/etcd.scenario.ts | 23 +- .../tests/olm/prometheus.scenario.ts | 242 ++++++++++++++++++ .../operator-hub/operator-hub.scenario.ts | 72 ++++-- .../views/olm-catalog.view.ts | 2 + .../views/operator-hub.view.ts | 23 +- frontend/public/components/app.jsx | 5 +- .../operator-hub-item-details.tsx | 17 +- .../operator-hub/operator-hub-items.tsx | 6 +- .../operator-hub/operator-hub-page.tsx | 169 +++++------- .../operator-hub/operator-hub-subscribe.tsx | 222 +++++++++++----- .../clusterserviceversion-resource.tsx | 6 +- .../clusterserviceversion.tsx | 6 +- .../operator-lifecycle-manager/index.tsx | 17 +- .../operator-group.tsx | 70 ++++- .../package-manifest.tsx | 37 +-- frontend/public/components/radio.jsx | 37 --- frontend/public/components/radio.tsx | 58 +++++ frontend/public/components/utils/link.jsx | 2 +- frontend/public/components/utils/tooltip.tsx | 5 +- frontend/public/module/k8s/index.ts | 3 +- test-prow-e2e.sh | 3 - 28 files changed, 896 insertions(+), 296 deletions(-) create mode 100644 frontend/integration-tests/tests/olm/prometheus.scenario.ts delete mode 100644 frontend/public/components/radio.jsx create mode 100644 frontend/public/components/radio.tsx diff --git a/frontend/__mocks__/k8sResourcesMocks.ts b/frontend/__mocks__/k8sResourcesMocks.ts index 4129b2eec46..fe89f1135c2 100644 --- a/frontend/__mocks__/k8sResourcesMocks.ts +++ b/frontend/__mocks__/k8sResourcesMocks.ts @@ -58,6 +58,7 @@ export const testClusterServiceVersion: ClusterServiceVersionKind = { 'alm-owner-testapp': 'testapp.clusterserviceversions.operators.coreos.com.v1alpha1', }, }, + installModes: [], install: { strategy: 'Deployment', spec: { @@ -129,6 +130,7 @@ export const localClusterServiceVersion: ClusterServiceVersionKind = { 'alm-owner-local-testapp': 'local-testapp.clusterserviceversions.operators.coreos.com.v1alpha1', }, }, + installModes: [], install: { strategy: 'Deployment', spec: { @@ -268,6 +270,7 @@ export const testPackageManifest: PackageManifestKind = { provider: { name: 'CoreOS, Inc', }, + installModes: [], }, }], defaultChannel: 'alpha', diff --git a/frontend/__mocks__/operatorHubItemsMocks.ts b/frontend/__mocks__/operatorHubItemsMocks.ts index a97146e0059..17169da21ce 100644 --- a/frontend/__mocks__/operatorHubItemsMocks.ts +++ b/frontend/__mocks__/operatorHubItemsMocks.ts @@ -40,6 +40,7 @@ const amqPackageManifest = { provider: { name: 'Red Hat', }, + installModes: [], annotations: { 'alm-examples': '[{"apiVersion":"kafka.strimzi.io/v1alpha1","kind":"Kafka","metadata":{"name":"my-cluster"},"spec":{"kafka":{"replicas":3,"listeners":{"plain":{},"tls":{}},"config":{"offsets.topic.replication.factor":3,"transaction.state.log.replication.factor":3,"transaction.state.log.min.isr":2},"storage":{"type":"ephemeral"}},"zookeeper":{"replicas":3,"storage":{"type":"ephemeral"}},"entityOperator":{"topicOperator":{},"userOperator":{}}}}, {"apiVersion":"kafka.strimzi.io/v1alpha1","kind":"KafkaConnect","metadata":{"name":"my-connect-cluster"},"spec":{"replicas":1,"bootstrapServers":"my-cluster-kafka-bootstrap:9093","tls":{"trustedCertificates":[{"secretName":"my-cluster-cluster-ca-cert","certificate":"ca.crt"}]}}}, {"apiVersion":"kafka.strimzi.io/v1alpha1","kind":"KafkaConnectS2I","metadata":{"name":"my-connect-cluster"},"spec":{"replicas":1,"bootstrapServers":"my-cluster-kafka-bootstrap:9093","tls":{"trustedCertificates":[{"secretName":"my-cluster-cluster-ca-cert","certificate":"ca.crt"}]}}}, {"apiVersion":"kafka.strimzi.io/v1alpha1","kind":"KafkaTopic","metadata":{"name":"my-topic","labels":{"strimzi.io/cluster":"my-cluster"}},"spec":{"partitions":10,"replicas":3,"config":{"retention.ms":604800000,"segment.bytes":1073741824}}}, {"apiVersion":"kafka.strimzi.io/v1alpha1","kind":"KafkaUser","metadata":{"name":"my-user","labels":{"strimzi.io/cluster":"my-cluster"}},"spec":{"authentication":{"type":"tls"},"authorization":{"type":"simple","acls":[{"resource":{"type":"topic","name":"my-topic","patternType":"literal"},"operation":"Read","host":"*"},{"resource":{"type":"topic","name":"my-topic","patternType":"literal"},"operation":"Describe","host":"*"},{"resource":{"type":"group","name":"my-group","patternType":"literal"},"operation":"Read","host":"*"},{"resource":{"type":"topic","name":"my-topic","patternType":"literal"},"operation":"Write","host":"*"},{"resource":{"type":"topic","name":"my-topic","patternType":"literal"},"operation":"Create","host":"*"},{"resource":{"type":"topic","name":"my-topic","patternType":"literal"},"operation":"Describe","host":"*"}]}}}]', description: '**Red Hat AMQ Streams** is a massively scalable, distributed, and high performance data streaming platform based on the Apache Kafka project. \nAMQ Streams provides an event streaming backbone that allows microservices and other application components to exchange data with extremely high throughput and low latency.\n\n**The core capabilities include**\n* A pub/sub messaging model, similar to a traditional enterprise messaging system, in which application components publish and consume events to/from an ordered stream\n* The long term, fault-tolerant storage of events\n* The ability for a consumer to replay streams of events\n* The ability to partition topics for horizontal scalability\n\n# Before you start\n\n1. Create AMQ Streams Cluster Roles\n```\n$ oc apply -f http://amq.io/amqstreams/rbac.yaml\n```\n2. Create following bindings\n```\n$ oc adm policy add-cluster-role-to-user strimzi-cluster-operator -z strimzi-cluster-operator --namespace \n$ oc adm policy add-cluster-role-to-user strimzi-kafka-broker -z strimzi-cluster-operator --namespace \n```', @@ -89,6 +90,7 @@ const etcdPackageManifest = { provider: { name: 'CoreOS, Inc', }, + installModes: [], annotations: { 'alm-examples': '[{"apiVersion":"etcd.database.coreos.com/v1beta2","kind":"EtcdCluster","metadata":{"name":"example","namespace":"default"},"spec":{"size":3,"version":"3.2.13"}},{"apiVersion":"etcd.database.coreos.com/v1beta2","kind":"EtcdRestore","metadata":{"name":"example-etcd-cluster"},"spec":{"etcdCluster":{"name":"example-etcd-cluster"},"backupStorageType":"S3","s3":{"path":"","awsSecret":""}}},{"apiVersion":"etcd.database.coreos.com/v1beta2","kind":"EtcdBackup","metadata":{"name":"example-etcd-cluster-backup"},"spec":{"etcdEndpoints":[""],"storageType":"S3","s3":{"path":"","awsSecret":""}}}]', 'tectonic-visibility': 'ocs', @@ -136,6 +138,7 @@ const federationv2PackageManifest = { provider: { name: 'Red Hat', }, + installModes: [], annotations: { description: 'Kubernetes Federation V2 namespace-scoped installation', categories: '', @@ -184,6 +187,7 @@ const prometheusPackageManifest = { provider: { name: 'Red Hat', }, + installModes: [], annotations: { 'alm-examples': '[{"apiVersion":"monitoring.coreos.com/v1","kind":"Prometheus","metadata":{"name":"example","labels":{"prometheus":"k8s"}},"spec":{"replicas":2,"version":"v2.3.2","serviceAccountName":"prometheus-k8s","securityContext": {}, "serviceMonitorSelector":{"matchExpressions":[{"key":"k8s-app","operator":"Exists"}]},"ruleSelector":{"matchLabels":{"role":"prometheus-rulefiles","prometheus":"k8s"}},"alerting":{"alertmanagers":[{"namespace":"monitoring","name":"alertmanager-main","port":"web"}]}}},{"apiVersion":"monitoring.coreos.com/v1","kind":"ServiceMonitor","metadata":{"name":"example","labels":{"k8s-app":"prometheus"}},"spec":{"selector":{"matchLabels":{"k8s-app":"prometheus"}},"endpoints":[{"port":"web","interval":"30s"}]}},{"apiVersion":"monitoring.coreos.com/v1","kind":"Alertmanager","metadata":{"name":"alertmanager-main"},"spec":{"replicas":3, "securityContext": {}}}]', description: 'The Prometheus Operator for Kubernetes provides easy monitoring definitions for Kubernetes services and deployment and management of Prometheus instances.', @@ -230,6 +234,7 @@ const svcatPackageManifest = { provider: { name: 'Red Hat', }, + installModes: [], annotations: { description: 'Service Catalog lets you provision cloud services directly from the comfort of native Kubernetes tooling.', categories: 'catalog', @@ -276,6 +281,7 @@ const dummyPackageManifest = { provider: { name: 'Dummy', }, + installModes: [], annotations: { description: 'Dummy is not a real operator', categories: 'dummy', diff --git a/frontend/__tests__/components/modals/subscription-channel-modal.spec.tsx b/frontend/__tests__/components/modals/subscription-channel-modal.spec.tsx index 23a44fc3ac7..9ba7a4487c0 100644 --- a/frontend/__tests__/components/modals/subscription-channel-modal.spec.tsx +++ b/frontend/__tests__/components/modals/subscription-channel-modal.spec.tsx @@ -37,6 +37,7 @@ describe(SubscriptionChannelModal.name, () => { provider: { name: 'CoreOS, Inc', }, + installModes: [], }, }, { name: 'nightly', @@ -48,6 +49,7 @@ describe(SubscriptionChannelModal.name, () => { provider: { name: 'CoreOS, Inc', }, + installModes: [], }, }]; diff --git a/frontend/__tests__/components/operator-lifecycle-manager/operator-group.spec.tsx b/frontend/__tests__/components/operator-lifecycle-manager/operator-group.spec.tsx index 29ceab51072..d8181d7dd30 100644 --- a/frontend/__tests__/components/operator-lifecycle-manager/operator-group.spec.tsx +++ b/frontend/__tests__/components/operator-lifecycle-manager/operator-group.spec.tsx @@ -1,10 +1,12 @@ /* eslint-disable no-undef, no-unused-vars */ import * as React from 'react'; +import * as _ from 'lodash-es'; import { shallow } from 'enzyme'; -import { requireOperatorGroup, NoOperatorGroupMsg } from '../../../public/components/operator-lifecycle-manager/operator-group'; -import { testOperatorGroup } from '../../../__mocks__/k8sResourcesMocks'; +import { requireOperatorGroup, NoOperatorGroupMsg, supports, InstallModeSet, InstallModeType, installedFor } from '../../../public/components/operator-lifecycle-manager/operator-group'; +import { OperatorGroupKind, SubscriptionKind } from '../../../public/components/operator-lifecycle-manager'; +import { testOperatorGroup, testSubscription } from '../../../__mocks__/k8sResourcesMocks'; describe('requireOperatorGroup', () => { const SomeComponent = () =>
Requires OperatorGroup
; @@ -33,3 +35,111 @@ describe('requireOperatorGroup', () => { expect(wrapper.find(NoOperatorGroupMsg).exists()).toBe(false); }); }); + +describe('installedFor', () => { + const pkgName = testSubscription.spec.name; + const ns = testSubscription.metadata.namespace; + let subscriptions: SubscriptionKind[]; + let operatorGroups: OperatorGroupKind[]; + + beforeEach(() => { + subscriptions = []; + operatorGroups = []; + }); + + it('returns false if no `Subscriptions` exist for the given package', () => { + subscriptions = [testSubscription]; + operatorGroups = [{...testOperatorGroup, status: {namespaces: [ns], lastUpdated: null}}]; + + expect(installedFor(subscriptions)(operatorGroups)('new-operator')(ns)).toBe(false); + }); + + it('returns false if no `OperatorGroups` target the given namespace', () => { + subscriptions = [testSubscription]; + operatorGroups = [{...testOperatorGroup, status: {namespaces: ['prod-a', 'prod-b'], lastUpdated: null}}]; + + expect(installedFor(subscriptions)(operatorGroups)(pkgName)(ns)).toBe(false); + }); + + it('returns false if checking for `all-namespaces`', () => { + subscriptions = [testSubscription]; + operatorGroups = [{...testOperatorGroup, status: {namespaces: [ns], lastUpdated: null}}]; + + expect(installedFor(subscriptions)(operatorGroups)(pkgName)('')).toBe(false); + }); + + it('returns true if `Subscription` exists in the "global" `OperatorGroup`', () => { + subscriptions = [testSubscription]; + operatorGroups = [{...testOperatorGroup, status: {namespaces: [''], lastUpdated: null}}]; + + expect(installedFor(subscriptions)(operatorGroups)(pkgName)(ns)).toBe(true); + }); + + it('returns true if `Subscription` exists in an `OperatorGroup` that targets given namespace', () => { + subscriptions = [testSubscription]; + operatorGroups = [{...testOperatorGroup, status: {namespaces: [ns], lastUpdated: null}}]; + + expect(installedFor(subscriptions)(operatorGroups)(pkgName)(ns)).toBe(true); + }); +}); + +describe('supports', () => { + let set: InstallModeSet; + let ownNamespaceGroup: OperatorGroupKind; + let singleNamespaceGroup: OperatorGroupKind; + let multiNamespaceGroup: OperatorGroupKind; + let allNamespacesGroup: OperatorGroupKind; + + beforeEach(() => { + ownNamespaceGroup = _.cloneDeep(testOperatorGroup); + ownNamespaceGroup.status = {namespaces: [ownNamespaceGroup.metadata.namespace], lastUpdated: null}; + singleNamespaceGroup = _.cloneDeep(testOperatorGroup); + singleNamespaceGroup.status = {namespaces: ['test-ns'], lastUpdated: null}; + multiNamespaceGroup = _.cloneDeep(testOperatorGroup); + multiNamespaceGroup.status = {namespaces: ['test-ns', 'default'], lastUpdated: null}; + allNamespacesGroup = _.cloneDeep(testOperatorGroup); + allNamespacesGroup.status = {namespaces: [''], lastUpdated: null}; + }); + + it('correctly returns for an Operator that can only run in its own namespace', () => { + set = [ + {type: InstallModeType.InstallModeTypeOwnNamespace, supported: true}, + {type: InstallModeType.InstallModeTypeSingleNamespace, supported: true}, + {type: InstallModeType.InstallModeTypeMultiNamespace, supported: false}, + {type: InstallModeType.InstallModeTypeAllNamespaces, supported: false}, + ]; + + expect(supports(set)(ownNamespaceGroup)).toBe(true); + expect(supports(set)(singleNamespaceGroup)).toBe(true); + expect(supports(set)(multiNamespaceGroup)).toBe(false); + expect(supports(set)(allNamespacesGroup)).toBe(false); + }); + + it('correctly returns for an Operator which can run in several namespaces', () => { + set = [ + {type: InstallModeType.InstallModeTypeOwnNamespace, supported: true}, + {type: InstallModeType.InstallModeTypeSingleNamespace, supported: true}, + {type: InstallModeType.InstallModeTypeMultiNamespace, supported: true}, + {type: InstallModeType.InstallModeTypeAllNamespaces, supported: false}, + ]; + + expect(supports(set)(ownNamespaceGroup)).toBe(true); + expect(supports(set)(singleNamespaceGroup)).toBe(true); + expect(supports(set)(multiNamespaceGroup)).toBe(true); + expect(supports(set)(allNamespacesGroup)).toBe(false); + }); + + it('correctly returns for an Operator which can only run in all namespaces', () => { + set = [ + {type: InstallModeType.InstallModeTypeOwnNamespace, supported: true}, + {type: InstallModeType.InstallModeTypeSingleNamespace, supported: false}, + {type: InstallModeType.InstallModeTypeMultiNamespace, supported: false}, + {type: InstallModeType.InstallModeTypeAllNamespaces, supported: true}, + ]; + + expect(supports(set)(ownNamespaceGroup)).toBe(false); + expect(supports(set)(singleNamespaceGroup)).toBe(false); + expect(supports(set)(multiNamespaceGroup)).toBe(false); + expect(supports(set)(allNamespacesGroup)).toBe(true); + }); +}); diff --git a/frontend/__tests__/components/operator-lifecycle-manager/package-manifest.spec.tsx b/frontend/__tests__/components/operator-lifecycle-manager/package-manifest.spec.tsx index 2a1fce2fdee..22c1eec6fee 100644 --- a/frontend/__tests__/components/operator-lifecycle-manager/package-manifest.spec.tsx +++ b/frontend/__tests__/components/operator-lifecycle-manager/package-manifest.spec.tsx @@ -35,7 +35,7 @@ describe(PackageManifestRow.displayName, () => { let wrapper: ShallowWrapper; beforeEach(() => { - wrapper = shallow(); + wrapper = shallow(); }); it('renders column for package name and logo', () => { @@ -58,11 +58,15 @@ describe(PackageManifestRow.displayName, () => { expect(wrapper.find('.co-resource-list__item').childAt(2).find(Link).at(0).childAt(0).text()).toEqual('View'); }); - it('renders button to create new subscription', () => { - wrapper = wrapper.setProps({subscription: null}); - + it('renders button to create new subscription if `canSubscribe` is true', () => { expect(wrapper.find('.co-resource-list__item').childAt(2).find('button').text()).toEqual('Create Subscription'); }); + + it('does not render button to create new subscription if `canSubscribe` is false', () => { + wrapper = wrapper.setProps({canSubscribe: false}); + + expect(wrapper.find('.co-resource-list__item').childAt(2).find('button').exists()).toBe(false); + }); }); describe(PackageManifestList.displayName, () => { @@ -74,7 +78,7 @@ describe(PackageManifestList.displayName, () => { otherPackageManifest.status.catalogSource = 'another-catalog-source'; otherPackageManifest.status.catalogSourceDisplayName = 'Another Catalog Source'; otherPackageManifest.status.catalogSourcePublisher = 'Some Publisher'; - packages = [testPackageManifest, otherPackageManifest]; + packages = [otherPackageManifest, testPackageManifest]; wrapper = shallow(); }); @@ -83,7 +87,6 @@ describe(PackageManifestList.displayName, () => { expect(wrapper.find('.co-catalogsource-list__section').length).toEqual(2); packages.forEach(({status}, i) => { expect(wrapper.find('.co-catalogsource-list__section').at(i).find('h3').text()).toEqual(status.catalogSourceDisplayName); - expect(wrapper.find('.co-catalogsource-list__section').at(i).find('h3').text()).toEqual(status.catalogSourceDisplayName); }); }); diff --git a/frontend/integration-tests/protractor.conf.ts b/frontend/integration-tests/protractor.conf.ts index d07371bed76..07d898f4bec 100644 --- a/frontend/integration-tests/protractor.conf.ts +++ b/frontend/integration-tests/protractor.conf.ts @@ -89,7 +89,7 @@ export const config: Config = { crud: ['tests/base.scenario.ts', 'tests/crud.scenario.ts', 'tests/secrets.scenario.ts', 'tests/filter.scenario.ts', 'tests/modal-annotations.scenario.ts', 'tests/environment.scenario.ts'], monitoring: ['tests/base.scenario.ts', 'tests/monitoring.scenario.ts'], newApp: ['tests/base.scenario.ts', 'tests/overview/overview.scenario.ts', 'tests/source-to-image.scenario.ts', 'tests/deploy-image.scenario.ts'], - olm: ['tests/base.scenario.ts', 'tests/olm/descriptors.scenario.ts', 'tests/olm/catalog.scenario.ts', 'tests/olm/etcd.scenario.ts'], + olm: ['tests/base.scenario.ts', 'tests/olm/descriptors.scenario.ts', 'tests/olm/catalog.scenario.ts', 'tests/olm/prometheus.scenario.ts', 'tests/olm/etcd.scenario.ts'], olmUpgrade: ['tests/base.scenario.ts', 'tests/olm/update-channel-approval.scenario.ts'], performance: ['tests/base.scenario.ts', 'tests/performance.scenario.ts'], serviceCatalog: ['tests/base.scenario.ts', 'tests/service-catalog/service-catalog.scenario.ts', 'tests/service-catalog/service-broker.scenario.ts', 'tests/service-catalog/service-class.scenario.ts', 'tests/service-catalog/service-binding.scenario.ts', 'tests/developer-catalog.scenario.ts'], @@ -108,6 +108,7 @@ export const config: Config = { 'tests/olm/descriptors.scenario.ts', 'tests/olm/catalog.scenario.ts', 'tests/operator-hub/operator-hub.scenario.ts', + 'tests/olm/prometheus.scenario.ts', 'tests/olm/etcd.scenario.ts'], all: ['tests/base.scenario.ts', 'tests/crud.scenario.ts', diff --git a/frontend/integration-tests/tests/olm/catalog.scenario.ts b/frontend/integration-tests/tests/olm/catalog.scenario.ts index cdc2850c50c..cec5344ffc5 100644 --- a/frontend/integration-tests/tests/olm/catalog.scenario.ts +++ b/frontend/integration-tests/tests/olm/catalog.scenario.ts @@ -9,13 +9,29 @@ import * as sidenavView from '../../views/sidenav.view'; describe('Installing a service from a Catalog Source', () => { const openCloudServices = new Set(['etcd', 'Prometheus Operator', 'AMQ Streams', 'Service Catalog', 'FederationV2']); + const operatorGroupName = 'test-operatorgroup'; beforeAll(async() => { + const catalogSource = { + apiVersion: 'operators.coreos.com/v1alpha1', + kind: 'CatalogSource', + metadata: {name: 'console-e2e'}, + spec: { + sourceType: 'grpc', + image: 'quay.io/operatorframework/operator-manifests@sha256:ac3140d9f2d2a3cf5446d82048ddf64ad5cd13b31070d1c4b5c689b7272062dc', + displayName: 'Console E2E Operators', + publisher: 'Red Hat, Inc', + }, + }; + execSync(`echo '${JSON.stringify(catalogSource)}' | kubectl create -n ${testName} -f -`); + // FIXME(alecmerdler): Wait until `PackageManifests` are being served from registry pod + browser.sleep(30000); + const operatorGroup = { apiVersion: 'operators.coreos.com/v1alpha2', kind: 'OperatorGroup', - metadata: {name: 'test-operatorgroup'}, - spec: {selector: {matchLabels: {'test-name': testName}}}, + metadata: {name: operatorGroupName}, + spec: {targetNamespaces: [testName]}, }; execSync(`echo '${JSON.stringify(operatorGroup)}' | kubectl create -n ${testName} -f -`); @@ -28,6 +44,10 @@ describe('Installing a service from a Catalog Source', () => { checkErrors(); }); + afterAll(() => { + execSync(`kubectl delete operatorgroup -n ${testName} ${operatorGroupName}`); + }); + it('displays `Catalog` tab in navigation sidebar', async() => { await browser.wait(until.presenceOf(sidenavView.navSectionFor('Catalog'))); diff --git a/frontend/integration-tests/tests/olm/etcd.scenario.ts b/frontend/integration-tests/tests/olm/etcd.scenario.ts index e73a1d86593..d4450208088 100644 --- a/frontend/integration-tests/tests/olm/etcd.scenario.ts +++ b/frontend/integration-tests/tests/olm/etcd.scenario.ts @@ -3,6 +3,7 @@ import { browser, $, $$, element, ExpectedConditions as until, by } from 'protractor'; import { safeDump, safeLoad } from 'js-yaml'; import { defaultsDeep } from 'lodash'; +import { execSync } from 'child_process'; import { appHost, testName, checkLogs, checkErrors } from '../../protractor.conf'; import * as crudView from '../../views/crud.view'; @@ -10,7 +11,7 @@ import * as catalogView from '../../views/olm-catalog.view'; import * as sidenavView from '../../views/sidenav.view'; import * as yamlView from '../../views/yaml.view'; -xdescribe('Interacting with the etcd OCS', () => { +describe('Interacting with the etcd Operator (all-namespaces install mode)', () => { const etcdClusterResources = new Set(['Service', 'Pod']); const deleteRecoveryTime = 60000; const etcdOperatorName = 'etcd-operator'; @@ -18,9 +19,17 @@ xdescribe('Interacting with the etcd OCS', () => { const etcdcluster = `${testName}-etcdcluster`; const etcdbackup = `${testName}-etcdbackup`; const etcdrestore = `${testName}-etcdrestore`; + const operatorGroupName = 'test-global-operatorgroup'; beforeAll(async() => { - await browser.get(`${appHost}/status/all-namespaces`); + const operatorGroup = { + apiVersion: 'operators.coreos.com/v1alpha2', + kind: 'OperatorGroup', + metadata: {name: operatorGroupName}, + }; + execSync(`echo '${JSON.stringify(operatorGroup)}' | kubectl create -n ${testName} -f -`); + + await browser.get(`${appHost}/status/ns/${testName}`); await browser.wait(until.presenceOf(sidenavView.navSectionFor('Catalog'))); }); @@ -29,13 +38,19 @@ xdescribe('Interacting with the etcd OCS', () => { checkErrors(); }); + afterAll(() => { + execSync(`kubectl delete operatorgroup -n ${testName} ${operatorGroupName}`); + execSync(`kubectl delete subscriptions -n ${testName} --all`); + execSync(`kubectl delete clusterserviceversions -n ${testName} --all`); + }); + it('can be enabled from the Catalog Source', async() => { await sidenavView.clickNavLink(['Catalog', 'Operator Management']); await catalogView.isLoaded(); await catalogView.createSubscriptionFor('etcd'); await browser.wait(until.presenceOf($('.ace_text-input'))); const content = await yamlView.editorContent.getText(); - const newContent = defaultsDeep({}, {metadata: {generateName: `${testName}-etcd-`, namespace: testName, labels: {[testLabel]: testName}}, spec: {channel: 'alpha', source: 'rh-operators', name: 'etcd'}}, safeLoad(content)); + const newContent = defaultsDeep({}, {metadata: {generateName: `${testName}-etcd-`, namespace: testName, labels: {[testLabel]: testName}}}, safeLoad(content)); await yamlView.setContent(safeDump(newContent)); await $('#save-changes').click(); await crudView.isLoaded(); @@ -66,7 +81,7 @@ xdescribe('Interacting with the etcd OCS', () => { await crudView.isLoaded(); await browser.sleep(500); - browser.wait(until.visibilityOf(crudView.rowForOperator('etcd')), 5000); + await browser.wait(until.visibilityOf(crudView.rowForOperator('etcd')), 5000); }); it('displays metadata about etcd OCS in the "Overview" section', async() => { diff --git a/frontend/integration-tests/tests/olm/prometheus.scenario.ts b/frontend/integration-tests/tests/olm/prometheus.scenario.ts new file mode 100644 index 00000000000..74002e12a79 --- /dev/null +++ b/frontend/integration-tests/tests/olm/prometheus.scenario.ts @@ -0,0 +1,242 @@ +/* eslint-disable no-undef, no-unused-vars */ + +import { browser, $, $$, element, ExpectedConditions as until, by } from 'protractor'; +import { defaultsDeep } from 'lodash'; +import { safeDump, safeLoad } from 'js-yaml'; +import { execSync } from 'child_process'; + +import { appHost, testName, checkLogs, checkErrors } from '../../protractor.conf'; +import * as crudView from '../../views/crud.view'; +import * as catalogView from '../../views/olm-catalog.view'; +import * as sidenavView from '../../views/sidenav.view'; +import * as yamlView from '../../views/yaml.view'; + +describe('Interacting with the Prometheus Operator (single-namespace install mode)', () => { + const prometheusResources = new Set(['StatefulSet', 'Pod']); + const alertmanagerResources = new Set(['StatefulSet', 'Pod']); + const serviceMonitorResources = new Set(['Pod']); + const deleteRecoveryTime = 60000; + const prometheusOperatorName = 'prometheus-operator'; + const testLabel = 'automatedTestName'; + const operatorGroupName = 'test-single-operatorgroup'; + + beforeAll(async() => { + const operatorGroup = { + apiVersion: 'operators.coreos.com/v1alpha2', + kind: 'OperatorGroup', + metadata: {name: operatorGroupName}, + spec: {targetNamespaces: [testName]}, + }; + execSync(`echo '${JSON.stringify(operatorGroup)}' | kubectl create -n ${testName} -f -`); + + await browser.get(`${appHost}/status/ns/${testName}`); + await browser.wait(until.presenceOf(sidenavView.navSectionFor('Catalog'))); + }); + + afterEach(() => { + checkLogs(); + checkErrors(); + }); + + afterAll(() => { + execSync(`kubectl delete operatorgroup -n ${testName} ${operatorGroupName}`); + execSync(`kubectl delete subscriptions -n ${testName} --all`); + execSync(`kubectl delete clusterserviceversions -n ${testName} --all`); + }); + + it('can be enabled from the Catalog Source', async() => { + await sidenavView.clickNavLink(['Catalog', 'Operator Management']); + await catalogView.isLoaded(); + await catalogView.createSubscriptionFor('Prometheus'); + await browser.wait(until.presenceOf($('.ace_text-input'))); + const content = await yamlView.editorContent.getText(); + const newContent = defaultsDeep({}, {metadata: {generateName: `${testName}-prometheus-`, namespace: testName, labels: {[testLabel]: testName}}}, safeLoad(content)); + await yamlView.setContent(safeDump(newContent)); + await $('#save-changes').click(); + await crudView.isLoaded(); + await sidenavView.clickNavLink(['Catalog', 'Operator Management']); + await catalogView.isLoaded(); + + expect(catalogView.hasSubscription('Prometheus')).toBe(true); + }); + + it('creates Prometheus Operator `Deployment`', async() => { + await browser.get(`${appHost}/k8s/ns/${testName}/deployments`); + await crudView.isLoaded(); + await browser.wait(until.textToBePresentInElement(crudView.rowForName(prometheusOperatorName).$('a[title=pods]'), '1 of 1 pods')); + + expect(crudView.rowForName(prometheusOperatorName).isDisplayed()).toBe(true); + expect(crudView.labelsForRow(prometheusOperatorName).filter(l => l.getText().then(t => t === `olm.owner=${prometheusOperatorName}`)).first()).toBeDefined(); + expect(crudView.labelsForRow(prometheusOperatorName).filter(l => l.getText().then(t => t === `olm.owner.namespace=${testName}`)).first()).toBeDefined(); + }); + + xit('recreates Prometheus Operator `Deployment` if manually deleted', async() => { + await crudView.deleteRow('Deployment')(prometheusOperatorName); + await browser.wait(until.textToBePresentInElement(crudView.rowForName(prometheusOperatorName).$('a[title=pods]'), '0 of 1 pods')); + await browser.wait(until.textToBePresentInElement(crudView.rowForName(prometheusOperatorName).$('a[title=pods]'), '1 of 1 pods')); + + expect(crudView.rowForName(prometheusOperatorName).isDisplayed()).toBe(true); + }, deleteRecoveryTime); + + it('displays Prometheus OCS in "Cluster Service Versions" view for the namespace', async() => { + await browser.get(`${appHost}/k8s/ns/${testName}/clusterserviceversions`); + await crudView.isLoaded(); + await browser.sleep(500); + + await browser.wait(until.visibilityOf(crudView.rowForOperator('Prometheus Operator')), 5000); + }); + + it('displays metadata about Prometheus OCS in the "Overview" section', async() => { + await crudView.rowForOperator('Prometheus Operator').$('.co-clusterserviceversion-logo').click(); + await browser.wait(until.presenceOf($('.loading-box__loaded')), 5000); + + expect($('.co-m-pane__details').isDisplayed()).toBe(true); + }); + + it('displays empty message in the "All Instances" section', async() => { + await element(by.linkText('All Instances')).click(); + await crudView.isLoaded(); + + expect(crudView.rowFilterFor('Prometheus').isDisplayed()).toBe(true); + expect(crudView.rowFilterFor('Alertmanager').isDisplayed()).toBe(true); + expect(crudView.rowFilterFor('ServiceMonitor').isDisplayed()).toBe(true); + expect(crudView.rowFilterFor('PrometheusRule').isDisplayed()).toBe(true); + expect(crudView.statusMessageTitle.getText()).toEqual('No Application Resources Found'); + expect(crudView.statusMessageDetail.getText()).toEqual('Application resources are declarative components used to define the behavior of the application.'); + }); + + it('displays YAML editor for creating a new `Prometheus` instance', async() => { + await browser.wait(until.visibilityOf(element(by.buttonText('Create New')))); + await element(by.buttonText('Create New')).click(); + await browser.wait(until.visibilityOf($$('.dropdown-menu').first()), 1000); + await $$('.dropdown-menu').first().element(by.linkText('Prometheus')).click(); + await browser.wait(until.presenceOf($('.ace_text-input'))); + + expect($('.yaml-editor__header').getText()).toContain('Create Prometheus'); + }); + + it('displays new `Prometheus` that was created from YAML editor', async() => { + await $('#save-changes').click(); + await crudView.isLoaded(); + await browser.wait(until.visibilityOf(crudView.rowForName('example'))); + + expect(crudView.rowForName('example').getText()).toContain('Prometheus'); + }); + + it('displays metadata about the created `Prometheus` in its "Overview" section', async() => { + await crudView.rowForName('example').element(by.linkText('example')).click(); + await browser.wait(until.presenceOf($('.loading-box__loaded')), 5000); + + expect($('.co-clusterserviceversion-resource-details__section--info').isDisplayed()).toBe(true); + }); + + it('displays the raw YAML for the `Prometheus`', async() => { + await element(by.linkText('YAML')).click(); + await browser.wait(until.presenceOf($('.yaml-editor__buttons'))); + await $('.yaml-editor__buttons').element(by.buttonText('Save')).click(); + await browser.wait(until.visibilityOf($('.alert-success')), 1000); + + expect(crudView.successMessage.getText()).toContain('example has been updated to version'); + }); + + it('displays Kubernetes objects associated with the `Prometheus` in its "Resources" section', async() => { + await element(by.linkText('Resources')).click(); + await crudView.isLoaded(); + + prometheusResources.forEach(kind => { + expect(crudView.rowFilterFor(kind).isDisplayed()).toBe(true); + }); + }); + + it('displays YAML editor for creating a new `Alertmanager` instance', async() => { + await $$('.breadcrumb-link').first().click(); + await crudView.isLoaded(); + await element(by.linkText('All Instances')).click(); + await browser.wait(until.visibilityOf(element(by.buttonText('Create New')))); + await element(by.buttonText('Create New')).click(); + await browser.wait(until.visibilityOf($$('.dropdown-menu').first()), 1000); + await $$('.dropdown-menu').first().element(by.linkText('Alertmanager')).click(); + await browser.wait(until.presenceOf($('.ace_text-input'))); + + expect($('.yaml-editor__header').getText()).toContain('Create Alertmanager'); + }); + + it('displays new `Alertmanager` that was created from YAML editor', async() => { + await $('#save-changes').click(); + await crudView.isLoaded(); + await browser.wait(until.visibilityOf(crudView.rowForName('alertmanager-main'))); + + expect(crudView.rowForName('alertmanager-main').getText()).toContain('Alertmanager'); + }); + + it('displays metadata about the created `Alertmanager` in its "Overview" section', async() => { + await crudView.rowForName('alertmanager-main').element(by.linkText('alertmanager-main')).click(); + await browser.wait(until.presenceOf($('.loading-box__loaded')), 5000); + + expect($('.co-clusterserviceversion-resource-details__section--info').isDisplayed()).toBe(true); + }); + + it('displays the raw YAML for the `Alertmanager`', async() => { + await element(by.linkText('YAML')).click(); + await browser.wait(until.presenceOf($('.yaml-editor__buttons'))); + await $('.yaml-editor__buttons').element(by.buttonText('Save')).click(); + await browser.wait(until.visibilityOf(crudView.successMessage), 1000); + + expect(crudView.successMessage.getText()).toContain('alertmanager-main has been updated to version'); + }); + + it('displays Kubernetes objects associated with the `Alertmanager` in its "Resources" section', async() => { + await element(by.linkText('Resources')).click(); + await crudView.isLoaded(); + + alertmanagerResources.forEach(kind => { + expect(crudView.rowFilterFor(kind).isDisplayed()).toBe(true); + }); + }); + + it('displays YAML editor for creating a new `ServiceMonitor` instance', async() => { + await $$('.breadcrumb-link').first().click(); + await crudView.isLoaded(); + await element(by.linkText('All Instances')).click(); + await browser.wait(until.visibilityOf(element(by.buttonText('Create New')))); + await element(by.buttonText('Create New')).click(); + await browser.wait(until.visibilityOf($$('.dropdown-menu').first()), 1000); + await $$('.dropdown-menu').first().element(by.linkText('Service Monitor')).click(); + await browser.wait(until.presenceOf($('.ace_text-input')), 10000); + + expect($('.yaml-editor__header').getText()).toContain('Create Service Monitor'); + }); + + it('displays new `ServiceMonitor` that was created from YAML editor', async() => { + await $('#save-changes').click(); + await crudView.isLoaded(); + await browser.wait(until.visibilityOf(crudView.rowForName('example'))); + + expect(crudView.rowForName('example').getText()).toContain('ServiceMonitor'); + }); + + it('displays metadata about the created `ServiceMonitor` in its "Overview" section', async() => { + await crudView.rowForName('example').element(by.linkText('example')).click(); + await browser.wait(until.presenceOf($('.loading-box__loaded')), 5000); + + expect($('.co-clusterserviceversion-resource-details__section--info').isDisplayed()).toBe(true); + }); + + it('displays the raw YAML for the `ServiceMonitor`', async() => { + await element(by.linkText('YAML')).click(); + await browser.wait(until.presenceOf($('.yaml-editor__buttons'))); + await $('.yaml-editor__buttons').element(by.buttonText('Save')).click(); + await browser.wait(until.visibilityOf(crudView.successMessage), 1000); + + expect(crudView.successMessage.getText()).toContain('example has been updated to version'); + }); + + it('displays Kubernetes objects associated with the `ServiceMonitor` in its "Resources" section', async() => { + await element(by.linkText('Resources')).click(); + await crudView.isLoaded(); + + serviceMonitorResources.forEach(kind => { + expect(crudView.rowFilterFor(kind).isDisplayed()).toBe(true); + }); + }); +}); diff --git a/frontend/integration-tests/tests/operator-hub/operator-hub.scenario.ts b/frontend/integration-tests/tests/operator-hub/operator-hub.scenario.ts index db2a8292f85..bcd0b7bdb4b 100644 --- a/frontend/integration-tests/tests/operator-hub/operator-hub.scenario.ts +++ b/frontend/integration-tests/tests/operator-hub/operator-hub.scenario.ts @@ -1,19 +1,22 @@ /* eslint-disable no-undef, no-unused-vars */ -import { browser } from 'protractor'; +import { browser, $, ExpectedConditions as until } from 'protractor'; +import { execSync } from 'child_process'; -import { appHost, checkLogs, checkErrors } from '../../protractor.conf'; +import { appHost, checkLogs, checkErrors, testName } from '../../protractor.conf'; import * as crudView from '../../views/crud.view'; import * as catalogView from '../../views/catalog.view'; import * as catalogPageView from '../../views/catalog-page.view'; import * as operatorHubView from '../../views/operator-hub.view'; -describe('Viewing the operators in Operator Hub', () => { +describe('Subscribing to an Operator from Operator Hub', () => { const openCloudServices = new Set(['AMQ Streams', 'MongoDB']); - beforeEach(async() => { - await browser.get(`${appHost}/operatorhub`); - await crudView.isLoaded(); + afterAll(() => { + // FIXME: Don't hardcode namespace for running tests against upstream k8s + execSync('kubectl delete catalogsourceconfig -n openshift-marketplace installed-community-openshift-operators'); + execSync('kubectl delete subscription -n openshift-operators --all'); + execSync('kubectl delete clusterserviceversion -n openshift-operators --all'); }); afterEach(() => { @@ -22,6 +25,8 @@ describe('Viewing the operators in Operator Hub', () => { }); it('displays Operator Hub with expected available operators', async() => { + await browser.get(`${appHost}/operatorhub`); + await crudView.isLoaded(); openCloudServices.forEach(name => { expect(catalogPageView.catalogTileFor(name).isDisplayed()).toBe(true); @@ -73,7 +78,7 @@ describe('Viewing the operators in Operator Hub', () => { openCloudServices.forEach(name => { it(`displays OperatorHubModalOverlay with correct content when ${name} operator is clicked`, async() => { - await(catalogPageView.catalogTileFor(name).click()); + await catalogPageView.catalogTileFor(name).click(); await operatorHubView.operatorModalIsLoaded(); expect(operatorHubView.operatorModal.isDisplayed()).toBe(true); @@ -84,26 +89,15 @@ describe('Viewing the operators in Operator Hub', () => { }); }); - it('shows the warning dialog when "Show Community Operators" is clicked', async() => { - await(operatorHubView.clickShowCommunityToggle()); - await operatorHubView.operatorCommunityWarningIsLoaded(); - await operatorHubView.closeCommunityWarningModal(); - await operatorHubView.operatorCommunityWarningIsClosed(); + it('hides community Operators when "Show Community Operators" is not accepted', async() => { + expect(catalogPageView.catalogTileCount('etcd')).toBe(0); }); it('shows community operators when "Show Community Operators" is accepted', async() => { - await(operatorHubView.clickShowCommunityToggle()); - await operatorHubView.operatorCommunityWarningIsLoaded(); - await operatorHubView.acceptCommunityWarningModal(); - await operatorHubView.operatorCommunityWarningIsClosed(); - + await operatorHubView.showCommunityOperators(); await catalogPageView.clickFilterCheckbox('Community'); - expect(catalogPageView.catalogTileCount('etcd')).toBe(1); - await catalogPageView.clickFilterCheckbox('Community'); - - await(operatorHubView.clickShowCommunityToggle()); - expect(catalogPageView.catalogTileCount('etcd')).toBe(0); + expect(catalogPageView.catalogTileCount('etcd')).toBe(1); }); it('filters Operator Hub tiles by Category', async() => { @@ -111,4 +105,38 @@ describe('Viewing the operators in Operator Hub', () => { expect(catalogView.categoryTabs.isPresent()).toBe(true); }); + it('displays subscription creation form for selected Operator', async() => { + await catalogPageView.catalogTileFor('etcd').click(); + await operatorHubView.operatorModalIsLoaded(); + await operatorHubView.operatorModalInstallBtn.click(); + + expect(browser.getCurrentUrl()).toContain('/operatorhub/subscribe?pkg=etcd&catalog=community-operators&catalogNamespace=openshift-marketplace&targetNamespace='); + expect(operatorHubView.createSubscriptionFormTitle.isDisplayed()).toBe(true); + }); + + it('selects target namespace for Operator subscription', async() => { + await browser.wait(until.visibilityOf(operatorHubView.createSubscriptionFormInstallMode)); + + expect($('input[value="AllNamespaces"]').getAttribute('disabled')).toBe(null); + }); + + it('displays Operator as subscribed in Operator Hub', async() => { + await operatorHubView.createSubscriptionFormBtn.click(); + await crudView.isLoaded(); + await operatorHubView.showCommunityOperators(); + + expect(catalogPageView.catalogTileFor('etcd').$('.catalog-tile-pf-footer').getText()).toContain('Installed'); + }); + + it('displays Operator in "Cluster Service Versions" view for "default" namespace', async() => { + await browser.get(`${appHost}/operatorhub/ns/${testName}`); + await crudView.isLoaded(); + await operatorHubView.showCommunityOperators(); + await catalogPageView.catalogTileFor('etcd').click(); + await operatorHubView.operatorModalIsLoaded(); + await operatorHubView.viewInstalledOperator(); + await crudView.isLoaded(); + + await browser.wait(until.visibilityOf(crudView.rowForOperator('etcd')), 30000); + }); }); diff --git a/frontend/integration-tests/views/olm-catalog.view.ts b/frontend/integration-tests/views/olm-catalog.view.ts index b282788c187..4a5d186a2ed 100644 --- a/frontend/integration-tests/views/olm-catalog.view.ts +++ b/frontend/integration-tests/views/olm-catalog.view.ts @@ -17,6 +17,8 @@ export const hasSubscription = (name: string) => browser.getCurrentUrl().then(ur return entryRowFor(name).element(by.buttonText('Create Subscription')).isPresent(); }).then(canSubscribe => !canSubscribe); +export const viewSubscriptionsFor = (name: string) => entryRowFor(name).element(by.partialButtonText('View subscription')).click(); + export const viewCatalogDetail = (name: string) => $$('.co-catalogsource-list__section').filter(section => section.$('h3').getText() .then(text => text === name)).first().element(by.linkText('View catalog details')) .click(); diff --git a/frontend/integration-tests/views/operator-hub.view.ts b/frontend/integration-tests/views/operator-hub.view.ts index 201da110296..08433561358 100644 --- a/frontend/integration-tests/views/operator-hub.view.ts +++ b/frontend/integration-tests/views/operator-hub.view.ts @@ -1,21 +1,24 @@ /* eslint-disable no-undef, no-unused-vars */ -import {browser, $, ExpectedConditions as until, $$} from 'protractor'; +import { browser, $, ExpectedConditions as until, $$, by, element } from 'protractor'; -// Modal views export const operatorModal = $('.modal-content'); export const operatorModalIsLoaded = () => browser.wait(until.presenceOf(operatorModal), 1000) .then(() => browser.sleep(500)); export const operatorModalTitle = operatorModal.$('.catalog-item-header-pf-title'); +export const operatorModalInstallBtn = operatorModal.element(by.buttonText('Install')); export const closeOperatorModal = () => operatorModal.$('.close').click(); export const operatorModalIsClosed = () => browser.wait(until.not(until.presenceOf(operatorModal)), 1000) .then(() => browser.sleep(500)); +export const viewInstalledOperator = () => $('.hint-block-pf').element(by.linkText('View it here.')).click(); -export const communityWarningModal = $('.co-modal-ignore-warning'); -export const clickShowCommunityToggle = () => $$('.co-catalog-page__filter-toggle').click(); -export const operatorCommunityWarningIsLoaded = () => browser.wait(until.presenceOf(communityWarningModal), 1000) - .then(() => browser.sleep(500)); -export const operatorCommunityWarningIsClosed = () => browser.wait(until.not(until.presenceOf(communityWarningModal)), 1000) - .then(() => browser.sleep(500)); -export const closeCommunityWarningModal = () => communityWarningModal.$('.btn-default').click(); -export const acceptCommunityWarningModal = () => communityWarningModal.$('.btn-primary').click(); +export const createSubscriptionFormTitle = element(by.cssContainingText('h1', 'Create Operator Subscription')); +export const createSubscriptionFormBtn = element(by.buttonText('Subscribe')); +export const createSubscriptionFormInstallMode = element(by.cssContainingText('label', 'Installation Mode')); + +export const showCommunityOperators = async() => { + await $$('.co-catalog-page__filter-toggle').click(); + await browser.wait(until.presenceOf($('.co-modal-ignore-warning')), 1000).then(() => browser.sleep(500)); + await $('.co-modal-ignore-warning').$('.btn-primary').click(); + await browser.wait(until.not(until.presenceOf($('.co-modal-ignore-warning'))), 1000).then(() => browser.sleep(500)); +}; diff --git a/frontend/public/components/app.jsx b/frontend/public/components/app.jsx index 8487a32c3fb..4e9752f39ed 100644 --- a/frontend/public/components/app.jsx +++ b/frontend/public/components/app.jsx @@ -193,8 +193,11 @@ class App extends React.PureComponent { import('./cluster-health' /* webpackChunkName: "cluster-health" */).then(m => m.ClusterHealth)} /> import('./start-guide' /* webpackChunkName: "start-guide" */).then(m => m.StartGuidePage)} /> - import('./operator-hub/operator-hub-page' /* webpackChunkName: "operator-hub" */).then(m => m.OperatorHubPage)} /> + import('./operator-hub/operator-hub-page' /* webpackChunkName: "operator-hub" */).then(m => m.OperatorHubPage)} /> + import('./operator-hub/operator-hub-page' /* webpackChunkName: "operator-hub" */).then(m => m.OperatorHubPage)} /> + import('./operator-hub/operator-hub-subscribe' /* webpackChunkName: "operator-hub-subscribe" */).then(m => m.OperatorHubSubscribePage)} /> + import('./catalog/catalog-page' /* webpackChunkName: "catalog" */).then(m => m.CatalogPage)} /> import('./catalog/catalog-page' /* webpackChunkName: "catalog" */).then(m => m.CatalogPage)} /> diff --git a/frontend/public/components/operator-hub/operator-hub-item-details.tsx b/frontend/public/components/operator-hub/operator-hub-item-details.tsx index 7aed13c87d7..c0fd3980c16 100644 --- a/frontend/public/components/operator-hub/operator-hub-item-details.tsx +++ b/frontend/public/components/operator-hub/operator-hub-item-details.tsx @@ -7,8 +7,9 @@ import { CatalogItemHeader, PropertiesSidePanel, PropertyItem } from 'patternfly import { MarkdownView } from '../operator-lifecycle-manager/clusterserviceversion'; import { history, ExternalLink } from '../utils'; import { RH_OPERATOR_SUPPORT_POLICY_LINK } from '../../const'; +import { Link } from 'react-router-dom'; -export const OperatorHubItemDetails: React.SFC = ({item, closeOverlay}) => { +export const OperatorHubItemDetails: React.SFC = ({item, closeOverlay, namespace}) => { if (!item) { return null; } @@ -38,7 +39,7 @@ export const OperatorHubItemDetails: React.SFC = ({ title="Installed Operator" body={ - This Operator has been installed on the cluster. + This Operator has been installed on the cluster. View it here. } /> @@ -68,14 +69,7 @@ export const OperatorHubItemDetails: React.SFC = ({ return null; }; - const onActionClick = () => { - if (!installed) { - history.push(`/operatorhub/subscribe?pkg=${item.obj.metadata.name}&catalog=${catalogSource}&catalogNamespace=${catalogSourceNamespace}`); - return; - } - - // TODO: Allow for Manage button to navigate to the CSV details for the item - }; + const createLink = `/operatorhub/subscribe?pkg=${item.obj.metadata.name}&catalog=${catalogSource}&catalogNamespace=${catalogSourceNamespace}&targetNamespace=${namespace}`; return @@ -97,7 +91,7 @@ export const OperatorHubItemDetails: React.SFC = ({ className="co-catalog-page__overlay-create" disabled={installed} title={installed ? 'This Operator has been installed on the cluster.' : null} - onClick={onActionClick}> + onClick={() => !installed ? history.push(createLink) : null}> Install @@ -126,6 +120,7 @@ OperatorHubItemDetails.defaultProps = { }; export type OperatorHubItemDetailsProps = { + namespace?: string; item: any; closeOverlay: () => void; }; diff --git a/frontend/public/components/operator-hub/operator-hub-items.tsx b/frontend/public/components/operator-hub/operator-hub-items.tsx index d52126107b9..8ae7d3b11cd 100644 --- a/frontend/public/components/operator-hub/operator-hub-items.tsx +++ b/frontend/public/components/operator-hub/operator-hub-items.tsx @@ -335,6 +335,7 @@ export const OperatorHubTileView = requireOperatorGroup( const { uid, name, imgUrl, iconClass, provider, description, installed } = item; const normalizedIconClass = iconClass && `icon ${normalizeIconClass(iconClass)}`; const vendor = provider ? `provided by ${provider}` : null; + return ( - - {detailsItem && } + + {detailsItem && } ; } @@ -380,6 +381,7 @@ OperatorHubTileView.propTypes = { }; export type OperatorHubTileViewProps = { + namespace?: string; items: any[]; catalogSourceConfig: K8sResourceKind; }; diff --git a/frontend/public/components/operator-hub/operator-hub-page.tsx b/frontend/public/components/operator-hub/operator-hub-page.tsx index 167a24e13e9..fc7cbb87ee8 100644 --- a/frontend/public/components/operator-hub/operator-hub-page.tsx +++ b/frontend/public/components/operator-hub/operator-hub-page.tsx @@ -2,44 +2,45 @@ import * as React from 'react'; import * as _ from 'lodash-es'; -import {Helmet} from 'react-helmet'; +import { Helmet } from 'react-helmet'; +import { match } from 'react-router'; -import { Firehose, PageHeading, StatusBox, MsgBox, ExternalLink } from '../utils'; +import { Firehose, PageHeading, StatusBox, MsgBox, ExternalLink, withFallback } from '../utils'; +import { ErrorBoundaryFallback } from '../error'; import { referenceForModel, K8sResourceKind } from '../../module/k8s'; import { PackageManifestModel, OperatorGroupModel, CatalogSourceConfigModel, SubscriptionModel } from '../../models'; import { getOperatorProviderType } from './operator-hub-utils'; import { OperatorHubTileView } from './operator-hub-items'; import { PackageManifestKind, OperatorGroupKind, SubscriptionKind } from '../operator-lifecycle-manager'; +import { installedFor } from '../operator-lifecycle-manager/operator-group'; import { OPERATOR_HUB_CSC_BASE } from '../../const'; import * as operatorImg from '../../imgs/operator.svg'; -const normalizePackageManifests = (packageManifests: PackageManifestKind[] = [], subscriptions: SubscriptionKind[]) => { - const activePackageManifests = _.filter(packageManifests, packageManifest => { - return !packageManifest.status.removedFromBrokerCatalog; - }); - return _.map(activePackageManifests, packageManifest => { - const currentCSVDesc = _.get(packageManifest, 'status.channels[0].currentCSVDesc', {}); +export const OperatorHubList: React.SFC = (props) => { + const {catalogSourceConfig, operatorGroup, subscription, loaded, loadError, namespace = ''} = props; + const sourceConfigs = _.find(_.get(catalogSourceConfig, 'data'), csc => _.startsWith(csc.metadata.name, OPERATOR_HUB_CSC_BASE)); + const items = _.get(props.packageManifest, 'data', [] as PackageManifestKind[]).map(pkg => { + const currentCSVDesc = _.get(pkg, 'status.channels[0].currentCSVDesc', {}); const currentCSVAnnotations = _.get(currentCSVDesc, 'annotations', {}); const iconObj = _.get(currentCSVDesc, 'icon[0]'); - const installed = (subscriptions || []).some(sub => sub.spec.name === _.get(packageManifest, 'status.packageName')); return { - obj: packageManifest, + obj: pkg, kind: PackageManifestModel.kind, - name: _.get(currentCSVDesc, 'displayName', packageManifest.metadata.name), - uid: `${packageManifest.metadata.name}/${packageManifest.status.catalogSourceNamespace}`, - installed, - installState: installed ? 'Installed' : 'Not Installed', + name: _.get(currentCSVDesc, 'displayName', pkg.metadata.name), + uid: `${pkg.metadata.name}/${pkg.status.catalogSourceNamespace}`, + installed: installedFor(subscription.data)(operatorGroup.data)(pkg.status.packageName)(namespace), + installState: installedFor(subscription.data)(operatorGroup.data)(pkg.status.packageName)(namespace) ? 'Installed' : 'Not Installed', imgUrl: iconObj ? `data:${iconObj.mediatype};base64,${iconObj.base64data}` : operatorImg, description: currentCSVAnnotations.description || currentCSVDesc.description, longDescription: currentCSVDesc.description || currentCSVAnnotations.description, - provider: _.get(packageManifest, 'metadata.labels.provider'), - providerType: getOperatorProviderType(packageManifest), - tags: packageManifest.metadata.tags, + provider: _.get(pkg, 'metadata.labels.provider'), + providerType: getOperatorProviderType(pkg), + tags: pkg.metadata.tags, version: _.get(currentCSVDesc, 'version'), categories: currentCSVAnnotations.categories && _.map(currentCSVAnnotations.categories.split(','), category => category.trim()), - catalogSource: _.get(packageManifest, 'status.catalogSource'), - catalogSourceNamespace: _.get(packageManifest, 'status.catalogSourceNamespace'), + catalogSource: _.get(pkg, 'status.catalogSource'), + catalogSourceNamespace: _.get(pkg, 'status.catalogSourceNamespace'), ..._.pick(currentCSVAnnotations, [ 'certifiedLevel', 'healthIndex', @@ -50,95 +51,62 @@ const normalizePackageManifests = (packageManifests: PackageManifestKind[] = [], ]), }; }); -}; - -export class OperatorHubList extends React.Component { - state = {items: []}; - - componentDidMount() { - const {packageManifest, subscription, loaded} = this.props; - - if (loaded) { - const items = _.sortBy(normalizePackageManifests(_.get(packageManifest, 'data'), subscription.data), 'name'); - this.setState({items}); - } - } - - componentDidUpdate(prevProps: OperatorHubListProps) { - const {packageManifest, subscription, loaded} = this.props; - if (loaded && !prevProps.loaded || - !_.isEqual(subscription.data, prevProps.subscription.data) || - !_.isEqual(_.get(packageManifest, 'data'), _.get(prevProps.packageManifest, 'data'))) { - const items = _.sortBy(normalizePackageManifests(_.get(packageManifest, 'data'), subscription.data), 'name'); - this.setState({items}); - } - } - - render() { - const {catalogSourceConfig, loaded, loadError} = this.props; - const {items} = this.state; - const sourceConfigs = _.find(_.get(catalogSourceConfig, 'data'), csc => _.startsWith(csc.metadata.name, OPERATOR_HUB_CSC_BASE)); - - return ( - ( - Please check that the OperatorHub is running and that you have created a valid OperatorSource. For more information about Operator Hub, please click .} - /> - )}> - - - ); - } -} + return ( + Please check that the OperatorHub is running and that you have created a valid OperatorSource. For more information about Operator Hub, please click .} + /> + )}> + + ; +}; -export const OperatorHubPage: React.SFC = (props) => { - return - - Operator Hub - -
- -
- - {/* FIXME(alecmerdler): Hack because `Firehose` injects props without TypeScript knowing about it */} - - -
+export const OperatorHubPage = withFallback((props: OperatorHubPageProps) => + + Operator Hub + +
+ +
+ + {/* FIXME(alecmerdler): Hack because `Firehose` injects props without TypeScript knowing about it */} + +
- ; -}; +
+
, ErrorBoundaryFallback); export type OperatorHubPageProps = { - + match: match<{ns?: string}>; }; export type OperatorHubListProps = { + namespace?: string; catalogSourceConfig: {loaded: boolean, data?: K8sResourceKind[]}; operatorGroup: {loaded: boolean, data?: OperatorGroupKind[]}; packageManifest: {loaded: boolean, data?: PackageManifestKind[]}; @@ -151,4 +119,5 @@ export type OperatorHubListState = { items: any[]; }; +OperatorHubList.displayName = 'OperatorHubList'; OperatorHubPage.displayName = 'OperatorHubPage'; diff --git a/frontend/public/components/operator-hub/operator-hub-subscribe.tsx b/frontend/public/components/operator-hub/operator-hub-subscribe.tsx index ffa8107ddc4..315d7eebca9 100644 --- a/frontend/public/components/operator-hub/operator-hub-subscribe.tsx +++ b/frontend/public/components/operator-hub/operator-hub-subscribe.tsx @@ -3,37 +3,52 @@ import * as React from 'react'; import * as _ from 'lodash-es'; import { Helmet } from 'react-helmet'; +import { Alert } from 'patternfly-react'; -import { Firehose, LoadingBox, history } from '../utils'; +import { Firehose, LoadingBox, history, NsDropdown } from '../utils'; import { referenceForModel, K8sResourceKind, k8sUpdate, k8sCreate } from '../../module/k8s'; import { SubscriptionModel, CatalogSourceConfigModel, OperatorGroupModel, PackageManifestModel } from '../../models'; -import { OperatorGroupKind, PackageManifestKind, ClusterServiceVersionLogo, SubscriptionKind, InstallPlanApproval } from '../operator-lifecycle-manager'; -import { OperatorGroupSelector } from '../operator-lifecycle-manager/operator-group'; -import { RadioGroup } from '../radio'; +import { OperatorGroupKind, PackageManifestKind, ClusterServiceVersionLogo, SubscriptionKind, InstallPlanApproval, installModesFor, defaultChannelFor } from '../operator-lifecycle-manager'; +import { InstallModeType, isGlobal, installedFor, isSingle } from '../operator-lifecycle-manager/operator-group'; +import { RadioGroup, RadioInput } from '../radio'; import { OPERATOR_HUB_CSC_BASE } from '../../const'; import { getOperatorProviderType } from './operator-hub-utils'; +import { Tooltip } from '../utils/tooltip'; // TODO: Use `redux-form` instead of stateful component -const withFormState = (Component) => { +const withFormState =

(Component: React.ComponentType

) => { /** * Controlled component which holds form state (https://reactjs.org/docs/forms.html#controlled-components). */ - return class WithFormState extends React.Component { + return class WithFormState extends React.Component { static WrappedComponent = Component; state = { - target: null, + targetNamespace: null, + installMode: null, updateChannel: null, approval: InstallPlanApproval.Automatic, }; static getDerivedStateFromProps(nextProps, prevState) { - const updateChannel = !_.isEmpty(_.get(nextProps.packageManifest, 'data')) - ? (nextProps.packageManifest.data.status.channels.find(ch => ch.name === nextProps.packageManifest.data.status.defaultChannel) || nextProps.packageManifest.data.status.channels[0]).name + const updateChannel = !_.isEmpty(_.get(nextProps.packageManifest, 'data')) ? defaultChannelFor(nextProps.packageManifest.data) : null; + const installMode = !_.isEmpty(_.get(nextProps.packageManifest, 'data')) + ? installModesFor(nextProps.packageManifest.data)(updateChannel) + .filter(({supported}) => supported) + .reduce((acc, mode) => mode.type === InstallModeType.InstallModeTypeAllNamespaces + ? InstallModeType.InstallModeTypeAllNamespaces + : acc, + InstallModeType.InstallModeTypeOwnNamespace) : null; + const targetNamespace = installMode === InstallModeType.InstallModeTypeOwnNamespace + ? _.get(nextProps.operatorGroup, 'data', []) + .some(group => group.metadata.namespace === nextProps.targetNamespace && !isSingle(group)) ? null : nextProps.targetNamespace + : _.get(nextProps.operatorGroup, 'data', []) + .reduce((ns, group) => isGlobal(group) ? group.metadata.namespace : ns, prevState.targetNamespace || nextProps.targetNamespace); return { - target: prevState.target, + targetNamespace, + installMode: prevState.installMode || installMode, updateChannel: prevState.updateChannel || updateChannel, approval: prevState.approval || InstallPlanApproval.Automatic, }; @@ -55,9 +70,7 @@ export const OperatorHubSubscribeForm = withFormState((props: OperatorHubSubscri const providerType = getOperatorProviderType(props.packageManifest.data); const submit = () => { - const operatorGroupNamespace = props.operatorGroup.data.find(og => og.metadata.name === props.formState().target).metadata.namespace; - const OPERATOR_HUB_CSC_NAME = `${OPERATOR_HUB_CSC_BASE}-${srcProvider}-${props.formState().target}`; - + const OPERATOR_HUB_CSC_NAME = `${OPERATOR_HUB_CSC_BASE}-${srcProvider}-${props.formState().targetNamespace}`; const catalogSourceConfig = props.catalogSourceConfig.data.find(csc => csc.metadata.name === OPERATOR_HUB_CSC_NAME); const hasBeenEnabled = !_.isEmpty(catalogSourceConfig) && _.includes(catalogSourceConfig.spec.packages.split(','), packageName); const packages = _.isEmpty(catalogSourceConfig) @@ -72,23 +85,35 @@ export const OperatorHubSubscribeForm = withFormState((props: OperatorHubSubscri namespace: 'openshift-marketplace', }, spec: { - targetNamespace: operatorGroupNamespace, + targetNamespace: props.formState().targetNamespace, packages: `${packages}`, csDisplayName: `${providerType} Operators`, csPublisher: `${providerType}`, }, }; + const operatorGroup: OperatorGroupKind = { + apiVersion: 'operators.coreos.com/v1alpha2', + kind: 'OperatorGroup', + metadata: { + generateName: `${props.formState().targetNamespace}-`, + namespace: props.formState().targetNamespace, + }, + spec: { + targetNamespaces: [props.formState().targetNamespace], + }, + }; + const subscription: SubscriptionKind = { apiVersion: 'operators.coreos.com/v1alpha1', kind: 'Subscription', metadata: { name: packageName, - namespace: operatorGroupNamespace, + namespace: props.formState().targetNamespace, }, spec: { source: OPERATOR_HUB_CSC_NAME, - sourceNamespace: operatorGroupNamespace, + sourceNamespace: props.formState().targetNamespace, name: packageName, startingCSV: channels.find(ch => ch.name === props.formState().updateChannel).currentCSV, channel: props.formState().updateChannel, @@ -97,18 +122,69 @@ export const OperatorHubSubscribeForm = withFormState((props: OperatorHubSubscri }; return (!_.isEmpty(catalogSourceConfig) || hasBeenEnabled - ? k8sUpdate(CatalogSourceConfigModel, {...catalogSourceConfig, spec: {targetNamespace: operatorGroupNamespace, packages}}, 'openshift-marketplace', OPERATOR_HUB_CSC_NAME) + ? k8sUpdate(CatalogSourceConfigModel, {...catalogSourceConfig, spec: {...catalogSourceConfig.spec, packages}}, 'openshift-marketplace', OPERATOR_HUB_CSC_NAME) : k8sCreate(CatalogSourceConfigModel, newCatalogSourceConfig) - ).then(() => k8sCreate(SubscriptionModel, subscription)) + ).then(() => props.operatorGroup.data.some(group => group.metadata.namespace === props.formState().targetNamespace) + ? Promise.resolve() + : k8sCreate(OperatorGroupModel, operatorGroup)) + .then(() => k8sCreate(SubscriptionModel, subscription)) .then(() => history.push('/operatorhub')); }; - return

+ const installModes = channels.find(ch => ch.name === props.formState().updateChannel).currentCSVDesc.installModes; + const isGlobalOperator = installModes.find(m => m.type === InstallModeType.InstallModeTypeAllNamespaces).supported; + const descFor = (mode: InstallModeType) => { + if (mode === InstallModeType.InstallModeTypeAllNamespaces && isGlobalOperator) { + return 'Operator will be available in all namespaces.'; + } + if (mode === InstallModeType.InstallModeTypeOwnNamespace && !isGlobalOperator) { + return 'Operator will be available in a single namespace only.'; + } + return 'This mode is not supported by this Operator'; + }; + const subscriptionExists = (ns: string) => installedFor(props.subscription.data)(props.operatorGroup.data)(props.packageManifest.data.status.packageName)(ns); + + return
-
+
- - props.updateFormState({target})} excludeName={'olm-operators'} /> + +
+ +
+
+ +
@@ -127,64 +203,80 @@ export const OperatorHubSubscribeForm = withFormState((props: OperatorHubSubscri ]} onChange={(e) => props.updateFormState({approval: e.currentTarget.value})} />
-
-
-
- - -
+ +
+ { subscriptionExists(props.formState().targetNamespace) && Operator subscription in namespace "{props.formState().targetNamespace}" already exists } + + + +
-
; +
; }); -export const OperatorHubSubscribePage: React.SFC = (props) => { - return
- - Operator Hub Subscription - -
-

Create Operator Subscription

-

Keep your service up to date by subscribing to a channel and update strategy from which to pull updates.

-
- - {/* FIXME(alecmerdler): Hack because `Firehose` injects props without TypeScript knowing about it */} - - -
; +export const OperatorHubSubscribePage: React.SFC = (props) =>
+ + Operator Hub Subscription + +
+

Create Operator Subscription

+

Keep your service up to date by subscribing to a channel and update strategy from which to pull updates.

+
+ + {/* FIXME(alecmerdler): Hack because `Firehose` injects props without TypeScript knowing about it */} + + +
; + +type FormData = { + targetNamespace?: string; + installMode?: InstallModeType; + updateChannel?: string; + approval?: InstallPlanApproval; +}; + +type WithFormStateProps = { + updateFormState: (state: FormData) => void; + formState: () => FormData; }; export type OperatorHubSubscribeFormProps = { loaded: boolean; loadError?: any; + namespace: string; operatorGroup: {loaded: boolean, data: OperatorGroupKind[]}; packageManifest: {loaded: boolean, data: PackageManifestKind}; catalogSourceConfig: {loaded: boolean, data: K8sResourceKind[]}; subscription: {loaded: boolean, data: SubscriptionKind[]}; - updateFormState: (state: {target?: string, updateChannel?: string, approval?: string}) => void; - formState: () => {target?: string, updateChannel?: string, approval?: InstallPlanApproval}; + updateFormState: (state: FormData) => void; + formState: () => FormData; }; export type OperatorHubSubscribePageProps = { diff --git a/frontend/public/components/operator-lifecycle-manager/clusterserviceversion-resource.tsx b/frontend/public/components/operator-lifecycle-manager/clusterserviceversion-resource.tsx index c03fb04942d..97bbf01fdf2 100644 --- a/frontend/public/components/operator-lifecycle-manager/clusterserviceversion-resource.tsx +++ b/frontend/public/components/operator-lifecycle-manager/clusterserviceversion-resource.tsx @@ -124,16 +124,16 @@ export const ProvidedAPIsPage = connect(inFlightStateToProps)( }); export const ProvidedAPIPage = connectToModel((props: ProvidedAPIPageProps) => { - const {namespace, kind, kindObj, kindsInFlight, csv} = props; + const {namespace, kind, kindsInFlight, csv} = props; return kindsInFlight ? null : v === 'create')} + canCreate={_.get(props.kindObj, 'verbs', [] as string[]).some(v => v === 'create')} createProps={{to: `/k8s/ns/${csv.metadata.namespace}/${ClusterServiceVersionModel.plural}/${csv.metadata.name}/${kind}/new`}} - namespace={kindObj.namespaced ? namespace : null} />; + namespace={_.get(props.kindObj, 'namespaced') ? namespace : null} />; }); export const ClusterServiceVersionResourceDetails = connectToModel( diff --git a/frontend/public/components/operator-lifecycle-manager/clusterserviceversion.tsx b/frontend/public/components/operator-lifecycle-manager/clusterserviceversion.tsx index bc65c123de1..f3a676500c9 100644 --- a/frontend/public/components/operator-lifecycle-manager/clusterserviceversion.tsx +++ b/frontend/public/components/operator-lifecycle-manager/clusterserviceversion.tsx @@ -151,7 +151,7 @@ export const CRDCard: React.SFC = (props) => { }; const crdCardRowStateToProps = ({k8s}, {crdDescs}) => { - const models: K8sKind[] = crdDescs.map(desc => k8s.getIn(['RESOURCES', 'models', referenceForProvidedAPI(desc)])); + const models: K8sKind[] = _.compact(crdDescs.map(desc => k8s.getIn(['RESOURCES', 'models', referenceForProvidedAPI(desc)]))); return { crdDescs: crdDescs.filter(desc => models.find(m => referenceForModel(m) === referenceForProvidedAPI(desc))), createable: models.filter(m => (m.verbs || []).includes('create')).map(m => referenceForModel(m)), @@ -160,7 +160,9 @@ const crdCardRowStateToProps = ({k8s}, {crdDescs}) => { export const CRDCardRow = connect(crdCardRowStateToProps)( (props: CRDCardRowProps) =>
- {props.crdDescs.map((desc, i) => )} + { _.isEmpty(props.crdDescs) + ? No Kubernetes APIs are being provided by this Operator. + : props.crdDescs.map((desc, i) => ) }
); diff --git a/frontend/public/components/operator-lifecycle-manager/index.tsx b/frontend/public/components/operator-lifecycle-manager/index.tsx index 03200ae23dc..09b855c638a 100644 --- a/frontend/public/components/operator-lifecycle-manager/index.tsx +++ b/frontend/public/components/operator-lifecycle-manager/index.tsx @@ -11,6 +11,7 @@ export { CatalogSourceDetailsPage, CreateSubscriptionYAML } from './catalog-sour export { SubscriptionsPage } from './subscription'; import * as operatorLogo from '../../imgs/operator.svg'; +import { InstallModeType } from './operator-group'; export const appCatalogLabel = 'alm-catalog'; export enum AppCatalog { @@ -114,6 +115,7 @@ export type ClusterServiceVersionKind = { customresourcedefinitions?: {owned?: CRDDescription[], required?: CRDDescription[]}; apiservicedefinitions?: {owned?: APIServiceDefinition[], required?: APIServiceDefinition[]}; replaces?: string; + installModes: {type: InstallModeType, supported: boolean}[]; }; status?: { phase: ClusterServiceVersionPhase; @@ -210,6 +212,7 @@ export type PackageManifestKind = { provider: { name: string; }; + installModes: {type: InstallModeType, supported: boolean}[]; } }[]; defaultChannel: string; @@ -219,8 +222,15 @@ export type PackageManifestKind = { export type OperatorGroupKind = { apiVersion: 'operators.coreos.com/v1alpha2'; kind: 'OperatorGroup'; - spec: {selector: Selector}; - status?: {namespaces: K8sResourceKind[]}; + spec?: { + selector?: Selector; + targetNamespaces?: string[]; + serviceAccount?: K8sResourceKind; + }; + status?: { + namespaces?: string[]; + lastUpdated: string; + }; } & K8sResourceKind; // TODO(alecmerdler): Shouldn't be needed anymore @@ -233,6 +243,9 @@ type ProvidedAPIsFor = (csv: ClusterServiceVersionKind) => (CRDDescription | API export const providedAPIsFor: ProvidedAPIsFor = csv => _.get(csv.spec, 'customresourcedefinitions.owned', []) .concat(_.get(csv.spec, 'apiservicedefinitions.owned', [])); +export const defaultChannelFor = (pkg: PackageManifestKind) => pkg.status.defaultChannel || pkg.status.channels[0].name; +export const installModesFor = (pkg: PackageManifestKind) => (channel: string) => pkg.status.channels.find(ch => ch.name === channel).currentCSVDesc.installModes; + export const referenceForProvidedAPI = (desc: CRDDescription | APIServiceDefinition): GroupVersionKind => _.get(desc, 'group') ? `${(desc as APIServiceDefinition).group}~${desc.version}~${desc.kind}` : `${(desc as CRDDescription).name.slice(desc.name.indexOf('.') + 1)}~${desc.version}~${desc.kind}`; diff --git a/frontend/public/components/operator-lifecycle-manager/operator-group.tsx b/frontend/public/components/operator-lifecycle-manager/operator-group.tsx index 32b95efd70b..a7319b14496 100644 --- a/frontend/public/components/operator-lifecycle-manager/operator-group.tsx +++ b/frontend/public/components/operator-lifecycle-manager/operator-group.tsx @@ -2,12 +2,14 @@ import * as React from 'react'; import * as _ from 'lodash-es'; +import { match, Link } from 'react-router-dom'; import { MsgBox } from '../utils/status-box'; import { K8sResourceKind, referenceForModel, GroupVersionKind } from '../../module/k8s'; -import { OperatorGroupKind } from './index'; +import { OperatorGroupKind, SubscriptionKind } from './index'; import { AsyncComponent } from '../utils/async'; import { OperatorGroupModel } from '../../models'; +import { getActiveNamespace } from '../../ui/ui-actions'; export const targetNamespacesFor = (obj: K8sResourceKind) => _.get(obj, ['metadata', 'annotations', 'olm.targetNamespaces']); export const operatorNamespaceFor = (obj: K8sResourceKind) => _.get(obj, ['metadata', 'annotations', 'olm.operatorNamespace']); @@ -15,8 +17,7 @@ export const operatorGroupFor = (obj: K8sResourceKind) => _.get(obj, ['metadata' export const NoOperatorGroupMsg: React.SFC = () => ; + detail={

The Operator Lifecycle Manager will not watch this namespace because it is not configured with an OperatorGroup. Create one here.

} />; type RequireOperatorGroupProps = { operatorGroup: {loaded: boolean, data?: OperatorGroupKind[]}; @@ -27,9 +28,10 @@ export const OperatorGroupSelector: React.SFC = (pro onChange={props.onChange || function() { return null; }} - desc="OperatorGroups" + desc="Operator Groups" placeholder="Select Operator Group" selectedKeyKind={referenceForModel(OperatorGroupModel)} + dataFilter={props.dataFilter} resources={[{kind: referenceForModel(OperatorGroupModel), fieldSelector: `metadata.name!=${props.excludeName}`}]} />; export const requireOperatorGroup =

(Component: React.ComponentType

) => { @@ -46,9 +48,69 @@ export const requireOperatorGroup =

(Compon } as React.ComponentClass

& {WrappedComponent: React.ComponentType

}; }; +export enum InstallModeType { + InstallModeTypeOwnNamespace = 'OwnNamespace', + InstallModeTypeSingleNamespace = 'SingleNamespace', + InstallModeTypeMultiNamespace = 'MultiNamespace', + InstallModeTypeAllNamespaces = 'AllNamespaces', +} + +export type InstallModeSet = {type: InstallModeType, supported: boolean}[]; + +export const supports = (set: InstallModeSet) => (obj: OperatorGroupKind) => { + const namespaces = _.get(obj.status, 'namespaces') || []; + const supportedModes = set.filter(({supported}) => supported).map(({type}) => type); + + if (namespaces.length === 1 && namespaces[0] === '') { + return supportedModes.includes(InstallModeType.InstallModeTypeAllNamespaces); + } + if (namespaces.length === 1 && namespaces[0] !== '') { + return supportedModes.includes(InstallModeType.InstallModeTypeSingleNamespace) || supportedModes.includes(InstallModeType.InstallModeTypeMultiNamespace); + } + if (namespaces.length > 1) { + return supportedModes.includes(InstallModeType.InstallModeTypeMultiNamespace); + } + if (namespaces.includes(obj.metadata.namespace)) { + return supportedModes.includes(InstallModeType.InstallModeTypeOwnNamespace); + } + if (namespaces.length > 1 && namespaces.includes('')) { + return false; + } +}; + +export const isGlobal = (obj: OperatorGroupKind) => supports([{type: InstallModeType.InstallModeTypeAllNamespaces, supported: true}])(obj); +export const isSingle = (obj: OperatorGroupKind) => supports([{type: InstallModeType.InstallModeTypeSingleNamespace, supported: true}])(obj); + +/** + * Determines if a given Operator package has a `Subscription` that makes it available in the given namespace. + * Finds any `Subscriptions` for the given package, matches them to their `OperatorGroup`, and checks if the `OperatorGroup` is targeting the given namespace or if it is global. + */ +export const installedFor = (allSubscriptions: SubscriptionKind[] = []) => (allGroups: OperatorGroupKind[] = []) => (pkgName: string) => (ns = '') => { + const installed = allSubscriptions.filter(sub => sub.spec.name === pkgName) + .some(sub => allGroups.some(og => og.metadata.namespace === sub.metadata.namespace && (isGlobal(og) || _.get(og.status, 'namespaces', [] as string[]).includes(ns)))); + return installed; +}; + export type OperatorGroupSelectorProps = { onChange?: (name: string, kind: GroupVersionKind) => void; excludeName?: string; + dataFilter?: (obj: OperatorGroupKind) => boolean; +}; + +export type OperatorGroupHeaderProps = { + +}; + +export type OperatorGroupRowProps = { + obj: OperatorGroupKind; +}; + +export type OperatorGroupListProps = { + +}; + +export type OperatorGroupsPageProps = { + match?: match<{ns?: string}>; }; NoOperatorGroupMsg.displayName = 'NoOperatorGroupMsg'; diff --git a/frontend/public/components/operator-lifecycle-manager/package-manifest.tsx b/frontend/public/components/operator-lifecycle-manager/package-manifest.tsx index 75fd0dea798..0b40daeee7c 100644 --- a/frontend/public/components/operator-lifecycle-manager/package-manifest.tsx +++ b/frontend/public/components/operator-lifecycle-manager/package-manifest.tsx @@ -5,8 +5,8 @@ import * as _ from 'lodash-es'; import { Link, match } from 'react-router-dom'; import { referenceForModel, K8sResourceKind } from '../../module/k8s'; -import { requireOperatorGroup } from './operator-group'; -import { PackageManifestKind, SubscriptionKind, ClusterServiceVersionLogo, visibilityLabel, OperatorGroupKind } from './index'; +import { requireOperatorGroup, installedFor, supports } from './operator-group'; +import { PackageManifestKind, SubscriptionKind, ClusterServiceVersionLogo, visibilityLabel, OperatorGroupKind, installModesFor, defaultChannelFor } from './index'; import { PackageManifestModel, SubscriptionModel, CatalogSourceModel, OperatorGroupModel } from '../../models'; import { StatusBox, MsgBox } from '../utils'; import { List, ListHeader, ColHead, MultiListPage } from '../factory'; @@ -20,7 +20,7 @@ export const PackageManifestHeader: React.SFC = (pro ; export const PackageManifestRow: React.SFC = (props) => { - const {obj, catalogSourceName, catalogSourceNamespace, subscription, defaultNS} = props; + const {obj, catalogSourceName, catalogSourceNamespace, subscription, defaultNS, canSubscribe} = props; const ns = getActiveNamespace(); const channel = !_.isEmpty(obj.status.defaultChannel) ? obj.status.channels.find(ch => ch.name === obj.status.defaultChannel) : obj.status.channels[0]; const {displayName, icon = [], version, provider} = channel.currentCSVDesc; @@ -36,13 +36,12 @@ export const PackageManifestRow: React.SFC = (props) =>

{version} ({channel.name})
-
- { subscription - ? subscriptionLink() - : None } - { (!subscription || ns === ALL_NAMESPACES_KEY) && - - } +
{ subscription + ? subscriptionLink() + : None } + { canSubscribe && + + }
; }; @@ -62,7 +61,7 @@ export const PackageManifestList = requireOperatorGroup((props: PackageManifestL label={PackageManifestModel.labelPlural} data={props.data} EmptyMsg={() => }> - { [...catalogs.values()].map(catalog =>
+ { _.sortBy([...catalogs.values()], 'displayName').map(catalog =>

{catalog.displayName}

@@ -80,7 +79,13 @@ export const PackageManifestList = requireOperatorGroup((props: PackageManifestL obj={rowProps.obj} catalogSourceName={catalog.name} catalogSourceNamespace={catalog.namespace} - subscription={(props.subscription.data || []).find(sub => sub.spec.name === rowProps.obj.metadata.name)} + subscription={(props.subscription.data || []) + .filter(sub => _.isEmpty(props.namespace) || sub.metadata.namespace === props.namespace) + .find(sub => sub.spec.name === rowProps.obj.metadata.name)} + canSubscribe={!installedFor(props.subscription.data)(props.operatorGroup.data)(rowProps.obj.status.packageName)(getActiveNamespace()) + && props.operatorGroup.data + .filter(og => _.isEmpty(props.namespace) || og.metadata.namespace === props.namespace) + .some(og => supports(installModesFor(rowProps.obj)(defaultChannelFor(rowProps.obj)))(og))} defaultNS={_.get(props.operatorGroup, 'data[0].metadata.namespace')} />} label="Package Manifests" EmptyMsg={() => } /> @@ -99,14 +104,14 @@ export const PackageManifestsPage: React.SFC = (props namespace={namespace} showTitle={false} helpText={HelpText} - ListComponent={(listProps: PackageManifestListProps) => } + ListComponent={(listProps: PackageManifestListProps) => } filterLabel="Packages by name" flatten={flatten} resources={[ {kind: referenceForModel(PackageManifestModel), isList: true, namespaced: true, prop: 'packageManifest', selector: {matchExpressions: [{key: visibilityLabel, operator: 'DoesNotExist'}, {key: OPERATOR_HUB_LABEL, operator: 'DoesNotExist'}]}}, {kind: referenceForModel(CatalogSourceModel), isList: true, namespaced: true, prop: 'catalogSource'}, - {kind: referenceForModel(SubscriptionModel), isList: true, namespaced: true, prop: 'subscription'}, - {kind: referenceForModel(OperatorGroupModel), isList: true, namespaced: true, prop: 'operatorGroup'}, + {kind: referenceForModel(SubscriptionModel), isList: true, prop: 'subscription'}, + {kind: referenceForModel(OperatorGroupModel), isList: true, prop: 'operatorGroup'}, ]} />; }; @@ -116,6 +121,7 @@ export type PackageManifestsPageProps = { }; export type PackageManifestListProps = { + namespace?: string; data: PackageManifestKind[]; filters?: {[name: string]: string}; subscription: {loaded: boolean, data?: SubscriptionKind[]}; @@ -135,6 +141,7 @@ export type PackageManifestRowProps = { catalogSourceNamespace: string; subscription?: SubscriptionKind; defaultNS: string; + canSubscribe: boolean; }; PackageManifestHeader.displayName = 'PackageManifestHeader'; diff --git a/frontend/public/components/radio.jsx b/frontend/public/components/radio.jsx deleted file mode 100644 index 811d710376c..00000000000 --- a/frontend/public/components/radio.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import * as _ from 'lodash-es'; -import * as React from 'react'; -import * as PropTypes from 'prop-types'; - -export const RadioInput = (props) => { - const inputProps = _.omit(props, ['title', 'subTitle', 'desc', 'children', 'inline']); - const labelClass = props.inline ? 'radio-inline' : ''; - const inputElement = - - {props.desc &&

{props.desc}

} - {props.children} -
; - - return props.inline ? inputElement :
{inputElement}
; - -}; - -RadioInput.propTypes = { - children: PropTypes.node, - desc: PropTypes.node, - title: PropTypes.node.isRequired, - inline?: PropTypes.bool, -}; - -export const RadioGroup = ({currentValue, onChange, items}) =>
- {items.map(({desc, title, value}) => )} -
; diff --git a/frontend/public/components/radio.tsx b/frontend/public/components/radio.tsx new file mode 100644 index 00000000000..3e7bc26ca56 --- /dev/null +++ b/frontend/public/components/radio.tsx @@ -0,0 +1,58 @@ +/* eslint-disable no-undef, no-unused-vars */ + +import * as _ from 'lodash-es'; +import * as React from 'react'; +import * as classNames from 'classnames'; + +export const RadioInput: React.SFC = (props) => { + const inputProps: React.InputHTMLAttributes = _.omit(props, ['title', 'subTitle', 'desc', 'children', 'inline']); + const inputElement = + + {props.desc &&

{props.desc}

} + {props.children} +
; + + return props.inline ? inputElement :
{inputElement}
; + +}; + +export const RadioGroup: React.SFC = ({currentValue, onChange, items}) =>
+ {items.map(({desc, title, subTitle, value, disabled}) => )} +
; + +export type RadioInputProps = { + checked: boolean; + desc?: string | JSX.Element; + onChange: (v: any) => void; + subTitle?: string | JSX.Element; + value: any; + disabled?: boolean; + inline?: boolean; +} & React.InputHTMLAttributes; + +export type RadioGroupProps = { + currentValue: any; + onChange: React.InputHTMLAttributes['onChange']; + items: ({ + desc?: string | JSX.Element; + title: string | JSX.Element; + subTitle?: string | JSX.Element; + value: any; + disabled?: boolean; + } & React.InputHTMLAttributes)[]; +}; + +RadioInput.displayName = 'RadioInput'; +RadioGroup.displayName = 'RadioGroup'; diff --git a/frontend/public/components/utils/link.jsx b/frontend/public/components/utils/link.jsx index eee3882ec35..35db8a6a0a2 100644 --- a/frontend/public/components/utils/link.jsx +++ b/frontend/public/components/utils/link.jsx @@ -18,7 +18,7 @@ export const legalNamePattern = /[a-z0-9](?:[-a-z0-9]*[a-z0-9])?/; const basePathPattern = new RegExp(`^/?${window.SERVER_FLAGS.basePath}`); -export const namespacedPrefixes = ['/search', '/status', '/k8s', '/overview', '/catalog', '/provisionedservices', '/operators', '/operatormanagement']; +export const namespacedPrefixes = ['/search', '/status', '/k8s', '/overview', '/catalog', '/provisionedservices', '/operators', '/operatormanagement', '/operatorhub']; export const stripBasePath = path => path.replace(basePathPattern, '/'); diff --git a/frontend/public/components/utils/tooltip.tsx b/frontend/public/components/utils/tooltip.tsx index 4e1b42e1852..f61e1969343 100644 --- a/frontend/public/components/utils/tooltip.tsx +++ b/frontend/public/components/utils/tooltip.tsx @@ -37,8 +37,8 @@ const tooltipOverrides = Object.freeze({ // Consider this mobile if the device screen width is less than 768. (This value shouldn't change.) const isMobile = window.screen.width < 768; -export const Tooltip: React.SFC = ({ content, children, styles, disableOnMobile }) => { - if (disableOnMobile && isMobile) { +export const Tooltip: React.SFC = ({ content, children, styles, disableOnMobile, hidden = false }) => { + if (disableOnMobile && isMobile || hidden) { return {children}; } const mergedStyles = styles ? _.merge({}, tooltipOverrides, styles) : tooltipOverrides; @@ -47,6 +47,7 @@ export const Tooltip: React.SFC = ({ content, children, styles, di type TooltipProps = { content: React.ReactNode; + hidden?: boolean; styles?: any; disableOnMobile?: boolean; }; diff --git a/frontend/public/module/k8s/index.ts b/frontend/public/module/k8s/index.ts index 27ce31dfabb..7290384e0a1 100644 --- a/frontend/public/module/k8s/index.ts +++ b/frontend/public/module/k8s/index.ts @@ -30,8 +30,9 @@ export type ObjectReference = { }; export type ObjectMetadata = { + name?: string; + generateName?: string; annotations?: {[key: string]: string}, - name: string, namespace?: string, labels?: {[key: string]: string}, ownerReferences?: OwnerReference[], diff --git a/test-prow-e2e.sh b/test-prow-e2e.sh index 637e5256783..0b7933c52ad 100755 --- a/test-prow-e2e.sh +++ b/test-prow-e2e.sh @@ -22,7 +22,4 @@ export BRIDGE_AUTH_PASSWORD="$(cat "${INSTALLER_DIR}/auth/kubeadmin-password")" set -x export BRIDGE_BASE_ADDRESS="https://$(oc get route console -n openshift-console -o jsonpath='{.spec.host}')" -oc create -f https://raw.githubusercontent.com/operator-framework/operator-lifecycle-manager/master/deploy/okd/manifests/0.8.0/0000_30_06-rh-operators.configmap.yaml -oc create -f https://raw.githubusercontent.com/operator-framework/operator-lifecycle-manager/master/deploy/okd/manifests/0.8.0/0000_30_09-rh-operators.catalogsource.yaml - ./test-gui.sh e2e