From 180f93d2a981c04cc5bf915b0b9451aea176fa37 Mon Sep 17 00:00:00 2001 From: David Thompson Date: Fri, 21 Apr 2023 17:29:04 -0400 Subject: [PATCH] Fix create service webview - Move the create service button from the Kubernetes tree to the context menu for the cluster - Add a page to the wizard to select which CustomResourceDefinition (CRD) to create an instance of, in lieu of using the Kubernetes tree to select the CRD - Show the documentation for the Operator that corresponds with the CRD on this page, since many Operators require additional post-installation steps before their associated CRDs work as expected - Material UI 5 -ify the form page - Add a Patternfly-like collpase widget for displaying nested objects - Remove properties from the schema if they are not required and aren't configured in the example YAML - If the user doesn't have access to list CRDs, drop them into a YAML editor with the example YAML for the CRD - We used to try to scrape the schema for the CRD from the swagger definitions, but I removed this, since the schema in the swagger definitions is often missing many properties and requires further processing to get it to work with React JsonSchema Form Fixes #3081 Signed-off-by: David Thompson --- .prettierrc | 1 + package-lock.json | 224 ++++++-- package.json | 30 +- src/extension.ts | 1 + src/k8s/csv.ts | 69 +-- src/k8s/utils.ts | 237 --------- src/oc/ocWrapper.ts | 17 + src/openshift/service.ts | 17 + src/util/swagger.ts | 58 -- src/webview/common/createServiceTypes.ts | 66 +++ src/webview/common/vscode-theme.ts | 26 +- src/webview/create-service/app/createForm.tsx | 495 +++++++++++++++--- src/webview/create-service/app/index.html | 116 ---- src/webview/create-service/app/index.tsx | 4 +- .../create-service/createServiceViewLoader.ts | 370 ++++++++++++- src/webview/tsconfig.json | 3 - test/integration/ocWrapper.test.ts | 15 + 17 files changed, 1139 insertions(+), 610 deletions(-) delete mode 100644 src/k8s/utils.ts create mode 100644 src/openshift/service.ts delete mode 100644 src/util/swagger.ts create mode 100644 src/webview/common/createServiceTypes.ts diff --git a/.prettierrc b/.prettierrc index 345f449ea..655ebebb9 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,5 +2,6 @@ "arrowParens": "always", "printWidth": 100, "singleQuote": true, + "jsxSingleQuote": true, "trailingComma": "all" } diff --git a/package-lock.json b/package-lock.json index d266a3a90..c3107cb50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@mui/material": "^5.14.15", "@mui/styles": "^5.14.15", "@rjsf/core": "^5.13.2", + "@rjsf/mui": "^5.13.2", "@rjsf/validator-ajv8": "^5.13.2", "@segment/analytics-next": "^1.59.0", "@svgr/plugin-jsx": "^8.1.0", @@ -50,6 +51,7 @@ "@types/express": "^4.17.20", "@types/fs-extra": "^11.0.3", "@types/lodash": "^4.14.200", + "@types/make-fetch-happen": "^10.0.2", "@types/mocha": "^10.0.3", "@types/node": "^16.18.59", "@types/proxyquire": "^1.3.30", @@ -105,6 +107,7 @@ "react-scroll-to-top": "^3.0.0", "react-syntax-highlighter": "^15.5.0", "remap-istanbul": "^0.13.0", + "showdown": "^2.1.0", "shx": "^0.3.3", "sinon": "^17.0.0", "sinon-chai": "^3.7.0", @@ -1892,6 +1895,24 @@ "react": "^16.14.0 || >=17" } }, + "node_modules/@rjsf/mui": { + "version": "5.13.2", + "resolved": "https://registry.npmjs.org/@rjsf/mui/-/mui-5.13.2.tgz", + "integrity": "sha512-RmuHGhFa4L43WzfMQ38wGEApNQg7ZMU/sgpdHqH4JbZoiJdis2BAIKmfa59mJM33Q03sSBqeZPgTeCoH7IQiog==", + "dev": true, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@emotion/react": "^11.7.0", + "@emotion/styled": "^11.6.0", + "@mui/icons-material": "^5.2.0", + "@mui/material": "^5.2.2", + "@rjsf/core": "^5.12.x", + "@rjsf/utils": "^5.12.x", + "react": ">=17" + } + }, "node_modules/@rjsf/utils": { "version": "5.13.0", "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-5.13.0.tgz", @@ -5590,6 +5611,17 @@ "integrity": "sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==", "dev": true }, + "node_modules/@types/make-fetch-happen": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@types/make-fetch-happen/-/make-fetch-happen-10.0.2.tgz", + "integrity": "sha512-NSMHhLp2dpXGqN0aolc0SygCrmck6HYTZjlRd1ys51yCVLhoFwV/5xwSDe4XDkNmWpeqKIqpSrN+w/xXU3XPEw==", + "dev": true, + "dependencies": { + "@types/node-fetch": "*", + "@types/retry": "*", + "@types/ssri": "*" + } + }, "node_modules/@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", @@ -5615,6 +5647,16 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.59.tgz", "integrity": "sha512-PJ1w2cNeKUEdey4LiPra0ZuxZFOGvetswE8qHRriV/sUkL5Al4tTmPV9D2+Y/TPIxTHHgxTfRjZVKWhPw/ORhQ==" }, + "node_modules/@types/node-fetch": { + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.6.tgz", + "integrity": "sha512-95X8guJYhfqiuVVhRFxVQcf4hW/2bCuoPwDasMf/531STFoNoWTT7YDnWdXHEZKqAGUigmpG31r2FE70LwnzJw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/parse-json": { "version": "4.0.0", "dev": true, @@ -5737,6 +5779,12 @@ "@types/node": "*" } }, + "node_modules/@types/retry": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.3.tgz", + "integrity": "sha512-rkxEZUFIyDEZhC6EfHz6Hwos2zXewCOLBzhdgv7D55qu4OAySNwDZzxbaMpFI6XthdBa5oHhR5s6/9MSuTfw4g==", + "dev": true + }, "node_modules/@types/scheduler": { "version": "0.16.3", "dev": true, @@ -5799,6 +5847,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ssri": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@types/ssri/-/ssri-7.1.2.tgz", + "integrity": "sha512-Mbo/NaBiZlXNlOFTLK+PXeVEzKFxi+ZVELuzmk4VxdRz6aqKpmP9bhcNqsIB2c/s78355WBHwUCGYhQDydcfEg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/tar-fs": { "version": "2.0.1", "dev": true, @@ -6811,20 +6868,6 @@ "proxy-from-env": "^1.1.0" } }, - "node_modules/axios/node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/azure-devops-node-api": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-11.2.0.tgz", @@ -8506,15 +8549,6 @@ "node": ">=10" } }, - "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.2", - "dev": true, - "license": "ISC", - "peer": true, - "engines": { - "node": ">= 6" - } - }, "node_modules/countries-and-timezones": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/countries-and-timezones/-/countries-and-timezones-3.5.1.tgz", @@ -10549,6 +10583,19 @@ "node": "*" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -16431,6 +16478,31 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/showdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz", + "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==", + "dev": true, + "dependencies": { + "commander": "^9.0.0" + }, + "bin": { + "showdown": "bin/showdown.js" + }, + "funding": { + "type": "individual", + "url": "https://www.paypal.me/tiviesantos" + } + }, + "node_modules/showdown/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/shx": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/shx/-/shx-0.3.4.tgz", @@ -18559,6 +18631,16 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "peer": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", @@ -19760,6 +19842,13 @@ "prop-types": "^15.8.1" } }, + "@rjsf/mui": { + "version": "5.13.2", + "resolved": "https://registry.npmjs.org/@rjsf/mui/-/mui-5.13.2.tgz", + "integrity": "sha512-RmuHGhFa4L43WzfMQ38wGEApNQg7ZMU/sgpdHqH4JbZoiJdis2BAIKmfa59mJM33Q03sSBqeZPgTeCoH7IQiog==", + "dev": true, + "requires": {} + }, "@rjsf/utils": { "version": "5.13.0", "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-5.13.0.tgz", @@ -21327,6 +21416,17 @@ "integrity": "sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==", "dev": true }, + "@types/make-fetch-happen": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@types/make-fetch-happen/-/make-fetch-happen-10.0.2.tgz", + "integrity": "sha512-NSMHhLp2dpXGqN0aolc0SygCrmck6HYTZjlRd1ys51yCVLhoFwV/5xwSDe4XDkNmWpeqKIqpSrN+w/xXU3XPEw==", + "dev": true, + "requires": { + "@types/node-fetch": "*", + "@types/retry": "*", + "@types/ssri": "*" + } + }, "@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", @@ -21350,6 +21450,16 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.59.tgz", "integrity": "sha512-PJ1w2cNeKUEdey4LiPra0ZuxZFOGvetswE8qHRriV/sUkL5Al4tTmPV9D2+Y/TPIxTHHgxTfRjZVKWhPw/ORhQ==" }, + "@types/node-fetch": { + "version": "2.6.6", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.6.tgz", + "integrity": "sha512-95X8guJYhfqiuVVhRFxVQcf4hW/2bCuoPwDasMf/531STFoNoWTT7YDnWdXHEZKqAGUigmpG31r2FE70LwnzJw==", + "dev": true, + "requires": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "@types/parse-json": { "version": "4.0.0", "dev": true, @@ -21465,6 +21575,12 @@ "@types/node": "*" } }, + "@types/retry": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.3.tgz", + "integrity": "sha512-rkxEZUFIyDEZhC6EfHz6Hwos2zXewCOLBzhdgv7D55qu4OAySNwDZzxbaMpFI6XthdBa5oHhR5s6/9MSuTfw4g==", + "dev": true + }, "@types/scheduler": { "version": "0.16.3", "dev": true @@ -21523,6 +21639,15 @@ "version": "8.1.2", "dev": true }, + "@types/ssri": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@types/ssri/-/ssri-7.1.2.tgz", + "integrity": "sha512-Mbo/NaBiZlXNlOFTLK+PXeVEzKFxi+ZVELuzmk4VxdRz6aqKpmP9bhcNqsIB2c/s78355WBHwUCGYhQDydcfEg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/tar-fs": { "version": "2.0.1", "dev": true, @@ -22232,18 +22357,6 @@ "follow-redirects": "^1.15.0", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" - }, - "dependencies": { - "form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - } } }, "azure-devops-node-api": { @@ -23470,13 +23583,6 @@ "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" - }, - "dependencies": { - "yaml": { - "version": "1.10.2", - "dev": true, - "peer": true - } } }, "countries-and-timezones": { @@ -24909,6 +25015,16 @@ "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==" }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -29051,6 +29167,23 @@ } } }, + "showdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz", + "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==", + "dev": true, + "requires": { + "commander": "^9.0.0" + }, + "dependencies": { + "commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true + } + } + }, "shx": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/shx/-/shx-0.3.4.tgz", @@ -30534,6 +30667,13 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "peer": true + }, "yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", diff --git a/package.json b/package.json index 08adb608f..b1d2a49c8 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "@mui/material": "^5.14.15", "@mui/styles": "^5.14.15", "@rjsf/core": "^5.13.2", + "@rjsf/mui": "^5.13.2", "@rjsf/validator-ajv8": "^5.13.2", "@segment/analytics-next": "^1.59.0", "@svgr/plugin-jsx": "^8.1.0", @@ -112,6 +113,7 @@ "@types/express": "^4.17.20", "@types/fs-extra": "^11.0.3", "@types/lodash": "^4.14.200", + "@types/make-fetch-happen": "^10.0.2", "@types/mocha": "^10.0.3", "@types/node": "^16.18.59", "@types/proxyquire": "^1.3.30", @@ -167,6 +169,7 @@ "react-scroll-to-top": "^3.0.0", "react-syntax-highlighter": "^15.5.0", "remap-istanbul": "^0.13.0", + "showdown": "^2.1.0", "shx": "^0.3.3", "sinon": "^17.0.0", "sinon-chai": "^3.7.0", @@ -721,8 +724,8 @@ "category": "OpenShift" }, { - "command": "clusters.openshift.csv.create", - "title": "Create Service ...", + "command": "openshift.service.create", + "title": "Create Operator-Backed Service", "category": "OpenShift" }, { @@ -902,6 +905,10 @@ { "id": "serverlessfunction/buildConfig", "label": "Build Configuration" + }, + { + "id": "view/item/context/createService", + "label": "Create Service" } ], "viewsContainers": { @@ -1408,6 +1415,18 @@ "group": "c1@1" } ], + "view/item/context/createService": [ + { + "command": "openshift.componentTypesView.registry.openHelmChartsInView", + "when": "view == openshiftProjectExplorer && viewItem == openshift.k8sContext && isLoggedIn", + "group": "c2" + }, + { + "command": "openshift.service.create", + "when": "view == openshiftProjectExplorer && viewItem == openshift.k8sContext && isLoggedIn", + "group": "c2" + } + ], "view/item/context": [ { "command": "openshift.sandbox.signup", @@ -1419,11 +1438,6 @@ "category": "1@1", "when": "viewItem == openshift.sandbox.status.ready" }, - { - "command": "clusters.openshift.csv.create", - "group": "1@1", - "when": "view == extension.vsKubernetesExplorer && viewItem == openshift.resource.csv.crdDescription" - }, { "command": "clusters.openshift.deployment.openConsole", "group": "3@0", @@ -1495,7 +1509,7 @@ "group": "c1" }, { - "command": "openshift.componentTypesView.registry.openHelmChartsInView", + "submenu": "view/item/context/createService", "when": "view == openshiftProjectExplorer && viewItem == openshift.k8sContext && isLoggedIn", "group": "c2" }, diff --git a/src/extension.ts b/src/extension.ts index d782acce9..98951bda4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -83,6 +83,7 @@ export async function activate(extensionContext: ExtensionContext): Promise `get csv ${csvName}`, @@ -103,57 +89,4 @@ export class ClusterServiceVersion extends OpenShiftItem { } } - @vsCommand('clusters.openshift.csv.create') - static async createNewService(crdOwnedNode: K8sCrdNode): Promise { - return ClusterServiceVersion.createNewServiceFromDescriptor(crdOwnedNode.impl.crdDescription, crdOwnedNode.impl.csv); - } - - static async createNewServiceFromDescriptor(crdDescription: CRDDescription, csv: ClusterServiceVersionKind): Promise { - let crdResource: CustomResourceDefinitionKind; - let apiVersion: string; - try { - crdResource = await Oc.Instance.getKubernetesObject('crd', crdDescription.name) as unknown as CustomResourceDefinitionKind; - } catch (err) { - // if crd cannot be accessed, try to use swagger - } - - let openAPIV3SchemaAll: JSONSchema7; - if (crdResource) { - openAPIV3SchemaAll = crdResource.spec.versions.find((version) => version.name === crdDescription.version).schema.openAPIV3Schema; - apiVersion = `${crdResource.spec.group}/${crdDescription.version}`; - } else { - const activeCluster = await this.odo.getActiveCluster(); - const token = await Oc.Instance.getCurrentUserToken(); - openAPIV3SchemaAll = await getOpenAPISchemaFor(activeCluster, token, crdDescription.kind, crdDescription.version); - const gvk = _.find(openAPIV3SchemaAll['x-kubernetes-group-version-kind'], ({ group, version, kind }) => - crdDescription.version === version && crdDescription.kind === kind && group); - apiVersion = `${gvk.group}/${gvk.version}`; - } - - const examplesYaml: string = csv.metadata?.annotations?.['alm-examples']; - const examples: any[] = examplesYaml ? loadYaml(examplesYaml) : undefined; - const example = examples ? examples.find(item => item.apiVersion === apiVersion && item.kind === crdDescription.kind) : {}; - generateDefaults(openAPIV3SchemaAll, example); - const openAPIV3Schema = _.defaultsDeep({}, DEFAULT_K8S_SCHEMA, _.omit(openAPIV3SchemaAll, 'properties.status')); - openAPIV3Schema.properties.metadata.properties.name.default = - example?.metadata?.name ? `${example.metadata.name}-${randomString()}` : `${crdDescription.kind}-${randomString()}`; - - const uiSchema = getUISchema( - openAPIV3Schema, - crdDescription - ); - - const panel = await CreateServiceViewLoader.loadView('Create Service', ClusterServiceVersion.createFormMessageListener.bind(undefined)); - - panel.webview.onDidReceiveMessage(async (event)=> { - if(event.command === 'ready') { - await panel.webview.postMessage({ - action: 'load', openAPIV3Schema, - uiSchema, - crdDescription, - formData: {} - }); - } - }); - } } diff --git a/src/k8s/utils.ts b/src/k8s/utils.ts deleted file mode 100644 index 2f7c5fe38..000000000 --- a/src/k8s/utils.ts +++ /dev/null @@ -1,237 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -/* eslint-disable header/header */ - -import * as Immutable from 'immutable'; -import { JSONSchema7 } from 'json-schema'; -import * as _ from 'lodash'; - -import { SpecCapability, Descriptor } from './olm/types'; - -export enum JSONSchemaType { - string = 'string', - number = 'number', - integer = 'integer', - boolean = 'boolean', - null = 'null', - array = 'array', - object = 'object', -} - -export const DEFAULT_K8S_SCHEMA: JSONSchema7 = { - type: JSONSchemaType.object, - properties: { - metadata: { - type: JSONSchemaType.object, - properties: { - namespace: { type: JSONSchemaType.string }, - name: { - type: JSONSchemaType.string, - default: 'example', - }, - labels: { - type: JSONSchemaType.object, - properties: {}, - additionalProperties: { type: JSONSchemaType.string }, - }, - }, - required: ['name'], - }, - spec: { type: JSONSchemaType.object }, - apiVersion: { type: JSONSchemaType.string }, - kind: { type: JSONSchemaType.string }, - }, -}; - -export const HIDDEN_UI_SCHEMA = { - 'ui:widget': 'hidden', - 'ui:options': { label: false }, -}; - -// Applies a hidden widget and label configuration to every property of the given schema. -// This is useful for whitelisting only a few schema properties when all properties are not known. -export const hideAllExistingProperties = (schema: JSONSchema7) => { - return _.reduce( - schema?.properties, - (acc, _unused, propertyName) => ({ - ...acc, - [propertyName]: HIDDEN_UI_SCHEMA, - }), - {}, - ); -}; - -// Transform a path string to a JSON schema path array -export const stringPathToUISchemaPath = (path: string): string[] => - (_.toPath(path) ?? []).map((subPath) => { - return /^\d+$/.test(subPath) ? 'items' : subPath; - }); - -// Recursive helper for getSchemaAtPath -const recursiveGetSchemaAtPath = ( - schema: JSONSchema7, - [segment, ...path]: string[] = [], - ): JSONSchema7 => { - if (segment) { - return /^\d+$/.test(segment) - ? recursiveGetSchemaAtPath(schema?.items as JSONSchema7, path) - : recursiveGetSchemaAtPath(schema?.properties?.[segment] as JSONSchema7, path); - } - return schema; - }; - -// Get a schema at the provided path string. -export const getSchemaAtPath = (schema: JSONSchema7, path: string): JSONSchema7 => { - return recursiveGetSchemaAtPath(schema, _.toPath(path)); -}; - -// Map a set of spec descriptors to a ui schema -export const descriptorsToUISchema = ( - descriptors: Descriptor[], - jsonSchema: JSONSchema7, -) => { - const uiSchemaFromDescriptors = _.reduce( - descriptors, - (uiSchemaAccumulator, descriptor, index: number) => { - const schemaForDescriptor = getSchemaAtPath(jsonSchema, descriptor.path); - if (!schemaForDescriptor) { - // eslint-disable-next-line no-console - console.warn( - '[OperandForm] SpecDescriptor path references a non-existent schema property:', - descriptor.path, - ); - return uiSchemaAccumulator; - } - - const uiSchemaPath = stringPathToUISchemaPath(descriptor.path); - if (descriptor.displayName){ - schemaForDescriptor.title = descriptor.displayName; - } - return uiSchemaAccumulator.withMutations((mutable) => { - mutable.mergeDeepIn( - uiSchemaPath, - Immutable.Map({ - ...(descriptor.description && { 'ui:description': descriptor.description }), - ...(descriptor.displayName && { 'ui:title': descriptor.displayName }), - 'ui:sortOrder': index + 1, - }), - ); - }); - }, - Immutable.Map(), - ).toJS(); - return uiSchemaFromDescriptors; -}; - -function camelCaseToTitle(name: string): string { - return name.replace(name.charAt(0),name.charAt(0).toLocaleUpperCase()).match(/[a-z]+|[A-Z][a-z]+/g).join(' '); -} - -function generateTitlesForSchema(uiSchema, schema): void { - Object.keys(schema.properties ? schema.properties : {}).forEach((name) => { - const schemaProperty = schema.properties[name]; - if (schemaProperty.type === 'object') { - if (!uiSchema[name]) { - uiSchema[name] = {}; - } - if (schemaProperty.properties) { - generateTitlesForSchema(uiSchema[name], schemaProperty); - } else { - uiSchema[name]['ui:widget'] = 'hidden'; - } - } else { - if (!uiSchema[name]) { - uiSchema[name] = {}; - } - if (schemaProperty.type === 'boolean' && !schemaProperty.title) { - schemaProperty.title = camelCaseToTitle(name); - schemaProperty.default = false; - } else if (!uiSchema[name]['ui:title']) { - uiSchema[name]['ui:title'] = camelCaseToTitle(name); - } - } - }); -} - -function hideEmptyNodesForSchema(uiSchema, schema): void { - Object.keys(schema.properties ? schema.properties : {}).forEach((name) => { - const schemaProperty = schema.properties[name]; - if (schemaProperty.type === 'object') { - if (schemaProperty.properties) { - if (!uiSchema[name]) { - uiSchema[name] = {}; - } - hideEmptyNodesForSchema(uiSchema[name], schemaProperty); - } else { - uiSchema['ui:widget'] = 'hidden'; - } - } - }); -} - -export function generateDefaults(jsonSchema, jsonData) { - if (jsonSchema.properties) { - Object.keys(jsonData).forEach(key => { - const nextValue = jsonData[key]; - if(typeof nextValue === 'object' && nextValue !== null && !Array.isArray(nextValue)) { - if (!jsonSchema.properties[key]) { - jsonSchema.properties[key] = {}; - } - generateDefaults(jsonSchema.properties[key], jsonData[key]); - } else { - if (nextValue !== undefined && jsonSchema.properties[key]) { - jsonSchema.properties[key].default = nextValue; - } - } - }); - } -} - -export function randomString(): string { - return _.sampleSize(_.toArray('abcdefghijklmnopqrstuvwxyz0123456789'), 5).join(''); -} - -// Use jsonSchema, descriptors, and some defaults to generate a uiSchema -export const getUISchema = (jsonSchema, providedAPI) => { - const hiddenMetaPropsUiSchema = hideAllExistingProperties(jsonSchema?.properties?.metadata as JSONSchema7); - const specUiSchema = descriptorsToUISchema(providedAPI?.specDescriptors, jsonSchema?.properties?.spec) - // Extend ui-schema by adding ui:title for properties without descriptor. - generateTitlesForSchema(specUiSchema, jsonSchema?.properties?.spec); - hideEmptyNodesForSchema(specUiSchema, jsonSchema); - return { - apiVersion: { - 'ui:widget': 'hidden' - }, - kind: { - 'ui:widget': 'hidden' - }, - metadata: { - ...hiddenMetaPropsUiSchema, - name: { - 'ui:title': 'Name', - }, - labels: { - 'ui:title': 'Labels', - 'ui:field': 'Labels', - }, - 'ui:options': { - label: false, - }, - 'ui:order': ['name', 'labels', '*'], - }, - spec: { - 'ui:description': '', // hide description for spec - ...specUiSchema, - 'ui:options': { - label: false, - }, - }, - 'ui:order': ['metadata', 'spec', '*'], - 'ui:options': { - label: false, - }, - }; -}; diff --git a/src/oc/ocWrapper.ts b/src/oc/ocWrapper.ts index c8a5d6717..7585a2027 100644 --- a/src/oc/ocWrapper.ts +++ b/src/oc/ocWrapper.ts @@ -314,6 +314,23 @@ export class Oc { } } + /** + * Returns true if the current user can access CRDs and false otherwise. + * + * @returns true if the current user can access CRDs and false otherwise + */ + public async canIGetCRDs(): Promise { + try { + await CliChannel.getInstance().executeTool( + new CommandText('oc', 'auth can-i get CustomResourceDefinition'), + ); + return true; + } catch (e) { + // do nothing + } + return false; + } + /** * Returns the oc command to list all resources of the given type in the given (or current) namespace * diff --git a/src/openshift/service.ts b/src/openshift/service.ts new file mode 100644 index 000000000..0f930631a --- /dev/null +++ b/src/openshift/service.ts @@ -0,0 +1,17 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +import { vsCommand } from '../vscommand'; +import CreateServiceViewLoader from '../webview/create-service/createServiceViewLoader'; + +/** + * Wraps commands that are used for interacting with services. + */ +export class Service { + @vsCommand('openshift.service.create') + static async createNewOperatorBackedService() { + await CreateServiceViewLoader.loadView(); + } +} diff --git a/src/util/swagger.ts b/src/util/swagger.ts deleted file mode 100644 index 47684aa69..000000000 --- a/src/util/swagger.ts +++ /dev/null @@ -1,58 +0,0 @@ -/*----------------------------------------------------------------------------------------------- - * Copyright (c) Red Hat, Inc. All rights reserved. - * Licensed under the MIT License. See LICENSE file in the project root for license information. - *-----------------------------------------------------------------------------------------------*/ - -import { JSONSchema7 } from 'json-schema'; -import * as path from 'path'; -import { Platform } from './platform'; - -const _ = require('lodash'); -const https = require('https'); -const fetch = require('make-fetch-happen').defaults({ - cachePath : path.join(Platform.getUserHomePath(), '.vs-openshift','cache'), -}); - -export type SwaggerDefinition = { - definitions?: SwaggerDefinitions; - description?: string; - type?: string; - enum?: string[]; - $ref?: string; - items?: SwaggerDefinition; - required?: string[]; - properties?: { - [prop: string]: SwaggerDefinition; - }; -}; - -export type SwaggerDefinitions = { - [name: string]: SwaggerDefinition; -}; - -export function getDefinitionKey (ocrdKind: string, ocrdVersion: string, definitions: SwaggerDefinitions): string { - return _.findKey(definitions, (def: SwaggerDefinition) => - _.some(def['x-kubernetes-group-version-kind'], ({ group, version, kind }) => - ocrdVersion === version && ocrdKind === kind && group - ) - ); - }; - -export function getOpenAPISchemaFor(clusterUrl: string, token: string, kind: string, version: string): Promise { - return fetch(`${clusterUrl}/openapi/v2`, { - headers: { - Authorization: `Bearer ${token}` - }, - agent: new https.Agent({ - rejectUnauthorized: false, - }) - }).then(res => { - return res.json(); - }).then(swagger => { - const key = getDefinitionKey(kind, version, swagger.definitions); - const result = swagger.definitions[key]; - delete swagger.definitions[key]; // delete requested schema from definition to avoid cycles - result.definitions = swagger.definitions; // required to resolve oopenapischema references - return result; - }); -} diff --git a/src/webview/common/createServiceTypes.ts b/src/webview/common/createServiceTypes.ts new file mode 100644 index 000000000..1b7078994 --- /dev/null +++ b/src/webview/common/createServiceTypes.ts @@ -0,0 +1,66 @@ +/*----------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See LICENSE file in the project root for license information. + *-----------------------------------------------------------------------------------------------*/ + +/** + * Represents the parts that we care about for the ClusterServiceVersion (CSV) Kubernetes resource. + */ +export type ClusterServiceVersion = { + spec: { + customresourcedefinitions: { + /** + * Stubs that describe the Custom Resource Definitions that this CSV have created. + */ + owned: CustomResourceDefinitionStub[]; + }; + /** + * Description of the ClusterServiceVersion, usually containing a description on how to use the Operator. + */ + description: string; + + /** + * An array of base64-encoded icons + */ + icon: string[]; + }; + +}; + +/** + * Represents the shortened description of a CustomResourceDefinition that's included in the ClusterServiceVersion. + */ +export type CustomResourceDefinitionStub = { + /** + * Pascal-case name + */ + kind: string; + /** + * FQN + */ + name: string; + version: string; + + /** + * Additional documentation for the properties under `.spec` + */ + specDescriptors: SpecDescriptor[]; + + /** + * Added property, not part of what's reported by the cluster. + * The description of the associated ClusterServiceVersion. + */ + csvDescription?: string; +}; + +/** + * Represents an additional piece of documentation for one of the properties + */ +export type SpecDescriptor = { + description: string; + displayName: string; + /** + * The JSON path to the property that this describes + */ + path: string; +} diff --git a/src/webview/common/vscode-theme.ts b/src/webview/common/vscode-theme.ts index cbf448278..8029cc022 100644 --- a/src/webview/common/vscode-theme.ts +++ b/src/webview/common/vscode-theme.ts @@ -63,11 +63,21 @@ export function createVSCodeTheme(paletteMode: PaletteMode): Theme { '--vscode-editor-inactiveSelectionBackground', ), }, - }, + } ], }, MuiTypography: { variants: [ + { + props: { + variant: 'h4', + }, + style: { + fontSize: '1em', + fontWeight: '650', + color: computedStyle.getPropertyValue('--vscode-foreground'), + }, + }, { props: { variant: 'h5', @@ -146,6 +156,20 @@ export function createVSCodeTheme(paletteMode: PaletteMode): Theme { }, }, }, + MuiPaper: { + variants: [ + { + props: { + variant: 'elevation', + }, + style: { + backgroundColor: computedStyle.getPropertyValue( + '--vscode-tab-border', + ), + }, + }, + ] + } }, }); } diff --git a/src/webview/create-service/app/createForm.tsx b/src/webview/create-service/app/createForm.tsx index d78a24223..6a59ba8f8 100644 --- a/src/webview/create-service/app/createForm.tsx +++ b/src/webview/create-service/app/createForm.tsx @@ -2,76 +2,445 @@ * Copyright (c) Red Hat, Inc. All rights reserved. * Licensed under the MIT License. See LICENSE file in the project root for license information. *-----------------------------------------------------------------------------------------------*/ -import Form, { IChangeEvent } from '@rjsf/core'; -import Validator from '@rjsf/validator-ajv8'; -import 'bootstrap/dist/css/bootstrap.min.css'; +import ExpandLess from '@mui/icons-material/ExpandLess'; +import ExpandMore from '@mui/icons-material/ExpandMore'; +import { + Alert, + Box, + Button, + Collapse, + Container, + FormControl, + FormHelperText, + IconButton, + InputLabel, + MenuItem, + PaletteMode, + Paper, + Select, + Stack, + ThemeProvider, + Typography, + Grid, +} from '@mui/material'; +import Form from '@rjsf/mui'; +import type { + ObjectFieldTemplateProps, + TitleFieldProps, + ArrayFieldTemplateProps, + RJSFSchema, + StrictRJSFSchema, + FormContextType, + ArrayFieldTemplateItemType +} from '@rjsf/utils'; +import { getTemplate , getUiOptions} from '@rjsf/utils'; +import validator from '@rjsf/validator-ajv8'; import * as React from 'react'; import 'react-dom'; +import type { CustomResourceDefinitionStub } from '../../common/createServiceTypes'; +import { LoadScreen } from '../../common/loading'; +import { createVSCodeTheme } from '../../common/vscode-theme'; +import { ArrowBack } from '@mui/icons-material'; +import { Converter } from 'showdown'; +import {ErrorPage} from '../../common/errorPage'; -export function CreateForm(props) { - let changed = false; - const [baseSchema, setBaseSchema] = React.useState({}); - const [uiSchema, setUiSchema] = React.useState({}); - const [formData, setFormData] = React.useState({}); - const [crdDescription, setCrdDescription] = React.useState({} as any); - const [step, setStep] = React.useState('ready'); - - const onSubmit = (e: IChangeEvent): void => { - // disable Create button while service is created - // extension should send message back to unlock the button in case of failure - // or close the editor in case of success - setStep('creating'); - setFormData(e.formData); +/** + * A replacement for the RJSF object field component that resembles the one in Patternfly and allows collapsing. + */ +function ObjectFieldTemplate(props: ObjectFieldTemplateProps) { + const [isExpanded, setExpanded] = React.useState(true); + + return ( + <> + {props.title ? ( + <> + + + { + e.preventDefault(); + setExpanded(!isExpanded); + }} + > + {isExpanded ? : } + + + + + {props.title} + {props.required && ' *'} + + + {props.description} + + + + + + + {props.properties.map((element) => ( +
{element.content}
+ ))} +
+
+
+ + ) : ( + <> + + {props.properties.map((element) => ( +
{element.content}
+ ))} +
+ + )} + + ); +} + +/** + * Based on https://github.com/rjsf-team/react-jsonschema-form/blob/main/packages/mui/src/ArrayFieldTemplate/ArrayFieldTemplate.tsx + */ +function ArrayFieldTemplate< + T = any, + S extends StrictRJSFSchema = RJSFSchema, + F extends FormContextType = any, +>(props: ArrayFieldTemplateProps) { + const { + canAdd, + disabled, + uiSchema, + items, + onAddClick, + schema, + readonly, + registry, + required, + title, + } = props; + + const uiOptions = getUiOptions(uiSchema); + const ArrayFieldItemTemplate = getTemplate<'ArrayFieldItemTemplate', T, S, F>( + 'ArrayFieldItemTemplate', + registry, + uiOptions, + ); + + const { + ButtonTemplates: { AddButton }, + } = registry.templates; + return ( + + + + + {title} + {required && ' *'} + + + {schema.description} + + + {items && + items.map(({ key, ...itemProps }: ArrayFieldTemplateItemType) => ( + + ))} + {canAdd && ( + + + + + + + + )} + + + ); +} + +function TitleFieldTemplate(props: TitleFieldProps) { + return ( + <> +

+ {props.title} + {props.required && '*'} +

+ + ); +} + +/** + * Component to select which type of service (which CRD) should be created. + */ +function SelectService(props: { + serviceKinds: CustomResourceDefinitionStub[]; + selectedServiceKind: CustomResourceDefinitionStub; + setSelectedServiceKind; + next: () => void; +}) { + const [isServiceKindTouched, setServiceKindTouched] = React.useState(false); + + const converter = React.useMemo(() => { + return new Converter(); + }, []); + + const [isDocumentationExpanded, setDocumentationExpanded] = React.useState(true); + + return ( +
{ + event.preventDefault(); + props.next(); + }} + > + + + Select Service Kind + + + Service Kind to Create + + The type of Operator-backed service to create + + {props.selectedServiceKind && props.selectedServiceKind.csvDescription && ( + + + + + { + e.preventDefault(); + setDocumentationExpanded(!isDocumentationExpanded); + }} + > + {isDocumentationExpanded ? : } + + Operator Documentation: + + + Most Operators require additional setup after installation that + is described here. + + + + +
+
+
+
+
+ )} + + {props.selectedServiceKind ? ( + + ) : ( + You must select a type of service to create. + )} + +
+
+ ); +} + +/** + * Component to set the required fields for the selected CRD using an RJSF form. + */ +function SpecifyService(props: { + serviceKind: CustomResourceDefinitionStub; + spec: object; + defaults: object; + next: () => void; + back: () => void; +}) { + const [formData, setFormData] = React.useState(props.defaults); + + const onSubmit = (_data, event: React.FormEvent): void => { + event.preventDefault(); window.vscodeApi.postMessage({ command: 'create', - formData: e.formData + data: formData, }); - } + props.next(); + }; - window.addEventListener('message', (event: any) => { - if(event?.data?.action === 'load') { - setBaseSchema(event.data.openAPIV3Schema); - setUiSchema(event.data.uiSchema); - setCrdDescription(event.data.crdDescription); - setFormData(event.data.formData); - setStep('loaded'); - } - if(event?.data?.action === 'error') { - setStep('loaded'); + return ( + + + + + + Create {props.serviceKind.kind} + +
setFormData((_) => e.formData)} + onSubmit={onSubmit} + liveValidate + noHtml5Validate + validator={validator} + showErrorList='top' + templates={{ ObjectFieldTemplate, TitleFieldTemplate, ArrayFieldTemplate }} + >
+
+ ); +} + +type CreateServicePage = 'Loading' | 'PickServiceKind' | 'ConfigureService' | 'Error'; + +export function CreateService() { + const [page, setPage] = React.useState('Loading'); + const [serviceKinds, setServiceKinds] = React.useState([]); + const [selectedServiceKind, setSelectedServiceKind] = + React.useState(undefined); + const [spec, setSpec] = React.useState(undefined); + const [defaults, setDefaults] = React.useState(undefined); + + const [themeKind, setThemeKind] = React.useState('light'); + const theme = React.useMemo(() => createVSCodeTheme(themeKind), [themeKind]); + const [error, setError] = React.useState(undefined); + + function messageListener(event) { + if (event?.data) { + const message = event.data; + switch (message.action) { + case 'setTheme': + setThemeKind(event.data.themeValue === 1 ? 'light' : 'dark'); + break; + case 'setServiceKinds': + setServiceKinds((_) => message.data); + setPage((_) => 'PickServiceKind'); + break; + case 'setSpec': + setSpec(message.data.spec); + setDefaults(message.data.defaults); + setPage('ConfigureService'); + break; + case 'error': + setError((prev) => message.data) + setPage((prev) => 'Error'); + break; + default: + break; + } } - }); - return <> - {step === 'ready' && ( -

Loading ....

- )} - - {(step === 'loaded' || step === 'creating') && ( -
-

Create {crdDescription.displayName}

-

{crdDescription.description}

-
<>, // to supress object editor title - 'Labels': () => <>}} // to suppress Labels field in first release - schema={baseSchema} - uiSchema={uiSchema} - onChange={()=> {changed = true}} - onSubmit={onSubmit} - liveValidate - disabled={step === 'creating'} - showErrorList={false} - validator={Validator} - >
- - -
-
-
- )} - ; + command: 'getSpec', + data: selectedServiceKind, + }); + setPage((_) => 'Loading'); + }} + /> + ); + break; + case 'ConfigureService': + pageElement = ( + { + setPage('Loading'); + }} + back={() => { + setPage('PickServiceKind'); + }} + /> + ); + break; + default: + <>Error; + } + + return ( + + {pageElement} + + ); } diff --git a/src/webview/create-service/app/index.html b/src/webview/create-service/app/index.html index b1ab37e43..e01f843fd 100644 --- a/src/webview/create-service/app/index.html +++ b/src/webview/create-service/app/index.html @@ -12,112 +12,6 @@ }
- diff --git a/src/webview/create-service/app/index.tsx b/src/webview/create-service/app/index.tsx index c0aab12df..ddca85239 100644 --- a/src/webview/create-service/app/index.tsx +++ b/src/webview/create-service/app/index.tsx @@ -5,8 +5,8 @@ import * as ReactDOM from 'react-dom'; import * as React from 'react'; -import { CreateForm } from './createForm'; +import { CreateService } from './createForm'; ReactDOM.render(( - + ), document.getElementById('root')); diff --git a/src/webview/create-service/createServiceViewLoader.ts b/src/webview/create-service/createServiceViewLoader.ts index 34f90e3f2..15645d596 100644 --- a/src/webview/create-service/createServiceViewLoader.ts +++ b/src/webview/create-service/createServiceViewLoader.ts @@ -2,32 +2,378 @@ * Copyright (c) Red Hat, Inc. All rights reserved. * Licensed under the MIT License. See LICENSE file in the project root for license information. *-----------------------------------------------------------------------------------------------*/ +import { randomInt } from 'crypto'; +import { dump as dumpYaml, load as loadYaml } from 'js-yaml'; +import { JSONSchema7 } from 'json-schema'; +import * as _ from 'lodash'; import * as path from 'path'; import * as vscode from 'vscode'; +import { ClusterServiceVersionKind, CustomResourceDefinitionKind } from '../../k8s/olm/types'; +import { Oc } from '../../oc/ocWrapper'; +import { Odo } from '../../odo/odoWrapper'; import { ExtensionID } from '../../util/constants'; import { loadWebviewHtml } from '../common-ext/utils'; +import type { + ClusterServiceVersion, + CustomResourceDefinitionStub, + SpecDescriptor +} from '../common/createServiceTypes'; export default class CreateServiceViewLoader { + private static panel: vscode.WebviewPanel; static get extensionPath(): string { return vscode.extensions.getExtension(ExtensionID).extensionPath; } - static async loadView(title: string, listenerFactory: (panel: vscode.WebviewPanel) => (event) => Promise): Promise { - const localResourceRoot = vscode.Uri.file(path.join(CreateServiceViewLoader.extensionPath, 'out', 'createServiceViewer')); + static async loadView(): Promise { + const localResourceRoot = vscode.Uri.file( + path.join(CreateServiceViewLoader.extensionPath, 'out', 'createServiceViewer'), + ); - let panel: vscode.WebviewPanel = vscode.window.createWebviewPanel('createServiceView', title, vscode.ViewColumn.One, { - enableScripts: true, - localResourceRoots: [localResourceRoot], - retainContextWhenHidden: true + if (CreateServiceViewLoader.panel) { + CreateServiceViewLoader.panel.reveal(); + return CreateServiceViewLoader.panel; + } + + CreateServiceViewLoader.panel = vscode.window.createWebviewPanel( + 'createServiceView', + 'Create Service', + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [localResourceRoot], + retainContextWhenHidden: true, + }, + ); + + CreateServiceViewLoader.panel.iconPath = vscode.Uri.file( + path.join(CreateServiceViewLoader.extensionPath, 'images/context/cluster-node.png'), + ); + CreateServiceViewLoader.panel.webview.html = await loadWebviewHtml( + 'createServiceViewer', + CreateServiceViewLoader.panel, + ); + + const colorThemeDisposable = vscode.window.onDidChangeActiveColorTheme(async function ( + colorTheme: vscode.ColorTheme, + ) { + await CreateServiceViewLoader.panel.webview.postMessage({ + action: 'setTheme', + themeValue: colorTheme.kind, + }); + }); + + CreateServiceViewLoader.panel.onDidDispose(() => { + colorThemeDisposable.dispose(); + CreateServiceViewLoader.panel = undefined; }); - panel.iconPath = vscode.Uri.file(path.join(CreateServiceViewLoader.extensionPath, 'images/context/cluster-node.png')); - panel.webview.html = await loadWebviewHtml('createServiceViewer', panel); - panel.onDidDispose(()=> { - panel = undefined; + CreateServiceViewLoader.panel.onDidDispose(() => { + CreateServiceViewLoader.panel = undefined; }); - panel.webview.onDidReceiveMessage(listenerFactory(panel)); - return panel; + CreateServiceViewLoader.panel.webview.onDidReceiveMessage( + CreateServiceViewLoader.messageListener, + ); + return CreateServiceViewLoader.panel; + } + + static async messageListener(message: { command: string; data: object }): Promise { + switch (message.command) { + case 'ready': + try { + // set theme + void CreateServiceViewLoader.panel.webview.postMessage({ + action: 'setTheme', + themeValue: vscode.window.activeColorTheme.kind, + }); + // send list of possible kinds of service to create + void CreateServiceViewLoader.panel.webview.postMessage({ + action: 'setServiceKinds', + data: await getServiceKindStubs(), + }); + } catch (e) { + void CreateServiceViewLoader.panel.webview.postMessage({ + action: 'error', + data: `${e}`, + }); + void vscode.window.showErrorMessage(`${e}`); + } + break; + case 'getSpec': { + try { + const clusterServiceVersion = await getClusterServiceVersionFromStub(message.data as CustomResourceDefinitionStub); + const defaults = await getDefaultsFromServiceKindStub( + clusterServiceVersion, + message.data as CustomResourceDefinitionStub, + ); + if (await Oc.Instance.canIGetCRDs()) { + // get the spec from the CRD + const spec = await getSpecFromServiceKindStub( + message.data as CustomResourceDefinitionStub, + defaults, + ); + void CreateServiceViewLoader.panel.webview.postMessage({ + action: 'setSpec', + data: { + spec, + defaults, + }, + }); + } else { + // create a new unsaved YAML file with the default data and show it to the user + const serviceYaml = dumpYaml(defaults); + const newTextDocument = await vscode.workspace.openTextDocument({ content: serviceYaml, language: 'yaml' }); + await vscode.window.showTextDocument(newTextDocument); + + // dispose webview and explain what happened + CreateServiceViewLoader.panel.dispose(); + CreateServiceViewLoader.panel = undefined; + + void vscode.window.showInformationMessage('Cannot access schema for the given service. Here is the YAML for the example instance'); + } + } catch (e) { + void CreateServiceViewLoader.panel.webview.postMessage({ + action: 'error', + data: `${e}`, + }); + void vscode.window.showErrorMessage(`${e}`); + } + break; + } + case 'create': { + try { + await Oc.Instance.createKubernetesObjectFromSpec(message.data); + void vscode.window.showInformationMessage(`Service ${(message.data as unknown as any).metadata.name} successfully created.` ); + CreateServiceViewLoader.panel.dispose(); + CreateServiceViewLoader.panel = undefined; + } catch (err) { + void CreateServiceViewLoader.panel.webview.postMessage({ + action: 'error', + data: `${err}`, + }); + void vscode.window.showErrorMessage(err); + } + break; + } + default: + void vscode.window.showErrorMessage(`Unrecognized message ${message.command}`); + } + } +} + +async function getServiceKindStubs(): Promise { + const clusterServiceVersions = (await Oc.Instance.getKubernetesObjects( + 'csv', + )) as ClusterServiceVersion[]; + return clusterServiceVersions.flatMap( + (clusterServiceVersion) => { + const serviceKinds = clusterServiceVersion.spec.customresourcedefinitions.owned; + for (const serviceKind of serviceKinds) { + serviceKind.csvDescription = clusterServiceVersion.spec.description; + } + return serviceKinds; + }, + ); +} + +/** + * @see `csv.ts` + */ +async function getSpecFromServiceKindStub( + stub: CustomResourceDefinitionStub, + defaults: object | undefined, +): Promise { + const serviceKind = await getFullServiceKindFromStub(stub); + const rawSchema: JSONSchema7 = serviceKind.spec.versions[0].schema.openAPIV3Schema; + cleanseSchema(rawSchema); + ensureRequiredPropertiesExist(rawSchema); + removeOptionalKeysFromSchema(rawSchema.properties.spec as JSONSchema7, defaults && (defaults as any).spec); + removeEmptyProperties(rawSchema); + if (stub.specDescriptors) { + addAdditionalDocumentationsToSchema(rawSchema.properties.spec as JSONSchema7, stub.specDescriptors); + } + return rawSchema; +} + +async function getFullServiceKindFromStub( + stub: CustomResourceDefinitionStub, +): Promise { + return (await Oc.Instance.getKubernetesObject( + 'CustomResourceDefinition', + stub.name, + )) as unknown as CustomResourceDefinitionKind; +} + +async function getDefaultsFromServiceKindStub(clusterServiceVersion: ClusterServiceVersionKind, stub: CustomResourceDefinitionStub): Promise { + let defaults: object = { + metadata: { + name: `${_.kebabCase(stub.kind)}${randomInt(100)}`, + namespace: await Odo.Instance.getActiveProject(), + } + }; + const examplesYaml: string = + clusterServiceVersion.metadata?.annotations?.['alm-examples']; + const examples: any[] = examplesYaml ? loadYaml(examplesYaml) as any[] : []; + const example = examples.find((item) => item.kind === stub.kind); + if (example) { + defaults = _.merge(example, defaults); + } + return defaults; +} + +async function getClusterServiceVersionFromStub(stub: CustomResourceDefinitionStub): Promise { + const clusterServiceVersions = (await Oc.Instance.getKubernetesObjects( + 'csv', + )) as unknown as ClusterServiceVersionKind[]; + + for (const clusterServiceVersion of clusterServiceVersions) { + if ( + clusterServiceVersion.spec.customresourcedefinitions.owned.find( + (crdStub) => crdStub.name === stub.name, + ) + ) { + return clusterServiceVersion; + } + } + // should never happen; if so, where did the stub come from? + return undefined; +} + +/** + * Mutates the schema: + * - Removes `status` as a property (not needed during custom resource creation) + * - Adds `metadata.name` and `metadata.namespace` as properties, + * and adds a description for them + */ +function cleanseSchema(schema: JSONSchema7): void { + + // remove status + delete schema.properties.status; + if (schema.required) { + schema.required = schema.required.filter((property) => property !== 'status'); + } + + // specify metadata + if (!schema.properties.metadata) { + schema.properties.metadata = { type: 'object' }; + } + if (!(schema.properties.metadata as JSONSchema7).properties) { + (schema.properties.metadata as JSONSchema7).properties = {}; + } + if (!(schema.properties.metadata as JSONSchema7).properties.name) { + (schema.properties.metadata as JSONSchema7).properties.name = { + type: 'string', + description: 'The name of the object that will be created', + }; + } + if (!(schema.properties.metadata as JSONSchema7).properties.namespace) { + (schema.properties.metadata as JSONSchema7).properties.namespace = { + type: 'string', + description: 'The namespace in which the object will be created', + }; + } +} + +/** + * Mutates the given schema to remove any non-required fields that aren't given a value by `data` + */ +function removeOptionalKeysFromSchema(schema: JSONSchema7, data?: object) { + if (!schema || schema.type !== 'object' || !schema.properties) { + return; + } + for (const key of Object.keys(schema.properties)) { + if (!schema.required || schema.required.includes(key) || (data && data[key])) { + removeOptionalKeysFromSchema(schema.properties[key] as JSONSchema7, data ? data[key] : undefined); + } else { + delete schema.properties[key]; + } + } +} + +/** + * + * If the schema has an entry in `required` that doesn't have a corresponding entry in `properties`, + * then create the entry, setting its type to string + * + * @param schema + */ +function ensureRequiredPropertiesExist(schema: JSONSchema7) { + if (!schema) { + return; + } + + // This is an object property without any specified keys + if (schema['x-kubernetes-preserve-unknown-fields']) { + schema.type = 'object'; + return; + } + + if (!schema.required || !schema.required.length) { + return; + } + + if (!schema.type) { + schema.type = 'object'; + } + if (!schema.properties) { + schema.properties = {}; + } + for (const requiredProperty of schema.required) { + if (!schema.properties[requiredProperty]) { + schema.properties[requiredProperty] = { + type: 'string', + } + } + } + for (const property of Object.keys(schema.properties)) { + ensureRequiredPropertiesExist(schema.properties[property] as JSONSchema7); + } +} + +function addAdditionalDocumentationsToSchema(schema: JSONSchema7, specDescriptors: SpecDescriptor[]) { + if (!schema) { + return; + } + for (const specDescriptor of specDescriptors) { + addAdditionalDocumentationToSchema(schema, specDescriptor); + } +} + +function addAdditionalDocumentationToSchema(schema: JSONSchema7, specDescriptor: SpecDescriptor) { + if (!schema || !schema.properties) { + return; + } + if (specDescriptor.path.indexOf('.') === -1 && schema.properties[specDescriptor.path]) { + const prop = (schema.properties[specDescriptor.path] as JSONSchema7); + if (!prop.description) { + prop.description = specDescriptor.description; + } + } else { + const firstSegment = specDescriptor.path.substring(0, specDescriptor.path.indexOf('.')); + if (schema.properties[firstSegment]) { + const newDescriptor: SpecDescriptor = { + ...specDescriptor, + path: specDescriptor.path.substring(specDescriptor.path.indexOf('.') + 1), + }; + addAdditionalDocumentationToSchema(schema.properties[firstSegment] as JSONSchema7, newDescriptor); + } + } +} + +function removeEmptyProperties(schema: JSONSchema7): void { + + if (!schema || schema.type !== 'object' || !schema.properties) { + return; + } + + for (const key of Object.keys(schema.properties)) { + if (typeof schema.properties[key] !== 'boolean' && (schema.properties[key] as JSONSchema7).type === 'object') { + if ((schema.properties[key] as JSONSchema7).properties && Object.keys((schema.properties[key] as JSONSchema7).properties).length) { + removeEmptyProperties(schema.properties[key] as JSONSchema7); + } else { + delete schema.properties[key]; + } + } } } diff --git a/src/webview/tsconfig.json b/src/webview/tsconfig.json index 664f62c66..458d52358 100644 --- a/src/webview/tsconfig.json +++ b/src/webview/tsconfig.json @@ -21,7 +21,4 @@ "common/*.tsx", "common/*.ts" ], - "exclude": [ - "*/*.ts" - ] } diff --git a/test/integration/ocWrapper.test.ts b/test/integration/ocWrapper.test.ts index 305111d83..08ec0e20b 100644 --- a/test/integration/ocWrapper.test.ts +++ b/test/integration/ocWrapper.test.ts @@ -243,4 +243,19 @@ suite('./oc/ocWrapper.ts', function () { }); + test('canIGetCRDs()', async function() { + let expected = false; + try { + // alternative method of checking if CRDs are accessible: try to get a crd + await Oc.Instance.getKubernetesObject('CustomResourceDefinition', 'bindablekinds.binding.operators.coreos.com'); + expected = true; + } catch (e) { + // do nothing + } + + const actual: boolean = await Oc.Instance.canIGetCRDs(); + + expect(actual).to.equal(expected); + }); + });