From d9893824c1b26e70f577bfcbf5a3e3cbc62f1922 Mon Sep 17 00:00:00 2001 From: alecmerdler Date: Wed, 6 Feb 2019 00:01:15 -0500 Subject: [PATCH] Operator Hub is now namespaced, need to test end-to-end --- frontend/__mocks__/k8sResourcesMocks.ts | 3 + frontend/__mocks__/operatorHubItemsMocks.ts | 6 + .../subscription-channel-modal.spec.tsx | 2 + .../operator-group.spec.tsx | 65 ++++++++++- frontend/public/components/app.jsx | 5 +- frontend/public/components/nav.jsx | 4 +- .../operator-hub/operator-hub-items.tsx | 4 +- .../operator-hub/operator-hub-page.tsx | 79 +++++-------- .../operator-hub/operator-hub-subscribe.tsx | 106 +++++++++++++++--- .../operator-lifecycle-manager/index.tsx | 14 ++- .../operator-group.tsx | 57 +++++++++- .../package-manifest.tsx | 2 + .../public/components/operator-management.tsx | 9 +- frontend/public/components/radio.jsx | 37 ------ frontend/public/components/radio.tsx | 58 ++++++++++ .../sidebars/operator-group-sidebar.tsx | 44 ++++++++ .../components/sidebars/resource-sidebars.ts | 16 ++- frontend/public/components/utils/link.jsx | 2 +- frontend/public/module/k8s/index.ts | 3 +- 19 files changed, 396 insertions(+), 120 deletions(-) delete mode 100644 frontend/public/components/radio.jsx create mode 100644 frontend/public/components/radio.tsx create mode 100644 frontend/public/components/sidebars/operator-group-sidebar.tsx diff --git a/frontend/__mocks__/k8sResourcesMocks.ts b/frontend/__mocks__/k8sResourcesMocks.ts index 4129b2eec46c..fe89f1135c2d 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 a97146e00593..17169da21cec 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 23a44fc3ac74..9ba7a4487c0b 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 29ceab510721..61b9594468c5 100644 --- a/frontend/__tests__/components/operator-lifecycle-manager/operator-group.spec.tsx +++ b/frontend/__tests__/components/operator-lifecycle-manager/operator-group.spec.tsx @@ -1,9 +1,11 @@ /* 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 { requireOperatorGroup, NoOperatorGroupMsg, supports, InstallModeSet, InstallModeType } from '../../../public/components/operator-lifecycle-manager/operator-group'; +import { OperatorGroupKind } from '../../../public/components/operator-lifecycle-manager'; import { testOperatorGroup } from '../../../__mocks__/k8sResourcesMocks'; describe('requireOperatorGroup', () => { @@ -33,3 +35,64 @@ describe('requireOperatorGroup', () => { expect(wrapper.find(NoOperatorGroupMsg).exists()).toBe(false); }); }); + +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/public/components/app.jsx b/frontend/public/components/app.jsx index 6dc451272175..141d35e83ee6 100644 --- a/frontend/public/components/app.jsx +++ b/frontend/public/components/app.jsx @@ -175,8 +175,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/nav.jsx b/frontend/public/components/nav.jsx index 75b3c4bc0c48..480978f032e4 100644 --- a/frontend/public/components/nav.jsx +++ b/frontend/public/components/nav.jsx @@ -23,9 +23,9 @@ import { MachineSetModel, PackageManifestModel, SubscriptionModel, + OperatorGroupModel, } from '../models'; import { referenceForModel } from '../module/k8s'; - import { history, stripBasePath } from './utils'; export const matchesPath = (resourcePath, prefix) => resourcePath === prefix || _.startsWith(resourcePath, `${prefix}/`); @@ -277,6 +277,8 @@ const operatorManagementStartsWith = [ InstallPlanModel.path, referenceForModel(CatalogSourceModel), CatalogSourceModel.path, + referenceForModel(OperatorGroupModel), + OperatorGroupModel.path, ]; const provisionedServicesStartsWith = ['serviceinstances', 'servicebindings']; const brokerManagementStartsWith = ['clusterservicebrokers', 'clusterserviceclasses']; diff --git a/frontend/public/components/operator-hub/operator-hub-items.tsx b/frontend/public/components/operator-hub/operator-hub-items.tsx index d52126107b9b..3e0338aca8dd 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 ( this.openOverlay(item)} - footer={installed ? Installed : null} + footer={installed && !_.isEmpty(this.props.namespace) ? Installed : null} /> ); } @@ -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 167a24e13e96..9bacb7fb2104 100644 --- a/frontend/public/components/operator-hub/operator-hub-page.tsx +++ b/frontend/public/components/operator-hub/operator-hub-page.tsx @@ -2,7 +2,8 @@ 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 { referenceForModel, K8sResourceKind } from '../../module/k8s'; @@ -52,51 +53,27 @@ 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 .} - /> - )}> - - - ); - } -} +export const OperatorHubList: React.SFC = (props) => { + const {packageManifest, catalogSourceConfig, subscription, loaded, loadError, namespace} = props; + const sourceConfigs = _.find(_.get(catalogSourceConfig, 'data'), csc => _.startsWith(csc.metadata.name, OPERATOR_HUB_CSC_BASE)); + const items = loaded + ? _.sortBy(normalizePackageManifests(_.get(packageManifest, 'data'), subscription.data), 'name') + : []; + + 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 @@ -120,14 +97,16 @@ export const OperatorHubPage: React.SFC = (props) => { kind: referenceForModel(PackageManifestModel), namespace: 'openshift-marketplace', prop: 'packageManifest', - selector: {matchLabels: {'openshift-marketplace': 'true'}}, + // FIXME(alecmerdler): Commenting this out for dev + // selector: {matchLabels: {'openshift-marketplace': 'true'}}, }, { isList: true, kind: referenceForModel(SubscriptionModel), + namespace: props.match.params.ns, prop: 'subscription', }]}> {/* FIXME(alecmerdler): Hack because `Firehose` injects props without TypeScript knowing about it */} - + @@ -135,10 +114,11 @@ export const OperatorHubPage: React.SFC = (props) => { }; 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 +131,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 ffa8107ddc4a..22658da74b66 100644 --- a/frontend/public/components/operator-hub/operator-hub-subscribe.tsx +++ b/frontend/public/components/operator-hub/operator-hub-subscribe.tsx @@ -3,16 +3,19 @@ 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 { InstallModeType, isGlobal } from '../operator-lifecycle-manager/operator-group'; +import { RadioGroup, RadioInput } from '../radio'; import { OPERATOR_HUB_CSC_BASE } from '../../const'; import { getOperatorProviderType } from './operator-hub-utils'; +const installModesFor = (pkg: PackageManifestKind) => (channel: string) => pkg.status.channels.find(ch => ch.name === channel).currentCSVDesc.installModes; + // TODO: Use `redux-form` instead of stateful component const withFormState = (Component) => { /** @@ -22,7 +25,8 @@ const withFormState = (Component) => { static WrappedComponent = Component; state = { - target: null, + targetNamespace: null, + installMode: null, updateChannel: null, approval: InstallPlanApproval.Automatic, }; @@ -31,9 +35,20 @@ const withFormState = (Component) => { 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 : 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 = _.get(nextProps.operatorGroup, 'data', []) + .reduce((ns, group) => installMode === InstallModeType.InstallModeTypeAllNamespaces && isGlobal(group) ? group.metadata.namespace : ns, null); return { - target: prevState.target, + targetNamespace: prevState.targetNamespace || targetNamespace, + installMode: prevState.installMode || installMode, updateChannel: prevState.updateChannel || updateChannel, approval: prevState.approval || InstallPlanApproval.Automatic, }; @@ -55,8 +70,8 @@ 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 operatorGroupNamespace = props.operatorGroup.data.find(og => og.metadata.name === props.formState().targetNamespace).metadata.namespace; + 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); @@ -72,6 +87,7 @@ export const OperatorHubSubscribeForm = withFormState((props: OperatorHubSubscri namespace: 'openshift-marketplace', }, spec: { + // FIXME(alecmerdler): `targetNamespace` should always be the same namespace as the global `OperatorGroup` (don't hardcode `openshift-operators) targetNamespace: operatorGroupNamespace, packages: `${packages}`, csDisplayName: `${providerType} Operators`, @@ -79,6 +95,18 @@ export const OperatorHubSubscribeForm = withFormState((props: OperatorHubSubscri }, }; + 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', @@ -99,16 +127,57 @@ export const OperatorHubSubscribeForm = withFormState((props: OperatorHubSubscri return (!_.isEmpty(catalogSourceConfig) || hasBeenEnabled ? k8sUpdate(CatalogSourceConfigModel, {...catalogSourceConfig, spec: {targetNamespace: operatorGroupNamespace, 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) => props.subscription.data.some(sub => sub.metadata.namespace === ns && sub.spec.name === props.packageManifest.data.status.packageName); + + return
- - props.updateFormState({target})} excludeName={'olm-operators'} /> + + props.updateFormState({installMode: e.target.value})} + value={InstallModeType.InstallModeTypeAllNamespaces} + checked={props.formState().installMode === InstallModeType.InstallModeTypeAllNamespaces} + disabled={!isGlobalOperator} + title="All namespaces on the cluster" + subTitle="(default)"> +
+

{descFor(InstallModeType.InstallModeTypeAllNamespaces)}

+
+
+ props.updateFormState({installMode: e.target.value})} + value={InstallModeType.InstallModeTypeOwnNamespace} + checked={props.formState().installMode === InstallModeType.InstallModeTypeOwnNamespace} + disabled={isGlobalOperator} + title="A specific namespace on the cluster"> +
+

{descFor(InstallModeType.InstallModeTypeOwnNamespace)}

+
+ { props.formState().installMode !== InstallModeType.InstallModeTypeAllNamespaces && !props.operatorGroup.data.some(og => og.metadata.namespace === ns && isGlobal(og))} + onChange={ns => props.updateFormState({targetNamespace: ns})} + id="dropdown-selectbox" />} +
@@ -128,8 +197,10 @@ 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}
+ {/* FIXME(alecmerdler): Disable if `Subscription` already exists in selected `targetNamespace` */}
@@ -137,7 +208,7 @@ export const OperatorHubSubscribeForm = withFormState((props: OperatorHubSubscri
-
; + ; }); export const OperatorHubSubscribePage: React.SFC = (props) => { @@ -164,14 +235,14 @@ export const OperatorHubSubscribePage: React.SFC namespace: new URLSearchParams(window.location.search).get('catalogNamespace'), name: new URLSearchParams(window.location.search).get('pkg'), prop: 'packageManifest', - selector: {matchLabels: {'openshift-marketplace':'true'}}, + selector: {matchLabels: {'openshift-marketplace': 'true'}}, }, { isList: true, kind: referenceForModel(SubscriptionModel), prop: 'subscription', }]}> {/* FIXME(alecmerdler): Hack because `Firehose` injects props without TypeScript knowing about it */} - +
; }; @@ -179,12 +250,13 @@ export const OperatorHubSubscribePage: React.SFC 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: {targetNamespace?: string, installMode?: InstallModeType, updateChannel?: string, approval?: string}) => void; + formState: () => {targetNamespace?: string, installMode?: InstallModeType, updateChannel?: string, approval?: InstallPlanApproval}; }; export type OperatorHubSubscribePageProps = { diff --git a/frontend/public/components/operator-lifecycle-manager/index.tsx b/frontend/public/components/operator-lifecycle-manager/index.tsx index 03200ae23dcd..c5b9a426e4c0 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 diff --git a/frontend/public/components/operator-lifecycle-manager/operator-group.tsx b/frontend/public/components/operator-lifecycle-manager/operator-group.tsx index 32b95efd70ba..42b03df72bdf 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 { 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,58 @@ 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 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 75fd0dea7987..787cb9e6ef54 100644 --- a/frontend/public/components/operator-lifecycle-manager/package-manifest.tsx +++ b/frontend/public/components/operator-lifecycle-manager/package-manifest.tsx @@ -5,6 +5,7 @@ import * as _ from 'lodash-es'; import { Link, match } from 'react-router-dom'; import { referenceForModel, K8sResourceKind } from '../../module/k8s'; +// FIXME(alecmerdler): Circular dependency from importing `requireOperatorGroup` import { requireOperatorGroup } from './operator-group'; import { PackageManifestKind, SubscriptionKind, ClusterServiceVersionLogo, visibilityLabel, OperatorGroupKind } from './index'; import { PackageManifestModel, SubscriptionModel, CatalogSourceModel, OperatorGroupModel } from '../../models'; @@ -37,6 +38,7 @@ export const PackageManifestRow: React.SFC = (props) =>

{version} ({channel.name})
+ {/* TODO(alecmerdler): Disable creating subscription if current `OperatorGroup` does not match `installModes` for CSV */} { subscription ? subscriptionLink() : None } diff --git a/frontend/public/components/operator-management.tsx b/frontend/public/components/operator-management.tsx index 0de3b8a5da5a..699446ccc959 100644 --- a/frontend/public/components/operator-management.tsx +++ b/frontend/public/components/operator-management.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; -import {HorizontalNav, PageHeading} from './utils'; -import {SubscriptionsPage} from './operator-lifecycle-manager/subscription'; -import {PackageManifestsPage} from './operator-lifecycle-manager/package-manifest'; -import {InstallPlansPage} from './operator-lifecycle-manager/install-plan'; + +import { HorizontalNav, PageHeading } from './utils'; +import { SubscriptionsPage } from './operator-lifecycle-manager/subscription'; +import { PackageManifestsPage } from './operator-lifecycle-manager/package-manifest'; +import { InstallPlansPage } from './operator-lifecycle-manager/install-plan'; const pages = [{ href: '', diff --git a/frontend/public/components/radio.jsx b/frontend/public/components/radio.jsx deleted file mode 100644 index 811d710376ce..000000000000 --- 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 000000000000..3e7bc26ca565 --- /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/sidebars/operator-group-sidebar.tsx b/frontend/public/components/sidebars/operator-group-sidebar.tsx new file mode 100644 index 000000000000..a374b6a5c984 --- /dev/null +++ b/frontend/public/components/sidebars/operator-group-sidebar.tsx @@ -0,0 +1,44 @@ +/* eslint-disable no-undef, no-unused-vars */ + +import * as React from 'react'; +import * as _ from 'lodash-es'; + +import { referenceForModel } from '../../module/k8s'; +import { OperatorGroupModel } from '../../models'; +import { SampleYaml } from './resource-sidebar'; + +const samples = [ + { + header: 'Own Namespace', + details: 'If supported, the set of namespaces targetted by an OperatorGroup must contain the namespace the Operator is to be installed in.', + templateName: 'own-namespace', + kind: referenceForModel(OperatorGroupModel), + }, + { + header: 'Single Namespace', + details: 'If supported, the set of namespaces targetted by an OperatorGroup can be of length 1.', + templateName: 'single-namespace', + kind: referenceForModel(OperatorGroupModel), + }, + { + header: 'Multi Namespace', + details: 'If supported, the set of namespaces targetted by an OperatorGroup can be of length >= 1. Any Operator supporting MultiNamespace implicitly supports SingleNamespace as well', + templateName: 'multi-namespace', + kind: referenceForModel(OperatorGroupModel), + }, +]; + +export const OperatorGroupSidebar: React.SFC = ({loadSampleYaml, downloadSampleYaml}) => { + return
    + {_.map(samples, (sample) => )} +
; +}; + +export type OperatorGroupSidebarProps = { + loadSampleYaml: any; + downloadSampleYaml: any; +}; diff --git a/frontend/public/components/sidebars/resource-sidebars.ts b/frontend/public/components/sidebars/resource-sidebars.ts index ad374de041c9..fb1c616f2cec 100644 --- a/frontend/public/components/sidebars/resource-sidebars.ts +++ b/frontend/public/components/sidebars/resource-sidebars.ts @@ -1,11 +1,23 @@ +/* eslint-disable no-unused-vars, no-undef */ + import { NetworkPolicySidebar } from './network-policy-sidebar'; import { RoleSidebar } from './role-sidebar'; import { BuildConfigSidebar } from './build-config-sidebar'; import { ResourceQuotaSidebar } from './resource-quota-sidebar'; +import { OperatorGroupSidebar } from './operator-group-sidebar'; +import { K8sKind } from '../../module/k8s'; + +type ResourceSidebarProps = { + kindObj?: K8sKind; + loadSampleYaml: () => void; + downloadSampleYaml: () => void; + isCreateMode?: boolean; +}; -export const resourceSidebars = new Map>() +export const resourceSidebars = new Map>() .set('BuildConfig', BuildConfigSidebar) .set('ClusterRole', RoleSidebar) .set('NetworkPolicy', NetworkPolicySidebar) .set('ResourceQuota', ResourceQuotaSidebar) - .set('Role', RoleSidebar); + .set('Role', RoleSidebar) + .set('OperatorGroup', OperatorGroupSidebar); diff --git a/frontend/public/components/utils/link.jsx b/frontend/public/components/utils/link.jsx index eee3882ec35f..35db8a6a0a27 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/module/k8s/index.ts b/frontend/public/module/k8s/index.ts index 678433255eb5..a2653ad2f42e 100644 --- a/frontend/public/module/k8s/index.ts +++ b/frontend/public/module/k8s/index.ts @@ -29,8 +29,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[],