From 66ce104c6b65c9eb39cde1af70ede08e84516c1f Mon Sep 17 00:00:00 2001 From: Mario Loriedo Date: Mon, 1 Apr 2019 23:09:00 +0200 Subject: [PATCH] Changed server:stop behavior and added server:delete (#76) --- README.md | 34 ++- src/api/che.ts | 207 ++++++++++----- src/api/kube.ts | 280 +++++++++++++++++++- src/api/openshift.ts | 44 ++- src/commands/server/delete.ts | 197 ++++++++++++++ src/commands/server/start.ts | 31 +-- src/commands/server/stop.ts | 204 ++++++++++++-- src/commands/server/update.ts | 8 +- src/commands/workspace/inject.ts | 6 +- src/commands/workspace/list.ts | 8 +- src/commands/workspace/start.ts | 6 +- src/commands/workspace/stop.ts | 8 +- src/installers/helm.ts | 7 +- src/installers/minishift-addon.ts | 8 +- src/installers/operator.ts | 2 +- src/platforms/minikube.ts | 2 +- src/platforms/minishift.ts | 2 +- test/api/che.test.ts | 266 +++++++++++-------- test/api/kube.test.ts | 7 + test/api/openshift.test.ts | 2 +- test/api/replies/get-keycloak-settings.json | 12 + test/e2e/minikube.test.ts | 83 ++++++ test/e2e/minishift.test.ts | 83 ++++++ 23 files changed, 1257 insertions(+), 250 deletions(-) create mode 100644 src/commands/server/delete.ts create mode 100644 test/api/replies/get-keycloak-settings.json create mode 100644 test/e2e/minikube.test.ts create mode 100644 test/e2e/minishift.test.ts diff --git a/README.md b/README.md index d78a795cd..419cc56fd 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ USAGE * [`chectl autocomplete [SHELL]`](#chectl-autocomplete-shell) * [`chectl help [COMMAND]`](#chectl-help-command) +* [`chectl server:delete`](#chectl-serverdelete) * [`chectl server:start`](#chectl-serverstart) * [`chectl server:stop`](#chectl-serverstop) * [`chectl server:update`](#chectl-serverupdate) @@ -92,6 +93,22 @@ OPTIONS _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v2.1.4/src/commands/help.ts)_ +## `chectl server:delete` + +delete any Che related resource: Kubernetes/OpenShift/Helm + +``` +USAGE + $ chectl server:delete + +OPTIONS + -h, --help show CLI help + -n, --chenamespace=chenamespace [default: che] Kubernetes namespace where Che was deployed + --listr-renderer=listr-renderer [default: default] Listr renderer. Can be 'default', 'silent' or 'verbose' +``` + +_See code: [src/commands/server/delete.ts](https://github.com/che-incubator/chectl/blob/v0.0.2/src/commands/server/delete.ts)_ + ## `chectl server:start` start Eclipse Che Server @@ -106,8 +123,6 @@ OPTIONS -b, --domain=domain Domain of the Kubernetes/OpenShift cluster (e.g. starter-us-east-2.openshiftapps.com or .nip.io) - -d, --debug Starts chectl in debug mode - -h, --help show CLI help -i, --cheimage=cheimage [default: eclipse/che-server:nightly] Che server container image @@ -124,6 +139,8 @@ OPTIONS -s, --tls Enable TLS encryption and multi-user mode -t, --templates=templates [default: templates] Path to the templates folder + + --listr-renderer=listr-renderer [default: default] Listr renderer. Can be 'default', 'silent' or 'verbose' ``` _See code: [src/commands/server/start.ts](https://github.com/che-incubator/chectl/blob/v0.0.2/src/commands/server/start.ts)_ @@ -137,8 +154,12 @@ USAGE $ chectl server:stop OPTIONS - -h, --help show CLI help - -n, --chenamespace=chenamespace [default: che] Kubernetes namespace where Che resources will be deployed + -h, --help show CLI help + -n, --chenamespace=chenamespace [default: che] Kubernetes namespace where Che resources will be deployed + --access-token=access-token Che OIDC Access Token + --che-selector=che-selector [default: app=che] Selector for Che Server resources + --deployment-name=deployment-name [default: che] Che deployment name + --listr-renderer=listr-renderer [default: default] Listr renderer. Can be 'default', 'silent' or 'verbose' ``` _See code: [src/commands/server/stop.ts](https://github.com/che-incubator/chectl/blob/v0.0.2/src/commands/server/stop.ts)_ @@ -154,6 +175,7 @@ USAGE OPTIONS -h, --help show CLI help -n, --chenamespace=chenamespace [default: che] Kubernetes namespace where Che resources will be deployed + --listr-renderer=listr-renderer [default: default] Listr renderer. Can be 'default', 'silent' or 'verbose' ``` _See code: [src/commands/server/update.ts](https://github.com/che-incubator/chectl/blob/v0.0.2/src/commands/server/update.ts)_ @@ -172,6 +194,7 @@ OPTIONS -k, --kubeconfig Inject the local Kubernetes configuration -n, --chenamespace=chenamespace [default: che] Kubernetes namespace where Che workspace is running -w, --workspace=workspace Target workspace + --listr-renderer=listr-renderer [default: default] Listr renderer. Can be 'default', 'silent' or 'verbose' ``` _See code: [src/commands/workspace/inject.ts](https://github.com/che-incubator/chectl/blob/v0.0.2/src/commands/workspace/inject.ts)_ @@ -187,6 +210,7 @@ USAGE OPTIONS -h, --help show CLI help -n, --chenamespace=chenamespace [default: che] Kubernetes namespace where Che server is deployed + --listr-renderer=listr-renderer [default: default] Listr renderer. Can be 'default', 'silent' or 'verbose' ``` _See code: [src/commands/workspace/list.ts](https://github.com/che-incubator/chectl/blob/v0.0.2/src/commands/workspace/list.ts)_ @@ -204,6 +228,7 @@ OPTIONS -h, --help show CLI help -n, --chenamespace=chenamespace [default: che] kubernetes namespace where Che server is deployed -w, --workspaceconfig=workspaceconfig path to a valid workspace configuration json file + --listr-renderer=listr-renderer [default: default] Listr renderer. Can be 'default', 'silent' or 'verbose' ``` _See code: [src/commands/workspace/start.ts](https://github.com/che-incubator/chectl/blob/v0.0.2/src/commands/workspace/start.ts)_ @@ -219,6 +244,7 @@ USAGE OPTIONS -h, --help show CLI help -n, --chenamespace=chenamespace [default: che] Kubernetes namespace where Che server is deployed + --listr-renderer=listr-renderer [default: default] Listr renderer. Can be 'default', 'silent' or 'verbose' ``` _See code: [src/commands/workspace/stop.ts](https://github.com/che-incubator/chectl/blob/v0.0.2/src/commands/workspace/stop.ts)_ diff --git a/src/api/che.ts b/src/api/che.ts index 6c2085f3d..15ee592a2 100644 --- a/src/api/che.ts +++ b/src/api/che.ts @@ -12,10 +12,11 @@ import { Core_v1Api, KubeConfig } from '@kubernetes/client-node' import axios from 'axios' -import * as execa from 'execa' +import { cli } from 'cli-ux' import * as fs from 'fs' import { KubeHelper } from '../api/kube' +import { OpenShiftHelper } from '../api/openshift' export class CheHelper { defaultCheResponseTimeoutMs = 3000 @@ -69,33 +70,39 @@ export class CheHelper { } } - async cheURLByIngress(ingress: string, namespace = ''): Promise { - const protocol = 'http' - const { stdout } = await execa('kubectl', - ['get', - 'ingress', - '-n', - `${namespace}`, - '-o', - 'jsonpath={.spec.rules[0].host}', - ingress - ], { timeout: 10000 }) - const hostname = stdout.trim() - return `${protocol}://${hostname}` - } - async cheURL(namespace = ''): Promise { const kube = new KubeHelper() - const protocol = 'http' - let hostname = '' - if (await kube.ingressExist('che', namespace)) { - hostname = await kube.getIngressHost('che', namespace) - } else if (await kube.ingressExist('che-ingress', namespace)) { - hostname = await kube.getIngressHost('che-ingress', namespace) + if (await kube.isOpenShift()) { + return this.cheOpenShiftURL(namespace) } else { - throw new Error('ERR_INGRESS_NO_EXIST') + return this.cheK8sURL(namespace) + } + } + + async cheK8sURL(namespace = ''): Promise { + const kube = new KubeHelper() + const ingress_names = ['che', 'che-ingress'] + for (const ingress_name of ingress_names) { + if (await kube.ingressExist(ingress_name, namespace)) { + const protocol = await kube.getIngressProtocol(ingress_name, namespace) + const hostname = await kube.getIngressHost(ingress_name, namespace) + return `${protocol}://${hostname}` + } } - return `${protocol}://${hostname}` + throw new Error(`ERR_INGRESS_NO_EXIST - No ingress ${ingress_names} in namespace ${namespace}`) + } + + async cheOpenShiftURL(namespace = ''): Promise { + const oc = new OpenShiftHelper() + const route_names = ['che', 'che-host'] + for (const route_name of route_names) { + if (await oc.routeExist(route_name, namespace)) { + const protocol = await oc.getRouteProtocol(route_name, namespace) + const hostname = await oc.getRouteHost(route_name, namespace) + return `${protocol}://${hostname}` + } + } + throw new Error(`ERR_ROUTE_NO_EXIST - No route ${route_names} in namespace ${namespace}`) } async cheNamespaceExist(namespace = '') { @@ -115,12 +122,56 @@ export class CheHelper { } } + async getCheServerStatus(cheURL: string, responseTimeoutMs = this.defaultCheResponseTimeoutMs): Promise { + const endpoint = `${cheURL}/api/system/state` + let response = null + try { + response = await axios.get(endpoint, { timeout: responseTimeoutMs }) + } catch (error) { + throw this.getCheApiError(error, endpoint) + } + if (!response || response.status !== 200 || !response.data || !response.data.status) { + throw new Error('E_BAD_RESP_CHE_API') + } + return response.data.status + } + + async startShutdown(cheURL: string, accessToken = '', responseTimeoutMs = this.defaultCheResponseTimeoutMs) { + const endpoint = `${cheURL}/api/system/stop?shutdown=true` + const headers = accessToken ? {Authorization: `${accessToken}`} : null + let response = null + try { + response = await axios.post(endpoint, null, { headers, timeout: responseTimeoutMs }) + } catch (error) { + if (error.response && error.response.status === 409) { + return + } else { + throw this.getCheApiError(error, endpoint) + } + } + if (!response || response.status !== 204) { + throw new Error('E_BAD_RESP_CHE_API') + } + } + + async waitUntilReadyToShutdown(cheURL: string, intervalMs = 500, timeoutMs = 60000) { + const iterations = timeoutMs / intervalMs + for (let index = 0; index < iterations; index++) { + let status = await this.getCheServerStatus(cheURL) + if (status === 'READY_TO_SHUTDOWN') { + return + } + await cli.wait(intervalMs) + } + throw new Error('ERR_TIMEOUT') + } + async isCheServerReady(cheURL: string, namespace = '', responseTimeoutMs = this.defaultCheResponseTimeoutMs): Promise { if (!await this.cheNamespaceExist(namespace)) { return false } - await axios.interceptors.response.use(response => response, async (error: any) => { + const id = await axios.interceptors.response.use(response => response, async (error: any) => { if (error.config && error.response && (error.response.status === 404 || error.response.status === 503)) { return axios.request(error.config) } @@ -129,8 +180,10 @@ export class CheHelper { try { await axios.get(`${cheURL}/api/system/state`, { timeout: responseTimeoutMs }) + await axios.interceptors.response.eject(id) return true } catch { + await axios.interceptors.response.eject(id) return false } } @@ -139,78 +192,96 @@ export class CheHelper { if (!await this.cheNamespaceExist(namespace)) { throw new Error('E_BAD_NS') } - + let url = await this.cheURL(namespace) + let endpoint = `${url}/api/devfile` let devfile - + let response try { - let url = await this.cheURL(namespace) devfile = fs.readFileSync(devfilePath, 'utf8') - let response = await axios.post(`${url}/api/devfile`, devfile, {headers: {'Content-Type': 'text/yaml'}}) - if (response && response.data && response.data.links && response.data.links.ide) { - let ideURL = response.data.links.ide - return this.buildDashboardURL(ideURL) - } else { - throw new Error('E_BAD_RESP_CHE_SERVER') - } + response = await axios.post(endpoint, devfile, {headers: {'Content-Type': 'text/yaml'}}) } catch (error) { if (!devfile) { throw new Error(`E_NOT_FOUND_DEVFILE - ${devfilePath} - ${error.message}`) } if (error.response && error.response.status === 400) { throw new Error(`E_BAD_DEVFILE_FORMAT - Message: ${error.response.data.message}`) } - if (error.response) { - // The request was made and the server responded with a status code - // that falls out of the range of 2xx - throw new Error(`E_CHE_SERVER_UNKNOWN_ERROR - Status: ${error.response.status}`) - } else if (error.request) { - // The request was made but no response was received - // `error.request` is an instance of XMLHttpRequest in the browser and an instance of - // http.ClientRequest in node.js - throw new Error(`E_CHE_SERVER_NO_RESPONSE - ${error.message}`) - } else { - // Something happened in setting up the request that triggered an Error - throw new Error(`E_CHECTL_UNKNOWN_ERROR - Message: ${error.message}`) - } + throw this.getCheApiError(error, endpoint) } + if (response && response.data && response.data.links && response.data.links.ide) { + let ideURL = response.data.links.ide + return this.buildDashboardURL(ideURL) + } else { + throw new Error('E_BAD_RESP_CHE_SERVER') + } + } async createWorkspaceFromWorkspaceConfig(namespace: string | undefined, workspaceConfigPath = ''): Promise { if (!await this.cheNamespaceExist(namespace)) { throw new Error('E_BAD_NS') } - + let url = await this.cheURL(namespace) + let endpoint = `${url}/api/workspace` let workspaceConfig + let response try { - let url = await this.cheURL(namespace) let workspaceConfig = fs.readFileSync(workspaceConfigPath, 'utf8') - let response = await axios.post(`${url}/api/workspace`, workspaceConfig, {headers: {'Content-Type': 'application/json'}}) - if (response && response.data && response.data.links && response.data.links.ide) { - let ideURL = response.data.links.ide - return this.buildDashboardURL(ideURL) - } else { - throw new Error('E_BAD_RESP_CHE_SERVER') - } + response = await axios.post(endpoint, workspaceConfig, {headers: {'Content-Type': 'application/json'}}) } catch (error) { if (!workspaceConfig) { throw new Error(`E_NOT_FOUND_WORKSPACE_CONFIG_FILE - ${workspaceConfigPath} - ${error.message}`) } if (error.response && error.response.status === 400) { throw new Error(`E_BAD_WORKSPACE_CONFIG_FORMAT - Message: ${error.response.data.message}`) } - if (error.response) { - // The request was made and the server responded with a status code - // that falls out of the range of 2xx - throw new Error(`E_CHE_SERVER_UNKNOWN_ERROR - Status: ${error.response.status}`) - } else if (error.request) { - // The request was made but no response was received - // `error.request` is an instance of XMLHttpRequest in the browser and an instance of - // http.ClientRequest in node.js - throw new Error(`E_CHE_SERVER_NO_RESPONSE - ${error.message}`) + throw this.getCheApiError(error, endpoint) + } + if (response && response.data && response.data.links && response.data.links.ide) { + let ideURL = response.data.links.ide + return this.buildDashboardURL(ideURL) + } else { + throw new Error('E_BAD_RESP_CHE_SERVER') + } + } + + async isAuthenticationEnabled(cheURL: string, responseTimeoutMs = this.defaultCheResponseTimeoutMs): Promise { + const endpoint = `${cheURL}/api/keycloak/settings` + let response = null + try { + response = await axios.get(endpoint, { timeout: responseTimeoutMs }) + } catch (error) { + if (error.response && (error.response.status === 404 || error.response.status === 503)) { + return false } else { - // Something happened in setting up the request that triggered an Error - throw new Error(`E_CHECTL_UNKNOWN_ERROR - Message: ${error.message}`) + throw this.getCheApiError(error, endpoint) } } + if (!response || response.status !== 200 || !response.data) { + throw new Error('E_BAD_RESP_CHE_API') + } + return true } async buildDashboardURL(ideURL: string): Promise { return ideURL.replace(/\/[^/|.]*\/[^/|.]*$/g, '\/dashboard\/#\/ide$&') } + + private getCheApiError(error: any, endpoint: string): Error { + if (error.response && error.response.status === 403) { + return new Error(`E_CHE_API_FORBIDDEN - Endpoint: ${endpoint} - Message: ${JSON.stringify(error.response.data.message)}`) + } + if (error.response && error.response.status === 401) { + return new Error(`E_CHE_API_UNAUTHORIZED - Endpoint: ${endpoint} - Message: ${JSON.stringify(error.response.data)}`) + } + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + return new Error(`E_CHE_API_UNKNOWN_ERROR - Endpoint: ${endpoint} -Status: ${error.response.status}`) + } else if (error.request) { + // The request was made but no response was received + // `error.request` is an instance of XMLHttpRequest in the browser and an instance of + // http.ClientRequest in node.js + return new Error(`E_CHE_API_NO_RESPONSE - Endpoint: ${endpoint} - Error message: ${error.message}`) + } else { + // Something happened in setting up the request that triggered an Error + return new Error(`E_CHECTL_UNKNOWN_ERROR - Endpoint: ${endpoint} - Message: ${error.message}`) + } + } } diff --git a/src/api/kube.ts b/src/api/kube.ts index 28415fd08..65710e600 100644 --- a/src/api/kube.ts +++ b/src/api/kube.ts @@ -9,11 +9,12 @@ **********************************************************************/ // tslint:disable:object-curly-spacing -import { Apps_v1Api, Core_v1Api, Extensions_v1beta1Api, KubeConfig, RbacAuthorization_v1Api, V1ConfigMap, V1ConfigMapEnvSource, V1Container, V1DeleteOptions, V1Deployment, V1DeploymentSpec, V1EnvFromSource, V1LabelSelector, V1ObjectMeta, V1Pod, V1PodSpec, V1PodTemplateSpec, V1RoleBinding, V1RoleRef, V1ServiceAccount, V1Subject } from '@kubernetes/client-node' +import { ApisApi, Apps_v1Api, Core_v1Api, Extensions_v1beta1Api, KubeConfig, RbacAuthorization_v1Api, V1ConfigMap, V1ConfigMapEnvSource, V1Container, V1DeleteOptions, V1Deployment, V1DeploymentSpec, V1EnvFromSource, V1LabelSelector, V1ObjectMeta, V1Pod, V1PodSpec, V1PodTemplateSpec, V1RoleBinding, V1RoleRef, V1ServiceAccount, V1Subject } from '@kubernetes/client-node' +import axios from 'axios' import { cli } from 'cli-ux' import { readFileSync } from 'fs' +import https = require('https') import * as yaml from 'js-yaml' - export class KubeHelper { kc = new KubeConfig() @@ -25,6 +26,22 @@ export class KubeHelper { } } + async deleteAllServices(namespace = '') { + const k8sApi = this.kc.makeApiClient(Core_v1Api) + try { + const res = await k8sApi.listNamespacedService(namespace, 'true') + if (res && res.response && res.response.statusCode === 200) { + const serviceList = res.body + const options = new V1DeleteOptions() + await serviceList.items.forEach(async service => { + await k8sApi.deleteNamespacedService(service.metadata.name, namespace, options) + }) + } + } catch (e) { + throw new Error(e.body.message) + } + } + async serviceAccountExist(name = '', namespace = ''): Promise { const k8sApi = this.kc.makeApiClient(Core_v1Api) try { @@ -50,6 +67,16 @@ export class KubeHelper { } } + async deleteServiceAccount(name = '', namespace = '') { + const k8sCoreApi = this.kc.makeApiClient(Core_v1Api) + try { + const options = new V1DeleteOptions() + await k8sCoreApi.deleteNamespacedServiceAccount(name, namespace, options) + } catch (e) { + throw new Error(e.body.message) + } + } + async roleBindingExist(name = '', namespace = ''): Promise { const k8sRbacAuthApi = this.kc.makeApiClient(RbacAuthorization_v1Api) try { @@ -83,6 +110,16 @@ export class KubeHelper { } } + async deleteRoleBinding(name = '', namespace = '') { + const k8sRbacAuthApi = this.kc.makeApiClient(RbacAuthorization_v1Api) + try { + const options = new V1DeleteOptions() + return await k8sRbacAuthApi.deleteNamespacedRoleBinding(name, namespace, options) + } catch (e) { + throw new Error(e.body.message) + } + } + async configMapExist(name = '', namespace = ''): Promise { const k8sCoreApi = this.kc.makeApiClient(Core_v1Api) try { @@ -115,6 +152,16 @@ export class KubeHelper { } } + async deleteConfigMap(name: string, namespace = '') { + const k8sCoreApi = this.kc.makeApiClient(Core_v1Api) + try { + const options = new V1DeleteOptions() + await k8sCoreApi.deleteNamespacedConfigMap(name, namespace, options) + } catch (e) { + throw new Error(e.body.message) + } + } + async podExist(name = '', namespace = ''): Promise { const k8sCoreApi = this.kc.makeApiClient(Core_v1Api) try { @@ -249,6 +296,21 @@ export class KubeHelper { throw new Error('ERR_TIMEOUT') } + async waitUntilPodIsDeleted(selector: string, namespace = '', intervalMs = 500, timeoutMs = 130000) { + const iterations = timeoutMs / intervalMs + for (let index = 0; index < iterations; index++) { + let readyStatus = await this.getPodReadyConditionStatus(selector, namespace) + if (readyStatus === 'False') { + return + } + if (readyStatus !== 'True') { + throw new Error(`ERR_BAD_READY_STATUS: ${readyStatus} (True or False expected) `) + } + await cli.wait(intervalMs) + } + throw new Error('ERR_TIMEOUT') + } + async deletePod(name: string, namespace = '') { this.kc.loadFromDefault() const k8sCoreApi = this.kc.makeApiClient(Core_v1Api) @@ -260,6 +322,79 @@ export class KubeHelper { } } + async deploymentExist(name = '', namespace = ''): Promise { + const k8sApi = this.kc.makeApiClient(Apps_v1Api) + try { + const res = await k8sApi.readNamespacedDeployment(name, namespace) + return ((res && res.body && + res.body.metadata && res.body.metadata.name + && res.body.metadata.name === name) as boolean) + } catch { + return false + } + } + + async isDeploymentPaused(name = '', namespace = ''): Promise { + const k8sApi = this.kc.makeApiClient(Apps_v1Api) + try { + const res = await k8sApi.readNamespacedDeployment(name, namespace) + if (!res || !res.body || !res.body.spec) { + throw new Error('E_BAD_DEPLOY_RESPONSE') + } + return res.body.spec.paused + } catch (e) { + throw new Error(e.body.message) + } + } + + async pauseDeployment(name = '', namespace = '') { + const k8sApi = this.kc.makeApiClient(PatchedK8sAppsApi) + try { + const patch = { + spec: { + paused: true + } + } + await k8sApi.patchNamespacedDeployment(name, namespace, patch) + } catch (e) { + throw new Error(e.body.message) + } + } + + async resumeDeployment(name = '', namespace = '') { + const k8sApi = this.kc.makeApiClient(PatchedK8sAppsApi) + try { + const patch = { + spec: { + paused: false + } + } + await k8sApi.patchNamespacedDeployment(name, namespace, patch) + } catch (e) { + throw new Error(e.body.message) + } + } + + async scaleDeployment(name = '', namespace = '', replicas: number) { + const k8sAppsApi = this.kc.makeApiClient(PatchedK8sAppsApi) + const patch = { + spec: { + replicas + } + } + let res + try { + res = await k8sAppsApi.patchNamespacedDeploymentScale(name, namespace, patch) + } catch (e) { + if (e.body && e.body.message) throw new Error(e.body.message) + else throw new Error(e) + } + + if (!res || !res.body) { + throw new Error('Patch deployment scale returned an invalid reponse') + } + } + async createDeployment(name: string, image: string, serviceAccount: string, @@ -297,6 +432,15 @@ export class KubeHelper { } } + async deleteAllDeployments(namespace = '') { + const k8sAppsApi = this.kc.makeApiClient(Apps_v1Api) + try { + await k8sAppsApi.deleteCollectionNamespacedDeployment(namespace) + } catch (e) { + throw new Error(e.body.message) + } + } + async createPod(name: string, image: string, serviceAccount: string, @@ -343,6 +487,74 @@ export class KubeHelper { } } + async deleteAllIngresses(namespace = '') { + const k8sExtensionsApi = this.kc.makeApiClient(Extensions_v1beta1Api) + try { + await k8sExtensionsApi.deleteCollectionNamespacedIngress(namespace) + } catch (e) { + throw new Error(e.body.message) + } + } + + async checkKubeApi() { + const currentCluster = this.kc.getCurrentCluster() + if (!currentCluster) { + throw new Error('Failed to get current Kubernetes cluster: returned null') + } + const agent = new https.Agent({ + rejectUnauthorized: false + }) + let endpoint = '' + try { + endpoint = `${currentCluster.server}/healthz` + let response = await axios.get(`${endpoint}`, { httpsAgent: agent }) + if (!response || response.status !== 200 || response.data !== 'ok') { + throw new Error('E_BAD_RESP_K8S_API') + } + } catch (error) { + if (error.response && error.response.status === 403) { + throw new Error(`E_K8S_API_FORBIDDEN - Message: ${error.response.data.message}`) + } + if (error.response && error.response.status === 401) { + throw new Error(`E_K8S_API_UNAUTHORIZED - Message: ${error.response.data.message}`) + } + if (error.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + throw new Error(`E_K8S_API_UNKNOWN_ERROR - Status: ${error.response.status}`) + } else if (error.request) { + // The request was made but no response was received + // `error.request` is an instance of XMLHttpRequest in the browser and an instance of + // http.ClientRequest in node.js + throw new Error(`E_K8S_API_NO_RESPONSE - Endpoint: ${endpoint} - Error message: ${error.message}`) + } else { + // Something happened in setting up the request that triggered an Error + throw new Error(`E_CHECTL_UNKNOWN_ERROR - Message: ${error.message}`) + } + } + } + + async isOpenShift(): Promise { + const k8sApiApi = this.kc.makeApiClient(ApisApi) + let res + try { + res = await k8sApiApi.getAPIVersions() + } catch (e) { + if (e.body && e.body.message) throw new Error(e.body.message) + else throw new Error(e) + } + if (!res || !res.body) { + throw new Error('Get API versions returned an invalid reponse') + } + const v1APIGroupList = res.body + for (const v1APIGroup of v1APIGroupList.groups) { + if (v1APIGroup.name === 'apps.openshift.io') { + return true + } + } + return false + } + async getIngressHost(name = '', namespace = ''): Promise { const k8sExtensionsApi = this.kc.makeApiClient(Extensions_v1beta1Api) try { @@ -359,6 +571,47 @@ export class KubeHelper { else throw new Error(e) } } + + async getIngressProtocol(name = '', namespace = ''): Promise { + const k8sExtensionsApi = this.kc.makeApiClient(Extensions_v1beta1Api) + try { + const res = await k8sExtensionsApi.readNamespacedIngress(name, namespace) + if (!res || !res.body || !res.body.spec) { + throw new Error('ERR_INGRESS_NO_HOST') + } + if (res.body.spec.tls && res.body.spec.tls.length > 0) { + return 'https' + } else { + return 'http' + } + } catch (e) { + if (e.body && e.body.message) throw new Error(e.body.message) + else throw new Error(e) + } + } + + async persistentVolumeClaimExist(name = '', namespace = ''): Promise { + const k8sCoreApi = this.kc.makeApiClient(Core_v1Api) + try { + const res = await k8sCoreApi.readNamespacedPersistentVolumeClaim(name, namespace) + return (res && res.body && + res.body.metadata && res.body.metadata.name + && res.body.metadata.name === name) + } catch { + return false + } + } + + async deletePersistentVolumeClaim(name = '', namespace = '') { + const k8sCoreApi = this.kc.makeApiClient(Core_v1Api) + try { + const options = new V1DeleteOptions() + await k8sCoreApi.deleteNamespacedPersistentVolumeClaim(name, namespace, options) + } catch (e) { + throw new Error(e.body.message) + } + } + } class PatchedK8sApi extends Core_v1Api { @@ -373,3 +626,26 @@ class PatchedK8sApi extends Core_v1Api { return returnValue } } + +class PatchedK8sAppsApi extends Apps_v1Api { + patchNamespacedDeployment(...args: any) { + const oldDefaultHeaders = this.defaultHeaders + this.defaultHeaders = { + 'Content-Type': 'application/strategic-merge-patch+json', + ...this.defaultHeaders, + } + const returnValue = super.patchNamespacedDeployment.apply(this, args) + this.defaultHeaders = oldDefaultHeaders + return returnValue + } + patchNamespacedDeploymentScale(...args: any) { + const oldDefaultHeaders = this.defaultHeaders + this.defaultHeaders = { + 'Content-Type': 'application/strategic-merge-patch+json', + ...this.defaultHeaders, + } + const returnValue = super.patchNamespacedDeploymentScale.apply(this, args) + this.defaultHeaders = oldDefaultHeaders + return returnValue + } +} diff --git a/src/api/openshift.ts b/src/api/openshift.ts index 03db33a9c..378f9327b 100644 --- a/src/api/openshift.ts +++ b/src/api/openshift.ts @@ -12,10 +12,48 @@ import execa = require('execa') export class OpenShiftHelper { - async getHostByRouteName(routeName: string, namespace = ''): Promise { + async getRouteHost(name: string, namespace = ''): Promise { const command = 'oc' - const args = ['get', 'route', '--namespace', namespace, '-o', `jsonpath={range.items[?(.metadata.name=='${routeName}')]}{.spec.host}{end}`] - const { stdout } = await execa(command, args, { timeout: 10000 }) + const args = ['get', 'route', '--namespace', namespace, '-o', `jsonpath={range.items[?(.metadata.name=='${name}')]}{.spec.host}{end}`] + const { stdout } = await execa(command, args, { timeout: 60000 }) return stdout.trim() } + async getRouteProtocol(name: string, namespace = ''): Promise { + const command = 'oc' + const args = ['get', 'route', '--namespace', namespace, '-o', `jsonpath={range.items[?(.metadata.name=='${name}')]}{.spec.tls.termination}{end}`] + const { stdout } = await execa(command, args, { timeout: 60000 }) + const termination = stdout.trim() + if (termination && termination.includes('edge') || termination.includes('passthrough') || termination.includes('reencrypt')) { + return 'https' + } else { + return 'http' + } + } + async routeExist(name: string, namespace = ''): Promise { + const command = 'oc' + const args = ['get', 'route', '--namespace', namespace, '-o', `jsonpath={range.items[?(.metadata.name=='${name}')]}{.metadata.name}{end}`] + const { stdout } = await execa(command, args, { timeout: 60000 }) + return stdout.trim().includes(name) + } + async deleteAllRoutes(namespace = '') { + const command = 'oc' + const args = ['delete', 'route', '--all', '--namespace', namespace] + await execa(command, args, { timeout: 60000 }) + } + async deploymentConfigExist(name = '', namespace = ''): Promise { + const command = 'oc' + const args = ['get', 'deploymentconfig', '--namespace', namespace, '-o', `jsonpath={range.items[?(.metadata.name=='${name}')]}{.metadata.name}{end}`] + const { stdout } = await execa(command, args, { timeout: 60000 }) + return stdout.trim().includes(name) + } + async scaleDeploymentConfig(name = '', namespace = '', replicas: number) { + const command = 'oc' + const args = ['scale', 'deploymentconfig', '--namespace', namespace, name, `--replicas=${replicas}`] + await execa(command, args, { timeout: 60000 }) + } + async deleteAllDeploymentConfigs(namespace = '') { + const command = 'oc' + const args = ['delete', 'deploymentconfig', '--all', '--namespace', namespace] + await execa(command, args, { timeout: 60000 }) + } } diff --git a/src/commands/server/delete.ts b/src/commands/server/delete.ts new file mode 100644 index 000000000..9ed32a3ce --- /dev/null +++ b/src/commands/server/delete.ts @@ -0,0 +1,197 @@ +/********************************************************************* + * Copyright (c) 2019 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ +// tslint:disable:object-curly-spacing +import { Command, flags } from '@oclif/command' +import { string } from '@oclif/parser/lib/flags' +import * as commandExists from 'command-exists' + +import { KubeHelper } from '../../api/kube' +import { OpenShiftHelper } from '../../api/openshift' +import { HelmHelper } from '../../installers/helm' +import { MinishiftAddonHelper } from '../../installers/minishift-addon' + +export default class Delete extends Command { + static description = 'delete any Che related resource: Kubernetes/OpenShift/Helm' + + static flags = { + help: flags.help({ char: 'h' }), + chenamespace: string({ + char: 'n', + description: 'Kubernetes namespace where Che was deployed', + default: 'che', + env: 'CHE_NAMESPACE' + }), + 'listr-renderer': string({ + description: 'Listr renderer. Can be \'default\', \'silent\' or \'verbose\'', + default: 'default' + }), + } + + async run() { + const { flags } = this.parse(Delete) + const Listr = require('listr') + const notifier = require('node-notifier') + const kh = new KubeHelper() + const oh = new OpenShiftHelper() + const helm = new HelmHelper() + const msAddon = new MinishiftAddonHelper() + const tasks = new Listr([ + { + title: 'Verify Kubernetes API', + task: async (ctx: any, task: any) => { + try { + await kh.checkKubeApi() + ctx.isOpenShift = await kh.isOpenShift() + task.title = await `${task.title}...OK` + if (ctx.isOpenShift) { + task.title = await `${task.title} (it's OpenShift)` + } + } catch (error) { + this.error(`Failed to connect to Kubernetes API. ${error.message}`) + } + } + }, + { + title: 'Delete all deployments', + task: async (_ctx: any, task: any) => { + await kh.deleteAllDeployments(flags.chenamespace) + task.title = await `${task.title}...OK` + } + }, + { + title: 'Delete all deployment configs', + enabled: (ctx: any) => ctx.isOpenShift, + task: async (_ctx: any, task: any) => { + await oh.deleteAllDeploymentConfigs(flags.chenamespace) + task.title = await `${task.title}...OK` + } + }, + { + title: 'Delete all services', + task: async (_ctx: any, task: any) => { + await kh.deleteAllServices(flags.chenamespace) + task.title = await `${task.title}...OK` + } + }, + { + title: 'Delete all ingresses', + enabled: (ctx: any) => !ctx.isOpenShift, + task: async (_ctx: any, task: any) => { + await kh.deleteAllIngresses(flags.chenamespace) + task.title = await `${task.title}...OK` + } + }, + { + title: 'Delete all routes', + enabled: (ctx: any) => ctx.isOpenShift, + task: async (_ctx: any, task: any) => { + await oh.deleteAllRoutes(flags.chenamespace) + task.title = await `${task.title}...OK` + } + }, + { + title: 'Delete configmaps che and che-operator', + task: async (_ctx: any, task: any) => { + if (await kh.configMapExist('che', flags.chenamespace)) { + await kh.deleteConfigMap('che', flags.chenamespace) + } + if (await kh.configMapExist('che-operator', flags.chenamespace)) { + await kh.deleteConfigMap('che-operator', flags.chenamespace) + } + task.title = await `${task.title}...OK` + } + }, + { + title: 'Delete rolebindings che, che-operator, che-workspace-exec and che-workspace-view', + task: async (_ctx: any, task: any) => { + if (await kh.roleBindingExist('che', flags.chenamespace)) { + await kh.deleteRoleBinding('che', flags.chenamespace) + } + if (await kh.roleBindingExist('che-operator', flags.chenamespace)) { + await kh.deleteRoleBinding('che-operator', flags.chenamespace) + } + if (await kh.roleBindingExist('che-workspace-exec', flags.chenamespace)) { + await kh.deleteRoleBinding('che-workspace-exec', flags.chenamespace) + } + if (await kh.roleBindingExist('che-workspace-view', flags.chenamespace)) { + await kh.deleteRoleBinding('che-workspace-view', flags.chenamespace) + } + task.title = await `${task.title}...OK` + } + }, + { + title: 'Delete service accounts che, che-operator, che-workspace', + task: async (_ctx: any, task: any) => { + if (await kh.serviceAccountExist('che', flags.chenamespace)) { + await kh.deleteServiceAccount('che', flags.chenamespace) + } + if (await kh.roleBindingExist('che-operator', flags.chenamespace)) { + await kh.deleteServiceAccount('che-operator', flags.chenamespace) + } + if (await kh.roleBindingExist('che-workspace', flags.chenamespace)) { + await kh.deleteServiceAccount('che-workspace', flags.chenamespace) + } + task.title = await `${task.title}...OK` + } + }, + { + title: 'Delete PVC postgres-data and che-data-volume', + task: async (_ctx: any, task: any) => { + if (await kh.persistentVolumeClaimExist('che-operator', flags.chenamespace)) { + await kh.deletePersistentVolumeClaim('postgres-data', flags.chenamespace) + } + task.title = await `${task.title}...OK` + } + }, + { + title: 'Delete pod che-operator', + task: async (_ctx: any, task: any) => { + if (await kh.podExist('che-operator', flags.chenamespace)) { + await kh.deletePod('che-operator', flags.chenamespace) + } + task.title = await `${task.title}...OK` + } + }, + { + title: 'Purge che Helm chart', + enabled: (ctx: any) => !ctx.isOpenShift, + task: async (_ctx: any, task: any) => { + if (await !commandExists.sync('helm')) { + task.title = await `${task.title}...OK (Helm not found)` + } else { + await helm.purgeHelmChart('che') + task.title = await `${task.title}...OK` + } + } + }, + { + title: 'Remove Che minishift addon', + enabled: (ctx: any) => ctx.isOpenShift, + task: async (_ctx: any, task: any) => { + if (!commandExists.sync('minishift')) { + task.title = await `${task.title}...OK (minishift not found)` + } else { + await msAddon.removeAddon() + task.title = await `${task.title}...OK` + } + } + }, + ], {renderer: flags['listr-renderer'] as any}) + + await tasks.run() + + notifier.notify({ + title: 'chectl', + message: 'Command server:update has completed.' + }) + + this.exit(0) + } +} diff --git a/src/commands/server/start.ts b/src/commands/server/start.ts index 7efef52d8..f84c4a430 100644 --- a/src/commands/server/start.ts +++ b/src/commands/server/start.ts @@ -18,7 +18,6 @@ import * as path from 'path' import { CheHelper } from '../../api/che' import { KubeHelper } from '../../api/kube' -import { OpenShiftHelper } from '../../api/openshift' import { HelmHelper } from '../../installers/helm' import { MinishiftAddonHelper } from '../../installers/minishift-addon' import { OperatorHelper } from '../../installers/operator' @@ -54,10 +53,9 @@ export default class Start extends Command { required: true, env: 'CHE_SERVER_BOOT_TIMEOUT' }), - debug: flags.boolean({ - char: 'd', - description: 'Starts chectl in debug mode', - default: false + 'listr-renderer': string({ + description: 'Listr renderer. Can be \'default\', \'silent\' or \'verbose\'', + default: 'default' }), multiuser: flags.boolean({ char: 'm', @@ -126,11 +124,9 @@ export default class Start extends Command { const che = new CheHelper() const operator = new OperatorHelper() const minishiftAddon = new MinishiftAddonHelper() - const listr_renderer = (flags.debug) ? 'verbose' : 'default' - let ingressName = 'che-ingress' // Platform Checks - let platformCheckTasks = new Listr(undefined, {renderer: listr_renderer, collapse: false}) + let platformCheckTasks = new Listr(undefined, {renderer: flags['listr-renderer'] as any, collapse: false}) if (flags.platform === 'minikube') { platformCheckTasks.add({ title: '✈️ Minikube preflight checklist', @@ -147,7 +143,7 @@ export default class Start extends Command { } // Installer - let installerTasks = new Listr({renderer: listr_renderer, collapse: false}) + let installerTasks = new Listr({renderer: flags['listr-renderer'] as any, collapse: false}) if (flags.installer === 'helm') { installerTasks.add({ title: '🏃‍ Running Helm to install Che', @@ -157,7 +153,6 @@ export default class Start extends Command { // The operator installs Che multiuser only flags.multiuser = true // Installers use distinct ingress names - ingressName = 'che' installerTasks.add({ title: '🏃‍ Running the Che Operator', task: () => operator.startTasks(flags, this) @@ -166,7 +161,6 @@ export default class Start extends Command { // minishift-addon supports Che singleuser only flags.multiuser = false // Installers use distinct ingress names - ingressName = 'che' installerTasks.add({ title: '🏃‍ Running the Che minishift-addon', task: () => minishiftAddon.startTasks(flags) @@ -182,7 +176,7 @@ export default class Start extends Command { title: '✅ Post installation checklist', task: () => cheBootstrapSubTasks }], { - renderer: listr_renderer, + renderer: flags['listr-renderer'] as any, collapse: false }) @@ -205,15 +199,7 @@ export default class Start extends Command { cheBootstrapSubTasks.add({ title: 'Retrieving Che Server URL', task: async (ctx: any, task: any) => { - if (flags.platform === 'minikube') { - ctx.cheURL = await che.cheURLByIngress(ingressName, flags.chenamespace) - } else if (flags.platform === 'minishift') { - const os = new OpenShiftHelper() - const hostname = await os.getHostByRouteName(ingressName, flags.chenamespace) - const protocol = flags.tls ? 'https' : 'http' - ctx.cheURL = `${protocol}://${hostname}` - } - + ctx.cheURL = await che.cheURL(flags.chenamespace) task.title = await `${task.title}...${ctx.cheURL}` } }) @@ -227,6 +213,7 @@ export default class Start extends Command { await platformCheckTasks.run() await installerTasks.run() await cheStartCheckTasks.run() + this.log('Command server:start has completed successfully.') } catch (err) { this.error(err) } @@ -235,6 +222,8 @@ export default class Start extends Command { title: 'chectl', message: 'Command server:start has completed successfully.' }) + + this.exit(0) } podStartTasks(selector: string, namespace = ''): Listr { diff --git a/src/commands/server/stop.ts b/src/commands/server/stop.ts index 2a1cd0808..ad1c7306e 100644 --- a/src/commands/server/stop.ts +++ b/src/commands/server/stop.ts @@ -11,10 +11,10 @@ import { Command, flags } from '@oclif/command' import { string } from '@oclif/parser/lib/flags' -import * as commandExists from 'command-exists' -import * as execa from 'execa' import { CheHelper } from '../../api/che' +import { KubeHelper } from '../../api/kube' +import { OpenShiftHelper } from '../../api/openshift' export default class Stop extends Command { static description = 'stop Eclipse Che Server' @@ -27,6 +27,24 @@ export default class Stop extends Command { default: 'che', env: 'CHE_NAMESPACE' }), + 'deployment-name': string({ + description: 'Che deployment name', + default: 'che', + env: 'CHE_DEPLOYMENT' + }), + 'che-selector': string({ + description: 'Selector for Che Server resources', + default: 'app=che', + env: 'CHE_SELECTOR' + }), + 'access-token': string({ + description: 'Che OIDC Access Token', + env: 'CHE_ACCESS_TOKEN' + }), + 'listr-renderer': string({ + description: 'Listr renderer. Can be \'default\', \'silent\' or \'verbose\'', + default: 'default' + }) } async run() { @@ -34,24 +52,177 @@ export default class Stop extends Command { const Listr = require('listr') const notifier = require('node-notifier') const che = new CheHelper() + const kh = new KubeHelper() + const oc = new OpenShiftHelper() const tasks = new Listr([ { - title: `Verify if namespace ${flags.chenamespace} exist`, - task: async () => { - if (!await che.cheNamespaceExist(flags.chenamespace)) { - this.error(`E_BAD_NS - Namespace does not exist.\nThe Kubernetes Namespace "${flags.chenamespace}" doesn't exist, Che Server cannot be stopped.\nFix with: verify the namespace where Che is running (kubectl get --all-namespaces deployment | grep che)\nhttps://github.com/eclipse/che`, {code: 'EBADNS'}) + title: 'Verify Kubernetes API', + task: async (ctx: any, task: any) => { + try { + await kh.checkKubeApi() + ctx.isOpenShift = await kh.isOpenShift() + task.title = await `${task.title}...done` + if (ctx.isOpenShift) { + task.title = await `${task.title} (it's OpenShift)` + } + } catch (error) { + this.error(`Failed to connect to Kubernetes API. ${error.message}`) + } + } + }, + { + title: `Verify if deployment \"${flags['deployment-name']}\" exist in namespace \"${flags.chenamespace}\"`, + task: async (ctx: any, task: any) => { + if (ctx.isOpenShift && await oc.deploymentConfigExist(flags['deployment-name'], flags.chenamespace)) { + // minishift addon and the openshift templates use a deployment config + ctx.deploymentConfigExist = true + ctx.foundKeycloakDeployment = await oc.deploymentConfigExist('keycloak', flags.chenamespace) + ctx.foundPostgresDeployment = await oc.deploymentConfigExist('postgres', flags.chenamespace) + if (ctx.foundKeycloakDeployment && ctx.foundPostgresDeployment) { + task.title = await `${task.title}...the dc "${flags['deployment-name']}" exists (as well as keycloak and postgres)` + } else { + task.title = await `${task.title}...the dc "${flags['deployment-name']}" exists` + } + } else if (await kh.deploymentExist(flags['deployment-name'], flags.chenamespace)) { + // helm chart and Che operator use a deployment + ctx.foundKeycloakDeployment = await kh.deploymentExist('keycloak', flags.chenamespace) + ctx.foundPostgresDeployment = await kh.deploymentExist('postgres', flags.chenamespace) + if (ctx.foundKeycloakDeployment && ctx.foundPostgresDeployment) { + task.title = await `${task.title}...it does (as well as keycloak and postgres)` + } else { + task.title = await `${task.title}...it does` + } + } else { + this.error(`E_BAD_DEPLOY - Deployment and DeploymentConfig do not exist.\nNeither a Deployment nor a DeploymentConfig named "${flags['deployment-name']}" exist in namespace \"${flags.chenamespace}\", Che Server cannot be stopped.\nFix with: verify the namespace where Che is running (oc get projects)\nhttps://github.com/eclipse/che`, {code: 'E_BAD_DEPLOY'}) + } + } + }, + { + title: `Verify if Che server pod is running (selector "${flags['che-selector']}")`, + task: async (ctx: any, task: any) => { + const cheServerPodExist = await kh.podsExistBySelector(flags['che-selector'] as string, flags.chenamespace) + if (!cheServerPodExist) { + task.title = `${task.title}...It doesn't. +Che server was already stopped.` + ctx.isAlreadyStopped = true + } else { + const cheServerPodReadyStatus = await kh.getPodReadyConditionStatus(flags['che-selector'] as string, flags.chenamespace) + if (cheServerPodReadyStatus !== 'True') { + task.title = `${task.title}...It doesn't. +Che server is not ready yet. Try again in a few seconds.` + ctx.isNotReadyYet = true + } else { + task.title = `${task.title}...done.` + } } } }, { - title: 'Verify if helm is installed', - task: () => this.checkIfInstalled('helm') + title: 'Check Che server status', + enabled: (ctx: any) => !ctx.isAlreadyStopped && !ctx.isNotReadyYet, + task: async (ctx: any, task: any) => { + let cheURL = '' + try { + cheURL = await che.cheURL(flags.chenamespace) + const status = await che.getCheServerStatus(cheURL) + ctx.isAuthEnabled = await che.isAuthenticationEnabled(cheURL) + const auth = ctx.isAuthEnabled ? '(auth enabled)' : '(auth disabled)' + task.title = await `${task.title}...${status} ${auth}` + } catch (error) { + this.error(`E_CHECK_CHE_STATUS_FAIL - Failed to check Che status (URL: ${cheURL}). ${error.message}`) + } + } }, { - title: 'Stopping Che server', - task: () => this.deleteChe() + title: 'Stop Che server and wait until it\'s ready to shutdown', + enabled: (ctx: any) => !ctx.isAlreadyStopped && !ctx.isNotReadyYet, + task: async (ctx: any, task: any) => { + if (ctx.isAuthEnabled && !flags['access-token']) { + this.error('E_AUTH_REQUIRED - Che authentication is enabled and an access token need to be provided (flag --access-token).\nFor instructions to retreive a valid access token refer to https://www.eclipse.org/che/docs/che-6/authentication.html') + } + try { + const cheURL = await che.cheURL(flags.chenamespace) + await che.startShutdown(cheURL, flags['access-token']) + await che.waitUntilReadyToShutdown(cheURL) + task.title = await `${task.title}...done` + } catch (error) { + this.error(`E_SHUTDOWN_CHE_SERVER_FAIL - Failed to shutdown Che server. ${error.message}`) + } + } + }, + { + title: `Scale \"${flags['deployment-name']}\" deployment to zero`, + enabled: (ctx: any) => !ctx.isAlreadyStopped && !ctx.isNotReadyYet, + task: async (ctx: any, task: any) => { + try { + if (ctx.deploymentConfigExist) { + await oc.scaleDeploymentConfig(flags['deployment-name'], flags.chenamespace, 0) + } else { + await kh.scaleDeployment(flags['deployment-name'], flags.chenamespace, 0) + } + task.title = await `${task.title}...done` + } catch (error) { + this.error(`E_SCALE_DEPLOY_FAIL - Failed to scale deployment. ${error.message}`) + } + } }, - ]) + { + title: 'Wait until Che pod is deleted', + enabled: (ctx: any) => !ctx.isAlreadyStopped && !ctx.isNotReadyYet, + task: async (_ctx: any, task: any) => { + await kh.waitUntilPodIsDeleted('app=che', flags.chenamespace) + task.title = `${task.title}...done.` + } + }, + { + title: 'Scale \"keycloak\" deployment to zero', + enabled: (ctx: any) => !ctx.isAlreadyStopped && !ctx.isNotReadyYet && ctx.foundKeycloakDeployment, + task: async (ctx: any, task: any) => { + try { + if (ctx.deploymentConfigExist) { + await oc.scaleDeploymentConfig('keycloak', flags.chenamespace, 0) + } else { + await kh.scaleDeployment('keycloak', flags.chenamespace, 0) + } + task.title = await `${task.title}...done` + } catch (error) { + this.error(`E_SCALE_DEPLOY_FAIL - Failed to scale keycloak deployment. ${error.message}`) + } + } + }, + { + title: 'Wait until Keycloak pod is deleted', + enabled: (ctx: any) => !ctx.isAlreadyStopped && !ctx.isNotReadyYet && ctx.foundKeycloakDeployment, + task: async (_ctx: any, task: any) => { + await kh.waitUntilPodIsDeleted('app=keycloak', flags.chenamespace) + task.title = `${task.title}...done.` + } + }, + { + title: 'Scale \"postgres\" deployment to zero', + enabled: (ctx: any) => !ctx.isAlreadyStopped && !ctx.isNotReadyYet && ctx.foundKeycloakDeployment, + task: async (ctx: any, task: any) => { + try { + if (ctx.deploymentConfigExist) { + await oc.scaleDeploymentConfig('postgres', flags.chenamespace, 0) + } else { + await kh.scaleDeployment('postgres', flags.chenamespace, 0) + } + task.title = await `${task.title}...done` + } catch (error) { + this.error(`E_SCALE_DEPLOY_FAIL - Failed to scale postgres deployment. ${error.message}`) + } + } + }, + { + title: 'Wait until Postgres pod is deleted', + enabled: (ctx: any) => !ctx.isAlreadyStopped && !ctx.isNotReadyYet && ctx.foundKeycloakDeployment, + task: async (_ctx: any, task: any) => { + await kh.waitUntilPodIsDeleted('app=postgres', flags.chenamespace) + task.title = `${task.title}...done.` + } + }, + ], {renderer: flags['listr-renderer'] as any}) try { await tasks.run() @@ -63,16 +234,7 @@ export default class Stop extends Command { title: 'chectl', message: 'Command server:stop has completed.' }) - } - - checkIfInstalled(commandName: string) { - if (!commandExists.sync(commandName)) { - throw new Error(`ERROR: ${commandName} is not installed.`) - } - } - async deleteChe() { - let command = 'helm delete che --purge' - await execa.shell(command, { timeout: 10000 }) + this.exit(0) } } diff --git a/src/commands/server/update.ts b/src/commands/server/update.ts index 015079fa3..8abbd4b62 100644 --- a/src/commands/server/update.ts +++ b/src/commands/server/update.ts @@ -23,10 +23,14 @@ export default class Update extends Command { default: 'che', env: 'CHE_NAMESPACE' }), + 'listr-renderer': string({ + description: 'Listr renderer. Can be \'default\', \'silent\' or \'verbose\'', + default: 'default' + }), } async run() { - // const { flags } = this.parse(Update) + const { flags } = this.parse(Update) const Listr = require('listr') const notifier = require('node-notifier') const tasks = new Listr([ @@ -36,7 +40,7 @@ export default class Update extends Command { { title: 'Waiting for the new Che Server pod to be created', skip: this.warn('Not implemented yet')}, { title: 'Waiting for the new Che Server to start', skip: this.warn('Not implemented yet')}, { title: 'Retrieving Che Server URL', skip: this.warn('Not implemented yet')}, - ]) + ], {renderer: flags['listr-renderer'] as any}) await tasks.run() diff --git a/src/commands/workspace/inject.ts b/src/commands/workspace/inject.ts index 8f9e02305..3dccef903 100644 --- a/src/commands/workspace/inject.ts +++ b/src/commands/workspace/inject.ts @@ -43,6 +43,10 @@ export default class Inject extends Command { default: 'che', env: 'CHE_NAMESPACE' }), + 'listr-renderer': string({ + description: 'Listr renderer. Can be \'default\', \'silent\' or \'verbose\'', + default: 'default' + }), } async run() { @@ -85,7 +89,7 @@ export default class Inject extends Command { task.skip('kubeconfig already exists in the target container') } }).catch(e => this.error(e.message)) }, - ]) + ], {renderer: flags['listr-renderer'] as any}) try { await tasks.run() diff --git a/src/commands/workspace/list.ts b/src/commands/workspace/list.ts index 927c93f4b..dc622b77b 100644 --- a/src/commands/workspace/list.ts +++ b/src/commands/workspace/list.ts @@ -23,17 +23,21 @@ export default class List extends Command { default: 'che', env: 'CHE_NAMESPACE' }), + 'listr-renderer': string({ + description: 'Listr renderer. Can be \'default\', \'silent\' or \'verbose\'', + default: 'default' + }), } async run() { - // const { flags } = this.parse(List) + const { flags } = this.parse(List) const Listr = require('listr') const notifier = require('node-notifier') const tasks = new Listr([ { title: 'Verify if we can access Kubernetes API', skip: this.warn('Not implemented yet') }, { title: 'Verify if Che is running', skip: this.warn('Not implemented yet') }, { title: 'Get Workspaces', skip: this.warn('Not implemented yet') }, - ]) + ], {renderer: flags['listr-renderer'] as any}) // Use https://github.com/oclif/cli-ux/tree/supertable#clitable to dispalay: // - workspace id diff --git a/src/commands/workspace/start.ts b/src/commands/workspace/start.ts index 3d41efdd4..26b5f429b 100644 --- a/src/commands/workspace/start.ts +++ b/src/commands/workspace/start.ts @@ -38,6 +38,10 @@ export default class Start extends Command { env: 'WORKSPACE_CONFIG_JSON_PATH', required: false, }), + 'listr-renderer': string({ + description: 'Listr renderer. Can be \'default\', \'silent\' or \'verbose\'', + default: 'default' + }), } async run() { @@ -75,7 +79,7 @@ export default class Start extends Command { ctx.workspaceIdeURL = await che.createWorkspaceFromWorkspaceConfig(flags.chenamespace, flags.workspaceconfig) } }, - ]) + ], {renderer: flags['listr-renderer'] as any}) try { let ctx = await tasks.run() diff --git a/src/commands/workspace/stop.ts b/src/commands/workspace/stop.ts index 3fad77475..49037961d 100644 --- a/src/commands/workspace/stop.ts +++ b/src/commands/workspace/stop.ts @@ -23,10 +23,14 @@ export default class Stop extends Command { default: 'che', env: 'CHE_NAMESPACE' }), + 'listr-renderer': string({ + description: 'Listr renderer. Can be \'default\', \'silent\' or \'verbose\'', + default: 'default' + }), } async run() { - // const { flags } = this.parse(Stop) + const { flags } = this.parse(Stop) const Listr = require('listr') const notifier = require('node-notifier') const tasks = new Listr([ @@ -35,7 +39,7 @@ export default class Stop extends Command { { title: 'Verify if the workspaces is running', skip: () => 'Not implemented yet', task: () => {}}, { title: 'Stop the workspace', skip: () => 'Not implemented yet', task: () => {}}, { title: 'Waiting for the workspace resources to be deleted', skip: () => 'Not implemented yet', task: () => {}}, - ]) + ], {renderer: flags['listr-renderer'] as any}) await tasks.run() diff --git a/src/installers/helm.ts b/src/installers/helm.ts index 7250971db..f0c8af41a 100644 --- a/src/installers/helm.ts +++ b/src/installers/helm.ts @@ -86,7 +86,7 @@ export class HelmHelper { task.title = `${task.title}...done.` } }, - ]) + ], {renderer: flags['listr-renderer'] as any}) } async tillerRoleBindingExist(execTimeout= 30000): Promise { @@ -165,4 +165,9 @@ error: E_COMMAND_FAILED`) let command = `helm upgrade --install che --force --namespace ${flags.chenamespace} --set global.ingressDomain=${flags.domain} --set cheImage=${flags.cheimage} --set global.cheWorkspacesNamespace=${flags.chenamespace} ${multiUserFlag} ${tlsFlag} ${destDir}` await execa.shell(command, { timeout: execTimeout }) } + + async purgeHelmChart(name: string, execTimeout= 30000) { + await execa('helm', ['delete', name, '--purge'], { timeout: execTimeout, reject: false}) + } + } diff --git a/src/installers/minishift-addon.ts b/src/installers/minishift-addon.ts index e34c2b1b5..6c137991c 100644 --- a/src/installers/minishift-addon.ts +++ b/src/installers/minishift-addon.ts @@ -38,7 +38,7 @@ export class MinishiftAddonHelper { task.title = `${task.title}...done.` } } - ]) + ], {renderer: flags['listr-renderer'] as any}) } async applyAddon(flags: any, execTimeout= 120000) { @@ -69,4 +69,10 @@ stdout: ${stdout} error: E_COMMAND_FAILED`) } } + + async removeAddon(execTimeout= 120000) { + let args = ['addon', 'remove', 'che'] + await execa('minishift', args, { timeout: execTimeout, reject: false }) + } + } diff --git a/src/installers/operator.ts b/src/installers/operator.ts index 488f23757..ca36271e7 100644 --- a/src/installers/operator.ts +++ b/src/installers/operator.ts @@ -132,7 +132,7 @@ export class OperatorHelper { task.title = `${task.title}...done.` } }, - ]) + ], {renderer: flags['listr-renderer'] as any}) } async copyCheOperatorResources(templatesDir: string, cacheDir: string): Promise { diff --git a/src/platforms/minikube.ts b/src/platforms/minikube.ts index b43da2ef8..f8351271f 100644 --- a/src/platforms/minikube.ts +++ b/src/platforms/minikube.ts @@ -84,7 +84,7 @@ export class MinikubeHelper { task.title = `${task.title}...${flags.domain}.` } }, - ]) + ], {renderer: flags['listr-renderer'] as any}) } async isMinikubeRunning(): Promise { diff --git a/src/platforms/minishift.ts b/src/platforms/minishift.ts index 3691fd391..75c012df3 100644 --- a/src/platforms/minishift.ts +++ b/src/platforms/minishift.ts @@ -72,7 +72,7 @@ export class MinishiftHelper { task.title = `${task.title}...${flags.domain}.` } }, - ]) + ], {renderer: flags['listr-renderer'] as any}) } async isMinishiftRunning(): Promise { diff --git a/test/api/che.test.ts b/test/api/che.test.ts index 9c9aa9cb3..98d02d9ec 100644 --- a/test/api/che.test.ts +++ b/test/api/che.test.ts @@ -21,130 +21,140 @@ let kc = ch.kc let k8sApi = new Core_v1Api() describe('Che helper', () => { - fancy - .stub(ch, 'cheNamespaceExist', () => true) - .nock(cheURL, api => api - .get('/api/system/state') - .reply(200)) - .it('detects if Che server is ready', async () => { - const res = await ch.isCheServerReady(cheURL, namespace) - expect(res).to.equal(true) - }) - fancy - .stub(ch, 'cheNamespaceExist', () => true) - .nock(cheURL, api => api - .get('/api/system/state') - .delayConnection(1000) - .reply(200)) - .it('detects if Che server is NOT ready', async () => { - const res = await ch.isCheServerReady(cheURL, namespace, 500) - expect(res).to.equal(false) - }) - fancy - .stub(ch, 'cheNamespaceExist', () => true) - .nock(cheURL, api => api - .get('/api/system/state') - .delayConnection(1000) - .reply(200)) - .it('waits until Che server is ready', async () => { - const res = await ch.isCheServerReady(cheURL, namespace, 2000) - expect(res).to.equal(true) - }) - fancy - .stub(ch, 'cheNamespaceExist', () => true) - .nock(cheURL, api => api - .get('/api/system/state') - .reply(404) - .get('/api/system/state') - .reply(503) - .get('/api/system/state') - .reply(200)) - .it('continues requesting until Che server is ready', async () => { - const res = await ch.isCheServerReady(cheURL, namespace, 2000) - expect(res).to.equal(true) - }) - fancy - .stub(ch, 'cheNamespaceExist', () => true) - .nock(cheURL, api => api - .get('/api/system/state') - .reply(404) - .get('/api/system/state') - .reply(404) - .get('/api/system/state') - .reply(503)) - .it('continues requesting but fails if Che server is NOT ready after timeout', async () => { - const res = await ch.isCheServerReady(cheURL, namespace, 2000) - expect(res).to.equal(false) - }) - fancy - .stub(kc, 'makeApiClient', () => k8sApi) - .stub(k8sApi, 'readNamespace', jest.fn().mockImplementation(() => {throw new Error()})) - .it('founds out that a namespace doesn\'t exist', async () => { - const res = await ch.cheNamespaceExist(namespace) - expect(res).to.equal(false) - }) - fancy - .stub(kc, 'makeApiClient', () => k8sApi) - .stub(k8sApi, 'readNamespace', () => ({ response: '', body: { metadata: { name: `${namespace}` } } })) - .it('founds out that a namespace does exist', async () => { - const res = await ch.cheNamespaceExist(namespace) - expect(res).to.equal(true) - }) - fancy - .stub(ch, 'cheNamespaceExist', () => true) - .stub(ch, 'cheURL', () => cheURL) - .nock(cheURL, api => api - .post('/api/devfile') - .replyWithFile(201, __dirname + '/replies/create-workspace-from-valid-devfile.json', { 'Content-Type': 'application/json' })) - .it('succeds creating a workspace from a valid devfile', async () => { - const res = await ch.createWorkspaceFromDevfile(namespace, __dirname + '/requests/devfile.valid') - expect(res).to.equal('https://che-che.192.168.64.39.nip.io/dashboard/#/ide/che/chectl') - }) - fancy - .stub(ch, 'cheNamespaceExist', () => true) - .stub(ch, 'cheURL', () => cheURL) - .nock(cheURL, api => api - .post('/api/devfile') - .replyWithFile(400, __dirname + '/replies/create-workspace-from-invalid-devfile.json', { - 'Content-Type': 'application/json' - })) - .do(() => ch.createWorkspaceFromDevfile(namespace, __dirname + '/requests/devfile.invalid')) - .catch(/E_BAD_DEVFILE_FORMAT/) - .it('fails creating a workspace from an invalid devfile') - fancy - .stub(ch, 'cheNamespaceExist', () => true) - .stub(ch, 'cheURL', () => cheURL) - .do(() => ch.createWorkspaceFromDevfile(namespace, __dirname + '/requests/devfile.inexistent')) - .catch(/E_NOT_FOUND_DEVFILE/) - .it('fails creating a workspace from a non-existing devfile') - fancy - .stub(ch, 'cheNamespaceExist', () => true) - .stub(ch, 'cheURL', () => cheURL) - .nock(cheURL, api => api - .post('/api/workspace') - .replyWithFile(201, __dirname + '/replies/create-workspace-from-valid-devfile.json', { 'Content-Type': 'application/json' })) - .it('succeds creating a workspace from a valid workspaceconfig', async () => { - const res = await ch.createWorkspaceFromWorkspaceConfig(namespace, __dirname + '/requests/workspaceconfig.valid') - expect(res).to.equal('https://che-che.192.168.64.39.nip.io/dashboard/#/ide/che/chectl') - }) - fancy - .it('builds the Dashboard URL of a workspace given the IDE link', async () => { - let ideURL = 'https://che-che.192.168.64.40.nip.io/che/name-with-dashes' - let dashboardURL = 'https://che-che.192.168.64.40.nip.io/dashboard/#/ide/che/name-with-dashes' - let res = await ch.buildDashboardURL(ideURL) - expect(res).to.equal(dashboardURL) - }) + describe('isCheServerReady', () => { + fancy + .stub(ch, 'cheNamespaceExist', () => true) + .nock(cheURL, api => api + .get('/api/system/state') + .reply(200)) + .it('detects if Che server is ready', async () => { + const res = await ch.isCheServerReady(cheURL, namespace) + expect(res).to.equal(true) + }) + fancy + .stub(ch, 'cheNamespaceExist', () => true) + .nock(cheURL, api => api + .get('/api/system/state') + .delayConnection(1000) + .reply(200)) + .it('detects if Che server is NOT ready', async () => { + const res = await ch.isCheServerReady(cheURL, namespace, 500) + expect(res).to.equal(false) + }) + fancy + .stub(ch, 'cheNamespaceExist', () => true) + .nock(cheURL, api => api + .get('/api/system/state') + .delayConnection(1000) + .reply(200)) + .it('waits until Che server is ready', async () => { + const res = await ch.isCheServerReady(cheURL, namespace, 2000) + expect(res).to.equal(true) + }) + fancy + .stub(ch, 'cheNamespaceExist', () => true) + .nock(cheURL, api => api + .get('/api/system/state') + .reply(404) + .get('/api/system/state') + .reply(503) + .get('/api/system/state') + .reply(200)) + .it('continues requesting until Che server is ready', async () => { + const res = await ch.isCheServerReady(cheURL, namespace, 2000) + expect(res).to.equal(true) + }) + fancy + .stub(ch, 'cheNamespaceExist', () => true) + .nock(cheURL, api => api + .get('/api/system/state') + .reply(404) + .get('/api/system/state') + .reply(404) + .get('/api/system/state') + .reply(503)) + .it('continues requesting but fails if Che server is NOT ready after timeout', async () => { + const res = await ch.isCheServerReady(cheURL, namespace, 20) + expect(res).to.equal(false) + }) + }) + describe('cheNamespaceExist', () => { + fancy + .stub(kc, 'makeApiClient', () => k8sApi) + .stub(k8sApi, 'readNamespace', jest.fn().mockImplementation(() => { throw new Error() })) + .it('founds out that a namespace doesn\'t exist', async () => { + const res = await ch.cheNamespaceExist(namespace) + expect(res).to.equal(false) + }) + fancy + .stub(kc, 'makeApiClient', () => k8sApi) + .stub(k8sApi, 'readNamespace', () => ({ response: '', body: { metadata: { name: `${namespace}` } } })) + .it('founds out that a namespace does exist', async () => { + const res = await ch.cheNamespaceExist(namespace) + expect(res).to.equal(true) + }) + }) + describe('createWorkspaceFromDevfile', () => { + fancy + .stub(ch, 'cheNamespaceExist', () => true) + .stub(ch, 'cheURL', () => cheURL) + .nock(cheURL, api => api + .post('/api/devfile') + .replyWithFile(201, __dirname + '/replies/create-workspace-from-valid-devfile.json', { 'Content-Type': 'application/json' })) + .it('succeds creating a workspace from a valid devfile', async () => { + const res = await ch.createWorkspaceFromDevfile(namespace, __dirname + '/requests/devfile.valid') + expect(res).to.equal('https://che-che.192.168.64.39.nip.io/dashboard/#/ide/che/chectl') + }) + fancy + .stub(ch, 'cheNamespaceExist', () => true) + .stub(ch, 'cheURL', () => cheURL) + .nock(cheURL, api => api + .post('/api/devfile') + .replyWithFile(400, __dirname + '/replies/create-workspace-from-invalid-devfile.json', { + 'Content-Type': 'application/json' + })) + .do(() => ch.createWorkspaceFromDevfile(namespace, __dirname + '/requests/devfile.invalid')) + .catch(/E_BAD_DEVFILE_FORMAT/) + .it('fails creating a workspace from an invalid devfile') + fancy + .stub(ch, 'cheNamespaceExist', () => true) + .stub(ch, 'cheURL', () => cheURL) + .do(() => ch.createWorkspaceFromDevfile(namespace, __dirname + '/requests/devfile.inexistent')) + .catch(/E_NOT_FOUND_DEVFILE/) + .it('fails creating a workspace from a non-existing devfile') + }) + describe('createWorkspaceFromWorkspaceConfig', () => { + fancy + .stub(ch, 'cheNamespaceExist', () => true) + .stub(ch, 'cheURL', () => cheURL) + .nock(cheURL, api => api + .post('/api/workspace') + .replyWithFile(201, __dirname + '/replies/create-workspace-from-valid-devfile.json', { 'Content-Type': 'application/json' })) + .it('succeds creating a workspace from a valid workspaceconfig', async () => { + const res = await ch.createWorkspaceFromWorkspaceConfig(namespace, __dirname + '/requests/workspaceconfig.valid') + expect(res).to.equal('https://che-che.192.168.64.39.nip.io/dashboard/#/ide/che/chectl') + }) + }) + describe('buildDashboardURL', () => { + fancy + .it('builds the Dashboard URL of a workspace given the IDE link', async () => { + let ideURL = 'https://che-che.192.168.64.40.nip.io/che/name-with-dashes' + let dashboardURL = 'https://che-che.192.168.64.40.nip.io/dashboard/#/ide/che/name-with-dashes' + let res = await ch.buildDashboardURL(ideURL) + expect(res).to.equal(dashboardURL) + }) + }) describe('getWorkspacePod', () => { fancy .stub(kc, 'makeApiClient', () => k8sApi) - .stub(k8sApi, 'listNamespacedPod', () => ({ response: '', body: { items: [{ metadata: {name: 'pod-name', labels: {'che.workspace_id': workspace}} }] } })) + .stub(k8sApi, 'listNamespacedPod', () => ({ response: '', body: { items: [{ metadata: { name: 'pod-name', labels: { 'che.workspace_id': workspace } } }] } })) .it('should return pod name where workspace with the given ID is running', async () => { const pod = await ch.getWorkspacePod(namespace, workspace) expect(pod).to.equal('pod-name') }) fancy .stub(kc, 'makeApiClient', () => k8sApi) - .stub(k8sApi, 'listNamespacedPod', () => ({ response: '', body: { items: [{ metadata: {name: 'pod-name', labels: {'che.workspace_id': workspace}} }] } })) + .stub(k8sApi, 'listNamespacedPod', () => ({ response: '', body: { items: [{ metadata: { name: 'pod-name', labels: { 'che.workspace_id': workspace } } }] } })) .it('should detect a pod where single workspace is running', async () => { const pod = await ch.getWorkspacePod(namespace) expect(pod).to.equal('pod-name') @@ -157,15 +167,37 @@ describe('Che helper', () => { .it('should fail if no workspace is running') fancy .stub(kc, 'makeApiClient', () => k8sApi) - .stub(k8sApi, 'listNamespacedPod', () => ({ response: '', body: { items: [{ metadata: {labels: {'che.workspace_id': `${workspace}1`}} }] } })) + .stub(k8sApi, 'listNamespacedPod', () => ({ response: '', body: { items: [{ metadata: { labels: { 'che.workspace_id': `${workspace}1` } } }] } })) .do(() => ch.getWorkspacePod(namespace, workspace)) .catch(/Pod is not found for the given workspace ID/) .it('should fail if no workspace is found for the given ID') fancy .stub(kc, 'makeApiClient', () => k8sApi) - .stub(k8sApi, 'listNamespacedPod', () => ({ response: '', body: { items: [{ metadata: {labels: {'che.workspace_id': workspace}} }, { metadata: {labels: {'che.workspace_id': `${workspace}1`}} }] } })) + .stub(k8sApi, 'listNamespacedPod', () => ({ response: '', body: { items: [{ metadata: { labels: { 'che.workspace_id': workspace } } }, { metadata: { labels: { 'che.workspace_id': `${workspace}1` } } }] } })) .do(() => ch.getWorkspacePod(namespace)) .catch(/More than one pod with running workspace is found. Please, specify Che Workspace ID./) .it('should fail if no workspace ID was provided but several workspaces are found') }) + describe('isAuthenticationEnabled', () => { + fancy + .nock(cheURL, api => api + .get('/api/keycloak/settings') + .replyWithFile(200, __dirname + '/replies/get-keycloak-settings.json', { + 'Content-Type': 'application/json' + })) + .it('should return true if the api/keycloak/settings endpoint doesn\'t exist', async () => { + const authEnabled = await ch.isAuthenticationEnabled(cheURL) + expect(authEnabled).to.equal(true) + }) + fancy + .nock(cheURL, api => api + .get('/api/keycloak/settings') + .reply(404, 'Page does not exist', { + 'Content-Type': 'text/plain' + })) + .it('should return false if the api/keycloak/settings endpoint doesn\'t exist', async () => { + const authEnabled = await ch.isAuthenticationEnabled(cheURL) + expect(authEnabled).to.equal(false) + }) + }) }) diff --git a/test/api/kube.test.ts b/test/api/kube.test.ts index b21f465ed..61f1889ce 100644 --- a/test/api/kube.test.ts +++ b/test/api/kube.test.ts @@ -99,4 +99,11 @@ describe('Kube API helper', () => { const timeout = 1000 await kube.waitForPodReady(selector, namespace, interval, timeout) }) + fancy + .nock(kubeClusterURL, api => api + .get('/healthz') + .reply(200, 'ok')) + .it('verifies that kuber API is ok', async () => { + await kube.checkKubeApi() + }) }) diff --git a/test/api/openshift.test.ts b/test/api/openshift.test.ts index 1b1a88b2e..29f246eac 100644 --- a/test/api/openshift.test.ts +++ b/test/api/openshift.test.ts @@ -24,7 +24,7 @@ describe('OpenShift API helper', () => { .it('retrieves the hostname of a route', async () => { (execa as any).mockResolvedValue({ code: 0, stdout: hostname }) const routeName = 'che' - const res = await openshift.getHostByRouteName(routeName, namespace) + const res = await openshift.getRouteHost(routeName, namespace) expect(res).to.equal(hostname) }) }) diff --git a/test/api/replies/get-keycloak-settings.json b/test/api/replies/get-keycloak-settings.json new file mode 100644 index 000000000..1069fdadb --- /dev/null +++ b/test/api/replies/get-keycloak-settings.json @@ -0,0 +1,12 @@ +{ + "che.keycloak.token.endpoint": "https://auth.openshift.io/api/token", + "che.keycloak.userinfo.endpoint": "https://auth.openshift.io/api/userinfo", + "che.keycloak.oidc_provider": "https://auth.openshift.io/api", + "che.keycloak.github.endpoint": "https://auth.openshift.io/api/token?for=https://github.com", + "che.keycloak.client_id": "740650a2-9c44-4db5-b067-a3d1b2cd2d01", + "che.keycloak.username_claim": "preferred_username", + "che.keycloak.logout.endpoint": "https://auth.openshift.io/api/logout", + "che.keycloak.jwks.endpoint": "https://auth.openshift.io/api/token/keys", + "che.keycloak.js_adapter_url": "/api/fabric8-end2end/files/RhCheKeycloak.js", + "che.keycloak.use_nonce": "false" + } \ No newline at end of file diff --git a/test/e2e/minikube.test.ts b/test/e2e/minikube.test.ts new file mode 100644 index 000000000..2ca626349 --- /dev/null +++ b/test/e2e/minikube.test.ts @@ -0,0 +1,83 @@ +/********************************************************************* + * Copyright (c) 2019 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ +// tslint:disable:object-curly-spacing +import { expect, test } from '@oclif/test' + +jest.setTimeout(600000) + +/* +## Before +PROFILE=chectl-e2e-tests +minikube start --memory=8192 --docker-opt userland-proxy=false -p ${PROFILE} +minikube profile ${PROFILE} + +yarn test --coverage=false --testRegex=/test/e2e/minikube.test.ts + +## After +minikube stop -p ${PROFILE} +minikube delete -p ${PROFILE} +*/ + +describe('e2e test', () => { + describe('server:start without parameters', () => { + test + .stdout() + .command(['server:start', '--listr-renderer=verbose']) + .exit(0) + .it('uses minikube as platform, helm as installer and auth is disabled', ctx => { + expect(ctx.stdout).to.contain('Minikube preflight checklist') + .and.to.contain('Running Helm') + .and.to.contain('Post installation checklist') + .and.to.contain('Command server:start has completed successfully') + }) + test + .stdout() + .command(['server:stop', '--listr-renderer=verbose']) + .exit(0) + .it('stops Server on minikube successfully') + test + .stdout() + .command(['server:delete', '--listr-renderer=verbose']) + .exit(0) + .it('deletes Che resources on minikube successfully') + }) + describe('server:start mulituser', () => { + test + .stdout() + .command(['server:start', '--listr-renderer=verbose', '--multiuser']) + .exit(0) + .it('uses minikube as platform, operator as installer and auth is enabled', ctx => { + expect(ctx.stdout).to.contain('Minikube preflight checklist') + .and.to.contain('Running the Che Operator') + .and.to.contain('Post installation checklist') + .and.to.contain('Command server:start has completed successfully') + }) + test + .skip() + .stdout() + .command(['server:stop', '--listr-renderer=verbose']) + /* + TODO: set CHE_ACCESS_TOKEN with auth:che-api-token that does something similar to + CHE_USER=admin + CHE_PASSWORD=admin + TOKEN_ENDPOINT="http://keycloak-che.192.168.64.68.nip.io/auth/realms/che/protocol/openid-connect/token" + export CHE_ACCESS_TOKEN=$(curl -sSL --data "grant_type=password&client_id=che-public&username=${CHE_USER}&password=${CHE_PASSWORD}" \ + ${TOKEN_ENDPOINT} | jq -r .access_token) + */ + .exit(0) + .it('stops Server on minikube successfully') + test + .skip() + .stdout() + .command(['server:delete', '--listr-renderer=verbose']) + .exit(0) + .it('deletes Che resources on minikube successfully') + }) +}) diff --git a/test/e2e/minishift.test.ts b/test/e2e/minishift.test.ts new file mode 100644 index 000000000..8510d4cfd --- /dev/null +++ b/test/e2e/minishift.test.ts @@ -0,0 +1,83 @@ +/********************************************************************* + * Copyright (c) 2019 Red Hat, Inc. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + **********************************************************************/ +// tslint:disable:object-curly-spacing +import { expect, test } from '@oclif/test' + +jest.setTimeout(600000) + +/* +## Before +PROFILE=chectl-e2e-tests && \ +minishift profile set ${PROFILE} && \ +minishift start --memory=8GB --cpus=4 --disk-size=50g --vm-driver=xhyve --network-nameserver 8.8.8.8 --docker-opt userland-proxy=false --profile ${PROFILE} + +yarn test --coverage=false --testRegex=/test/e2e/minishift.test.ts + +## After +minishift stop --profile ${PROFILE} +minishift delete --profile ${PROFILE} +*/ + +describe('e2e test', () => { + describe('server:start without parameters', () => { + test + .stdout() + .command(['server:start', '--platform=minishift', '--listr-renderer=verbose']) + .exit(0) + .it('uses minishift as platform, minishift-addon as installer and auth is disabled', ctx => { + expect(ctx.stdout).to.contain('Minishift preflight checklist') + .and.to.contain('Running the Che minishift-addon') + .and.to.contain('Post installation checklist') + .and.to.contain('Command server:start has completed successfully') + }) + test + .stdout() + .command(['server:stop', '--listr-renderer=verbose']) + .exit(0) + .it('stops Server on minishift successfully') + test + .stdout() + .command(['server:delete', '--listr-renderer=verbose']) + .exit(0) + .it('deletes Che resources on minishift successfully') + }) + describe('server:start mulituser', () => { + test + .stdout() + .command(['server:start', '--platform=minishift', '--listr-renderer=verbose', '--multiuser']) + .exit(0) + .it('uses minishift as platform, operator as installer and auth is enabled', ctx => { + expect(ctx.stdout).to.contain('Minishift preflight checklist') + .and.to.contain('Running the Che Operator') + .and.to.contain('Post installation checklist') + .and.to.contain('Command server:start has completed successfully') + }) + test + .skip() + .stdout() + /* + TODO: set CHE_ACCESS_TOKEN with auth:che-api-token that does something similar to + CHE_USER=admin && \ + CHE_PASSWORD=admin && \ + TOKEN_ENDPOINT="http://keycloak-che.192.168.64.69.nip.io/auth/realms/che/protocol/openid-connect/token" && \ + export CHE_ACCESS_TOKEN=$(curl -sSL --data "grant_type=password&client_id=che-public&username=${CHE_USER}&password=${CHE_PASSWORD}" \ + ${TOKEN_ENDPOINT} | jq -r .access_token) + */ + .command(['server:stop', '--listr-renderer=verbose']) + .exit(0) + .it('stops Server on Minishift successfully') + test + .skip() + .stdout() + .command(['server:delete', '--listr-renderer=verbose']) + .exit(0) + .it('deletes Che resources on Minishift successfully') + }) +})