diff --git a/Makefile b/Makefile index d7063dc09b..6d502b7984 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,12 @@ endif ################################## +# DEV Convenience + +reinstall: build push undeploy deploy + +################################## + # DEV - run apps locally for development .PHONY: dev-frontend diff --git a/backend/plugins/kube.js b/backend/plugins/kube.js index d2e8f8d84b..ae79fc3223 100644 --- a/backend/plugins/kube.js +++ b/backend/plugins/kube.js @@ -10,6 +10,9 @@ kc.loadFromDefault(); const currentContext = kc.getCurrentContext(); const customObjectsApi = kc.makeApiClient(k8s.CustomObjectsApi); +const coreV1Api = kc.makeApiClient(k8s.CoreV1Api); +const batchV1Api = kc.makeApiClient(k8s.BatchV1Api); +const batchV1beta1Api = kc.makeApiClient(k8s.BatchV1beta1Api); const currentUser = kc.getCurrentUser(); module.exports = fp(async (fastify) => { @@ -24,6 +27,9 @@ module.exports = fp(async (fastify) => { config: kc, currentContext, namespace, + coreV1Api, + batchV1beta1Api, + batchV1Api, customObjectsApi, currentUser, }); diff --git a/backend/routes/api/components/componentUtils.js b/backend/routes/api/components/componentUtils.js index d62b4b9faa..9b7c53d01c 100644 --- a/backend/routes/api/components/componentUtils.js +++ b/backend/routes/api/components/componentUtils.js @@ -67,16 +67,45 @@ const getInstalledOperators = async (fastify) => { csvs = _.get(res, 'body.items'); } catch (e) { fastify.log.error(e, 'failed to get ClusterServiceVersions'); + csvs = []; } return csvs.reduce((acc, csv) => { - if (csv.status.phase === 'Succeeded' && csv.status.reason === 'InstallSucceeded') { + if (csv.status?.phase === 'Succeeded' && csv.status?.reason === 'InstallSucceeded') { acc.push(csv); } return acc; }, []); }; +const getApplicationEnabledConfigMap = (fastify, appDef) => { + const namespace = fastify.kube.namespace; + const name = appDef.spec.enable?.validationConfigMap; + if (!name) { + Promise.resolve(null); + } + const coreV1Api = fastify.kube.coreV1Api; + return coreV1Api + .readNamespacedConfigMap(name, namespace) + .then((result) => result.body) + .catch((res) => { + fastify.log.error( + `Failed to read config map ${name} for ${appDef.metadata.name}: ${res.response?.body?.message}`, + ); + Promise.resolve(null); + }); +}; + +const getEnabledConfigMaps = (fastify, appDefs) => { + const configMapGetters = appDefs.reduce((acc, app) => { + if (app.spec.enable) { + acc.push(getApplicationEnabledConfigMap(fastify, app)); + } + return acc; + }, []); + return Promise.all(configMapGetters); +}; + const getApplicationDefs = () => { const normalizedPath = path.join(__dirname, '../../../../data/applications'); const applicationDefs = []; @@ -93,4 +122,11 @@ const getApplicationDefs = () => { return applicationDefs; }; -module.exports = { getInstalledKfdefs, getInstalledOperators, getLink, getApplicationDefs }; +module.exports = { + getInstalledKfdefs, + getInstalledOperators, + getLink, + getApplicationEnabledConfigMap, + getEnabledConfigMaps, + getApplicationDefs, +}; diff --git a/backend/routes/api/components/list.js b/backend/routes/api/components/list.js index 88696fa882..978d6f7b8a 100644 --- a/backend/routes/api/components/list.js +++ b/backend/routes/api/components/list.js @@ -3,16 +3,14 @@ const componentUtils = require('./componentUtils'); module.exports = async ({ fastify, request }) => { const applicationDefs = componentUtils.getApplicationDefs(); - if (!request.query.installed) { - return await Promise.all(applicationDefs); - } - // Fetch the installed kfDefs const kfdefApps = await componentUtils.getInstalledKfdefs(fastify); // Fetch the installed kfDefs const operatorCSVs = await componentUtils.getInstalledOperators(fastify); + // Fetch the enabled config maps + const enabledCMs = await componentUtils.getEnabledConfigMaps(fastify, applicationDefs); const getCSVForApp = (app) => operatorCSVs.find( (operator) => app.spec.csvName && operator.metadata.name.startsWith(app.spec.csvName), @@ -21,6 +19,7 @@ module.exports = async ({ fastify, request }) => { // Get the components associated with the installed KfDefs or operators const installedComponents = applicationDefs.reduce((acc, app) => { if (getCSVForApp(app)) { + app.spec.isEnabled = true; acc.push(app); return acc; } @@ -29,14 +28,27 @@ module.exports = async ({ fastify, request }) => { app.spec.kfdefApplications && kfdefApps.find((kfdefApp) => app.spec.kfdefApplications.includes(kfdefApp.name)) ) { + app.spec.isEnabled = true; acc.push(app); return acc; } + if (app.spec.enable) { + const cm = enabledCMs.find( + (enabledCM) => enabledCM?.metadata.name === app.spec.enable?.validationConfigMap, + ); + if (cm) { + if (cm.data?.validation_result === 'true') { + app.spec.isEnabled = true; + acc.push(app); + return acc; + } + } + } return acc; }, []); - return await Promise.all( + await Promise.all( installedComponents.map(async (installedComponent) => { if (installedComponent.spec.route) { const csv = getCSVForApp(installedComponent); @@ -57,4 +69,10 @@ module.exports = async ({ fastify, request }) => { return installedComponent; }), ); + + if (!request.query.installed) { + return await Promise.all(applicationDefs); + } + + return await Promise.all(installedComponents); }; diff --git a/backend/routes/api/validate-isv/index.js b/backend/routes/api/validate-isv/index.js new file mode 100644 index 0000000000..d751fc1c90 --- /dev/null +++ b/backend/routes/api/validate-isv/index.js @@ -0,0 +1,23 @@ +const { DEV_MODE } = require('../../../utils/constants'); +const responseUtils = require('../../../utils/responseUtils'); +const validateISV = require('./validateISV'); + +module.exports = async (fastify, opts) => { + fastify.get('/', async (request, reply) => { + return validateISV + .createValidationJob({ fastify, opts, request, reply }) + .then((res) => { + if (DEV_MODE) { + responseUtils.addCORSHeader(request, reply); + } + return res; + }) + .catch((res) => { + if (DEV_MODE) { + responseUtils.addCORSHeader(request, reply); + } + fastify.log.error(`Failed to create validation job: ${res.response?.body?.message}`); + reply.send(res); + }); + }); +}; diff --git a/backend/routes/api/validate-isv/validateISV.js b/backend/routes/api/validate-isv/validateISV.js new file mode 100644 index 0000000000..34dbaed44c --- /dev/null +++ b/backend/routes/api/validate-isv/validateISV.js @@ -0,0 +1,85 @@ +const componentUtils = require('../components/componentUtils'); +const createError = require('http-errors'); + +const getApplicationDef = (appName) => { + const appDefs = componentUtils.getApplicationDefs(); + return appDefs.find((appDef) => appDef.metadata.name === appName); +}; + +const createAccessSecret = async (appDef, namespace, stringData, coreV1Api) => { + const { enable } = appDef.spec; + if (!enable) { + return Promise.resolve(); + } + + stringData.configMapName = enable.validationConfigMap; + const name = enable.validationSecret; + const secret = { + apiVersion: 'v1', + metadata: { name, namespace }, + type: 'Opaque', + stringData, + }; + return coreV1Api + .readNamespacedSecret(name, namespace) + .then(() => { + return coreV1Api.replaceNamespacedSecret(name, namespace, secret); + }) + .catch(() => { + return coreV1Api.createNamespacedSecret(namespace, secret); + }); +}; + +const createValidationJob = async ({ fastify, request }) => { + const namespace = fastify.kube.namespace; + const appName = request.query?.appName; + const stringData = JSON.parse(request.query?.values ?? {}); + const batchV1beta1Api = fastify.kube.batchV1beta1Api; + const batchV1Api = fastify.kube.batchV1Api; + const coreV1Api = fastify.kube.coreV1Api; + const appDef = getApplicationDef(appName); + const { enable } = appDef.spec; + + const cronjobName = enable?.validationJob; + if (!cronjobName) { + const error = createError(500, 'failed to validate'); + error.explicitInternalServerError = true; + error.error = 'failed to find application definition file'; + error.message = 'Unable to validate the application.'; + throw error; + } + + return createAccessSecret(appDef, namespace, stringData, coreV1Api).then(() => { + return batchV1beta1Api.readNamespacedCronJob(cronjobName, namespace).then(async (res) => { + const cronJob = res.body; + const jobSpec = cronJob.spec.jobTemplate.spec; + const jobName = `${cronjobName}-job-custom-run`; + const job = { + apiVersion: 'batch/v1', + metadata: { + name: jobName, + namespace, + annotations: { + 'cronjob.kubernetes.io/instantiate': 'manual', + }, + }, + spec: jobSpec, + }; + // Flag the cronjob as no longer suspended + cronJob.spec.suspend = false; + await batchV1beta1Api.replaceNamespacedCronJob(cronjobName, namespace, cronJob).catch((e) => { + fastify.log.error(`failed to unsuspend cronjob: ${e.response.body.message}`); + }); + + // If there was a manual job already, delete it + await batchV1Api.deleteNamespacedJob(jobName, namespace).catch(() => {}); + + // Some delay to allow job to delete + return new Promise((resolve) => setTimeout(resolve, 1000)).then(() => + batchV1Api.createNamespacedJob(namespace, job), + ); + }); + }); +}; + +module.exports = { createAccessSecret, createValidationJob }; diff --git a/data/applications/anaconda-ce.yaml b/data/applications/anaconda-ce.yaml index fbb6f64088..1bcf9501f6 100644 --- a/data/applications/anaconda-ce.yaml +++ b/data/applications/anaconda-ce.yaml @@ -1,5 +1,5 @@ metadata: - name: anaconde-ce + name: anaconda-ce spec: displayName: Anaconda Commercial Edition provider: Anaconda @@ -13,5 +13,17 @@ spec: support: third party support docsLink: https://docs.anaconda.com/ quickStart: '' - getStartedLink: https://anaconda.cloud/register?utm_source=redhat-rhods-summit - + getStartedLink: https://anaconda.cloud/register?utm_source=redhat-rhods-summit + enable: + title: Connect Anaconda to JupyterHub + actionLabel: Connect + description: '' + variables: + Anaconda_ce_key: password + variableDisplayText: + Anaconda_ce_key: Anaconda CE Key + variableHelpText: + Anaconda_ce_key: This key is given to you by Anaconda + validationJob: anaconda-ce-periodic-validator + validationSecret: anaconda-ce-access + validationConfigMap: anaconda-ce-validation-result diff --git a/data/docs/anaconda-tutorial.yaml b/data/docs/anaconda-tutorial.yaml index dcef96ef1f..717a16b0fe 100644 --- a/data/docs/anaconda-tutorial.yaml +++ b/data/docs/anaconda-tutorial.yaml @@ -2,7 +2,7 @@ metadata: name: anaconda-background type: tutorial spec: - appName: anaconde-ce + appName: anaconda-ce displayName: What is Anaconda? description: |- Learn about the Anaconda Distrbution and why Python is so frequently used for Data Science tasks. diff --git a/data/docs/python-acceleration-numba-tutorial.yaml b/data/docs/python-acceleration-numba-tutorial.yaml index e4f08b0509..d79b6c76bc 100644 --- a/data/docs/python-acceleration-numba-tutorial.yaml +++ b/data/docs/python-acceleration-numba-tutorial.yaml @@ -2,7 +2,7 @@ metadata: name: python-accelerate-numba-tutorial type: tutorial spec: - appName: anaconde-ce + appName: anaconda-ce displayName: Accelerating Scientific Workloads in Python with Numba description: |- If you're interesting making your Python code run faster, this talk is for you diff --git a/data/docs/python-effective-pandas-tutorial.yaml b/data/docs/python-effective-pandas-tutorial.yaml index 37415b68b8..2cae9fba3e 100644 --- a/data/docs/python-effective-pandas-tutorial.yaml +++ b/data/docs/python-effective-pandas-tutorial.yaml @@ -2,7 +2,7 @@ metadata: name: python-effective-pandas-tutorial type: tutorial spec: - appName: anaconde-ce + appName: anaconda-ce displayName: Data analysis in Python with Pandas description: |- This series is about how to make effective use of pandas, a data analysis library for the Python programming language. It's targeted at an intermediate level: people who have some experience with pandas, but are looking to improve. diff --git a/data/docs/python-gpu-numba-tutorial.yaml b/data/docs/python-gpu-numba-tutorial.yaml index c3b559f398..f7ce71215e 100644 --- a/data/docs/python-gpu-numba-tutorial.yaml +++ b/data/docs/python-gpu-numba-tutorial.yaml @@ -2,7 +2,7 @@ metadata: name: python-gpu-numba-tutorial type: tutorial spec: - appName: anaconde-ce + appName: anaconda-ce displayName: GPU Computing in Python with Numba description: |- Learn how to use Numba to create GPU accelerated functions. diff --git a/data/docs/python-interactive-visualization-tutorial.yaml b/data/docs/python-interactive-visualization-tutorial.yaml index 591aaf63d4..7c532f4b91 100644 --- a/data/docs/python-interactive-visualization-tutorial.yaml +++ b/data/docs/python-interactive-visualization-tutorial.yaml @@ -2,7 +2,7 @@ metadata: name: python-interactive-visualization-tutorial type: tutorial spec: - appName: anaconde-ce + appName: anaconda-ce displayName: Build interactive visualizations and dashboards in Python description: |- This tutorial will take you through all of the steps involved in exploring data of many different types and sizes, building simple and complex figures, working with billions of data points, adding interactive behavior, widgets and controls, and deploying full dashboards and applications. diff --git a/data/docs/python-scalable-computing-dask-tutorial.yaml b/data/docs/python-scalable-computing-dask-tutorial.yaml index 67e6148572..6eefc8ba36 100644 --- a/data/docs/python-scalable-computing-dask-tutorial.yaml +++ b/data/docs/python-scalable-computing-dask-tutorial.yaml @@ -2,7 +2,7 @@ metadata: name: python-scalable-computing-dask-tutorial type: tutorial spec: - appName: anaconde-ce + appName: anaconda-ce displayName: Scalable computing in Python with Dask description: |- Dask is a parallel computing library that scales the existing Python ecosystem. This tutorial will introduce Dask and parallel data analysis more generally. Dask can scale down to your laptop and up to a cluster. In this tutorial, we’ll analyze medium sized datasets in parallel. diff --git a/data/docs/python-scikit-learn-tutorial.yaml b/data/docs/python-scikit-learn-tutorial.yaml index a2bd89b666..376cfd4b79 100644 --- a/data/docs/python-scikit-learn-tutorial.yaml +++ b/data/docs/python-scikit-learn-tutorial.yaml @@ -2,7 +2,7 @@ metadata: name: python-scikit-learn-tutorial type: tutorial spec: - appName: anaconde-ce + appName: anaconda-ce displayName: Machine Learning in Python with Scikit-Learn description: |- Learn how to build machine learning models with scikit-learn for supervised learning, unsupervised learning, and classification problems. diff --git a/data/docs/python-visualization-tutorial.yaml b/data/docs/python-visualization-tutorial.yaml index f61f4ee050..da2a5a5007 100644 --- a/data/docs/python-visualization-tutorial.yaml +++ b/data/docs/python-visualization-tutorial.yaml @@ -2,7 +2,7 @@ metadata: name: python-visualization-tutorial type: tutorial spec: - appName: anaconde-ce + appName: anaconda-ce displayName: Python tools for data visualization description: |- The PyViz.org website is an open platform for helping users decide on the best open-source (OSS) Python data visualization tools for their purposes, with links, overviews, comparisons, and examples. diff --git a/data/getting-started/anaconde-ce.md b/data/getting-started/anaconda-ce.md similarity index 100% rename from data/getting-started/anaconde-ce.md rename to data/getting-started/anaconda-ce.md diff --git a/frontend/src/pages/exploreApplication/EnableModal.tsx b/frontend/src/pages/exploreApplication/EnableModal.tsx new file mode 100644 index 0000000000..e74ab4e257 --- /dev/null +++ b/frontend/src/pages/exploreApplication/EnableModal.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import { + Alert, + Button, + Form, + FormAlert, + FormGroup, + Modal, + ModalVariant, + TextInput, + TextInputTypes, +} from '@patternfly/react-core'; +import { ODHApp } from '../../types'; +import { postValidateIsv } from '../../services/validateIsvService'; + +type EnableModalProps = { + selectedApp?: ODHApp; + onClose: () => void; +}; + +const EnableModal: React.FC = ({ selectedApp, onClose }) => { + const [postError, setPostError] = React.useState(false); + const [enableValues, setEnableValues] = React.useState<{ [key: string]: string }>({}); + + if (!selectedApp) { + return null; + } + + const updateEnableValue = (key: string, value: string): void => { + const updatedValues = { + ...enableValues, + [key]: value, + }; + setEnableValues(updatedValues); + }; + + const renderAppVariables = () => { + if (!selectedApp.spec.enable?.variables) { + return null; + } + return Object.keys(selectedApp.spec.enable.variables).map((key) => ( + + updateEnableValue(key, value)} + /> + + )); + }; + + const onDoEnableApp = () => { + postValidateIsv(selectedApp.metadata.name, enableValues) + .then(() => { + setPostError(false); + onClose(); + }) + .catch(() => { + setPostError(true); + }); + }; + + return ( + + {selectedApp.spec.enable?.actionLabel} + , + , + ]} + > + {selectedApp.spec.enable?.description ? selectedApp.spec.enable?.description : null} + {selectedApp.spec.enable?.variables ? ( +
+ {postError ? ( + + + + ) : null} + {renderAppVariables()} +
+ ) : null} +
+ ); +}; + +export default EnableModal; diff --git a/frontend/src/pages/exploreApplication/GetStartedPanel.scss b/frontend/src/pages/exploreApplication/GetStartedPanel.scss index c254086306..be86e655a8 100644 --- a/frontend/src/pages/exploreApplication/GetStartedPanel.scss +++ b/frontend/src/pages/exploreApplication/GetStartedPanel.scss @@ -11,7 +11,12 @@ font-weight: var(--pf-global--FontWeight--normal); } } - + &__button-panel { + display: flex; + .pf-c-button.pf-m-secondary { + margin-left: var(--pf-global--spacer--md); + } + } &__get-started-text { padding-right: var(--pf-global--spacer--xs); } diff --git a/frontend/src/pages/exploreApplication/GetStartedPanel.tsx b/frontend/src/pages/exploreApplication/GetStartedPanel.tsx index 07b286e23a..e8e8531b76 100644 --- a/frontend/src/pages/exploreApplication/GetStartedPanel.tsx +++ b/frontend/src/pages/exploreApplication/GetStartedPanel.tsx @@ -1,21 +1,24 @@ import * as React from 'react'; import { + Button, + ButtonVariant, DrawerPanelBody, DrawerHead, DrawerPanelContent, - Title, DrawerActions, DrawerCloseButton, EmptyState, EmptyStateVariant, - Spinner, EmptyStateIcon, EmptyStateBody, + Spinner, + Title, } from '@patternfly/react-core'; import { ExternalLinkAltIcon, WarningTriangleIcon } from '@patternfly/react-icons'; import { ODHApp, ODHGettingStarted } from '../../types'; import MarkdownView from '../../components/MarkdownView'; import { fetchGettingStartedDoc } from '../../services/gettingStartedService'; +import EnableModal from './EnableModal'; import './GetStartedPanel.scss'; @@ -28,6 +31,7 @@ const GetStartedPanel: React.FC = ({ selectedApp, onClose const [loaded, setLoaded] = React.useState(false); const [loadError, setLoadError] = React.useState(); const [odhGettingStarted, setOdhGettingStarted] = React.useState(); + const [enableOpen, setEnableOpen] = React.useState(false); const appName = selectedApp?.metadata.name; React.useEffect(() => { @@ -81,44 +85,56 @@ const GetStartedPanel: React.FC = ({ selectedApp, onClose return ; }; + const onEnable = () => { + setEnableOpen(true); + }; + return ( - - -
- - {selectedApp.spec.displayName} - - {selectedApp.spec.provider ? ( -
- - by {selectedApp.spec.provider} - -
- ) : null} -
- - - -
- {selectedApp.spec.getStartedLink ? ( - - + <> + + +
+ + {selectedApp.spec.displayName} + + {selectedApp.spec.provider ? ( +
+ + by {selectedApp.spec.provider} + +
+ ) : null} +
+ + + +
+ {selectedApp.spec.getStartedLink ? ( + - Get Started + Get started -
+ {selectedApp.spec.enable && !selectedApp.spec.isEnabled ? ( + + ) : null} +
+ ) : null} + + {renderMarkdownContents()} +
+ {enableOpen ? ( + setEnableOpen(false)} selectedApp={selectedApp} /> ) : null} - - {renderMarkdownContents()} - - + ); }; diff --git a/frontend/src/services/validateIsvService.ts b/frontend/src/services/validateIsvService.ts new file mode 100644 index 0000000000..4ec27dee12 --- /dev/null +++ b/frontend/src/services/validateIsvService.ts @@ -0,0 +1,23 @@ +import axios from 'axios'; +import { getBackendURL } from '../utilities/utils'; + +export const postValidateIsv = ( + appName: string, + values: { [key: string]: string }, +): Promise => { + const url = getBackendURL('/api/validate-isv'); + const searchParams = new URLSearchParams(); + if (appName) { + searchParams.set('appName', appName); + searchParams.set('values', JSON.stringify(values)); + } + const options = { params: searchParams }; + return axios + .get(url, options) + .then((response) => { + return response; + }) + .catch((e) => { + throw new Error(e.response.data.message); + }); +}; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 861682aa04..dcce0892e8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -16,6 +16,16 @@ type ODHApp = { support: string; quickStart: string | null; comingSoon: boolean | null; + isEnabled: boolean | null; + enable?: { + title: string; + actionLabel: string; + description?: string; + variables?: { [key: string]: string }; + variableDisplayText?: { [key: string]: string }; + variableHelpText?: { [key: string]: string }; + validationJob: string; + }; }; }; diff --git a/install/odh/base/cluster-role-binding.yaml b/install/odh/base/cluster-role-binding.yaml new file mode 100644 index 0000000000..8989108acc --- /dev/null +++ b/install/odh/base/cluster-role-binding.yaml @@ -0,0 +1,12 @@ +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: odh-dashboard +subjects: + - kind: ServiceAccount + name: odh-dashboard + namespace: redhat-ods-applications +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: odh-dashboard diff --git a/install/odh/base/cluster-role.yaml b/install/odh/base/cluster-role.yaml new file mode 100644 index 0000000000..d9d92d33ba --- /dev/null +++ b/install/odh/base/cluster-role.yaml @@ -0,0 +1,21 @@ +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: odh-dashboard +rules: + - verbs: + - get + - list + - watch + apiGroups: + - operators.coreos.com + resources: + - clusterserviceversions + - verbs: + - get + - list + - watch + apiGroups: + - route.openshift.io + resources: + - routes diff --git a/install/odh/base/kustomization.yaml b/install/odh/base/kustomization.yaml index f3c43d94f8..c033ecf6e1 100644 --- a/install/odh/base/kustomization.yaml +++ b/install/odh/base/kustomization.yaml @@ -5,8 +5,10 @@ commonLabels: app.kubernetes.io/part-of: odh-dashboard resources: - role.yaml +- cluster-role.yaml - service-account.yaml - role-binding.yaml +- cluster-role-binding.yaml - deployment.yaml - routes.yaml - service.yaml diff --git a/install/odh/base/role.yaml b/install/odh/base/role.yaml index 7decfab2ca..a80f827ad5 100644 --- a/install/odh/base/role.yaml +++ b/install/odh/base/role.yaml @@ -19,3 +19,35 @@ rules: - get - list - watch + - apiGroups: + - '' + verbs: + - create + - delete + - get + - patch + - update + - watch + resources: + - configmaps + - secrets + - apiGroups: + - batch + verbs: + - create + - delete + - get + - patch + - update + - watch + resources: + - cronjobs + - jobs + - apiGroups: + - image.openshift.io + verbs: + - create + - get + - patch + resources: + - imagestreams