diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b5f60cb8..459fc1e73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,8 +10,20 @@ contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additio ## The 'Create Cluster' and 'Configure From Cluster' Commands -The 'Create Cluster' and 'Configure from Cluster' commands rely on provider-specific tools or APIs for interacting with the cloud or cluster. Unfortunately, we don't have a general-purpose, API-style extension point for implementing new cloud providers, because we don't know enough about how configuring different cluster types might work in different environments. +The 'Create Cluster' and 'Configure from Cluster' commands rely on provider-specific tools or APIs for interacting with the cloud or cluster. We have a basic extension point for implementing new cloud providers. **This is currently experimental and we welcome feedback.** -At the moment, the extension provides a generic (albeit fiddly) mechanism for managing a series of pages in a UI (example: displaying a list of accounts for the user to choose from), invoking tools or APIs for populating state to be used in constructing those pages (example: invoking the Azure CLI to get a list of subscriptions), and flowing choices and state between those pages (example: returning the selected account so that another tool can list the clusters in that account). However, the cloud-specific pages and logic have to be built using artisanal TypeScript and built into this extension via pull request - we don't provide a nice way for you to externalise different providers. +To implement a provider for the 'Create Cluster' and/or 'Configure from Cluster' commands, your extension needs to do the following: -We'd be _really really happy_ to work with anyone wanting to implement providers for e.g. minikube, GKE, EKS, etc., both to help you understand how the existing implementation works, and to collaborate on simplifying and stabilising an extension mechanism. +* Set itself for automatic activation (the `*` activation event). We know this isn't great and would love to discuss a better approach. +* Start a HTTP server listening on a port of your choice (see `port` below). This HTTP server will serve up the HTML pages by which the user chooses a cluster or specifies creation settings. +* Get the VS Code Kubernetes extension's API object via the `vscode.extensions.getExtension` and `Extension.activate()` functions. +* The API object has a field named `clusterProviderRegistry`. This is an object you can use to register the type(s) of cluster you support. Call the registry's `register` method, passing an object with the following fields: + * `id` (string): an identifier for the type of cluster. This needs to be unique across all providers, as we use it to dispatch the user's selected cluster type to the appropriate handler. E.g. for Azure Kubernetes Service we use `aks`. + * `displayName` (string): the display name for the type of cluster. E.g. for Azure Kubernetes Service we use (wait for it) `Azure Kubernetes Service`. + * `port` (number): the port on which your provider will serve the requisite configuration pages. + * `supportedActions` (string[]): an array of strings specifying which commands you support for this cluster type. The supported commands are `configure` and `create` - any other strings in the array will be ignored. +* When the user choose 'Create Cluster' or 'Configure from Cluster', the extension displays a list of all registered cluster types supporting the appropriate command (using the `displayName` attribute and filtering using the `supportedActions` attribute). +* When the user selects a cluster type, the extension makes a HTTP GET request to `http://localhost:/?clusterType=`, where `port` and `id` are the port and id associated with the cluster type in its registration, and `action` is `configure` or `create` according to which command the user is executing. +* Your HTTP server must respond to this request with a suitable first page. From this point on it is all in your hands, though typically your first page will gather some information, and have a link either to further information-gathering pages or to perform the action immediately. + +For an example of this, see the `components/clusterprovider/azure` directory. If you have any questions or run into any problems, please post an issue - we'll be very happy to help. diff --git a/package-lock.json b/package-lock.json index 254f3dbf3..47b337a5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,20 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@types/bunyan": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/@types/bunyan/-/bunyan-1.8.4.tgz", + "integrity": "sha512-bxOF3fsm69ezKxdcJ7Oo/PsZMOJ+JIV/QJO2IADfScmR3sLulR88dpSnz6+q+9JJ1kD7dXFFgUrGRSKHLkOX7w==", + "requires": { + "@types/events": "1.1.0", + "@types/node": "6.0.96" + } + }, + "@types/events": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/events/-/events-1.1.0.tgz", + "integrity": "sha512-y3bR98mzYOo0pAZuiLari+cQyiKk3UXRuT45h1RjhfeCzqkjaVsfZJNaxdgtk7/3tzOm1ozLTqEqMP3VbI48jw==" + }, "@types/mocha": { "version": "2.2.46", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-2.2.46.tgz", @@ -13,8 +27,25 @@ "@types/node": { "version": "6.0.96", "resolved": "https://registry.npmjs.org/@types/node/-/node-6.0.96.tgz", - "integrity": "sha512-fsOOY6tMQ3jCB2wD51XFDmmpgm4wVKkJECdcVRqapbJEa7awJDcr+SaH8toz+4r4KW8YQ3M7ybXMoSDo1QGewA==", - "dev": true + "integrity": "sha512-fsOOY6tMQ3jCB2wD51XFDmmpgm4wVKkJECdcVRqapbJEa7awJDcr+SaH8toz+4r4KW8YQ3M7ybXMoSDo1QGewA==" + }, + "@types/restify": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/restify/-/restify-5.0.7.tgz", + "integrity": "sha512-0bcMA32Ys6nOQnD4QD6vDvfJg7nx5Dbd+oItNFAad3lwnanm0CqxSZpPQVgVMdD4Vrq/dY7yTaEUwsXOFci2iw==", + "requires": { + "@types/bunyan": "1.8.4", + "@types/node": "6.0.96", + "@types/spdy": "3.4.4" + } + }, + "@types/spdy": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/@types/spdy/-/spdy-3.4.4.tgz", + "integrity": "sha512-N9LBlbVRRYq6HgYpPkqQc3a9HJ/iEtVZToW6xlTtJiMhmRJ7jJdV7TaZQJw/Ve/1ePUsQiCTDc4JMuzzag94GA==", + "requires": { + "@types/node": "6.0.96" + } }, "ajv": { "version": "5.5.2", @@ -309,6 +340,17 @@ "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", "dev": true }, + "bunyan": { + "version": "1.8.12", + "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.12.tgz", + "integrity": "sha1-8VDw9nSKvdcq6uhPBEA74u8RN5c=", + "requires": { + "dtrace-provider": "0.8.6", + "moment": "2.20.1", + "mv": "2.1.1", + "safe-json-stringify": "1.0.4" + } + }, "cache-base": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", @@ -464,6 +506,15 @@ "integrity": "sha1-4+JbIHrE5wGvch4staFnksrD3Fg=", "dev": true }, + "clone-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-1.0.0.tgz", + "integrity": "sha1-6uCiQT9VwJQvgYwin+/OhF1/Oxw=", + "requires": { + "is-regexp": "1.0.0", + "is-supported-regexp-flag": "1.0.0" + } + }, "clone-stats": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-0.0.1.tgz", @@ -582,6 +633,35 @@ } } }, + "csv": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/csv/-/csv-1.2.1.tgz", + "integrity": "sha1-UjHt/BxxUlEuxFeBB2p6l/9SXAw=", + "requires": { + "csv-generate": "1.1.2", + "csv-parse": "1.3.3", + "csv-stringify": "1.1.2", + "stream-transform": "0.2.2" + } + }, + "csv-generate": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/csv-generate/-/csv-generate-1.1.2.tgz", + "integrity": "sha1-7GsA7a7W5ZrZwgWC9MNk4osUYkA=" + }, + "csv-parse": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-1.3.3.tgz", + "integrity": "sha1-0c/YdDwvhJoKuy/VRNtWaV0ZpJA=" + }, + "csv-stringify": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-1.1.2.tgz", + "integrity": "sha1-d6QVJlgbzjOA8SsA18W7rHDIK1g=", + "requires": { + "lodash.get": "4.4.2" + } + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -662,6 +742,11 @@ "integrity": "sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=", "dev": true }, + "detect-node": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.0.3.tgz", + "integrity": "sha1-ogM8CcyOFY03dI+951B4Mr1s4Sc=" + }, "diff": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/diff/-/diff-1.4.0.tgz", @@ -678,6 +763,15 @@ "resolved": "https://registry.npmjs.org/dockerfile-parse/-/dockerfile-parse-0.2.0.tgz", "integrity": "sha1-59F8EK04xqBQ4k+7ToiVBcGSdvo=" }, + "dtrace-provider": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.6.tgz", + "integrity": "sha1-QooiOv4DQl0s1tY0f99AxmkDVj0=", + "optional": true, + "requires": { + "nan": "2.8.0" + } + }, "duplexer": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", @@ -749,6 +843,11 @@ "once": "1.4.0" } }, + "escape-regexp-component": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/escape-regexp-component/-/escape-regexp-component-1.0.2.tgz", + "integrity": "sha1-nGO20LJf8qiMOtvRjFthrMO5+qI=" + }, "escape-string-regexp": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.2.tgz", @@ -781,6 +880,14 @@ "through": "2.3.8" } }, + "ewma": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ewma/-/ewma-2.0.1.tgz", + "integrity": "sha512-MYYK17A76cuuyvkR7MnqLW4iFYPEi5Isl2qb8rXiWpLiwFS9dxW/rncuNnjjgSENuVqZQkIuR4+DChVL4g1lnw==", + "requires": { + "assert-plus": "1.0.0" + } + }, "expand-brackets": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", @@ -954,6 +1061,11 @@ "mime-types": "2.1.17" } }, + "formidable": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.1.1.tgz", + "integrity": "sha1-lriIb3w8NQi5Mta9cMTTqI818ak=" + }, "fragment-cache": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", @@ -1873,6 +1985,11 @@ "glogg": "1.0.0" } }, + "handle-thing": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-1.2.5.tgz", + "integrity": "sha1-/Xqtcmvxpf0W38KbL3pmAdJxOcQ=" + }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -2002,6 +2119,22 @@ "parse-passwd": "1.0.0" } }, + "hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=", + "requires": { + "inherits": "2.0.3", + "obuf": "1.1.1", + "readable-stream": "2.3.3", + "wbuf": "1.7.2" + } + }, + "http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=" + }, "http-signature": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", @@ -2250,6 +2383,11 @@ "integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=", "dev": true }, + "is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=" + }, "is-relative": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", @@ -2265,6 +2403,11 @@ "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", "dev": true }, + "is-supported-regexp-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-supported-regexp-flag/-/is-supported-regexp-flag-1.0.0.tgz", + "integrity": "sha1-i1IMhfrnolM4LUsCZS4EVXbhO7g=" + }, "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -2305,8 +2448,7 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "isexe": { "version": "2.0.0", @@ -2774,6 +2916,11 @@ "lodash._root": "3.0.1" } }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + }, "lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", @@ -2948,6 +3095,11 @@ } } }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, "mime-db": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.30.0.tgz", @@ -2961,6 +3113,11 @@ "mime-db": "1.30.0" } }, + "minimalistic-assert": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.0.tgz", + "integrity": "sha1-cCvi3aazf0g2vLP121ZkG2Sh09M=" + }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -3043,6 +3200,12 @@ } } }, + "moment": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.20.1.tgz", + "integrity": "sha512-Yh9y73JRljxW5QxN08Fner68eFLxM5ynNOAw2LbIB1YAGeQzZT8QFSUvkAz609Zf+IHhhaUxqZK8dG3W/+HEvg==", + "optional": true + }, "ms": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz", @@ -3069,6 +3232,47 @@ "duplexer2": "0.0.2" } }, + "mv": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", + "integrity": "sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI=", + "optional": true, + "requires": { + "mkdirp": "0.5.1", + "ncp": "2.0.0", + "rimraf": "2.4.5" + }, + "dependencies": { + "glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=", + "optional": true, + "requires": { + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "rimraf": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", + "integrity": "sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto=", + "optional": true, + "requires": { + "glob": "6.0.4" + } + } + } + }, + "nan": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.8.0.tgz", + "integrity": "sha1-7XFfP+neArV6XmJS2QqWZ14fCFo=", + "optional": true + }, "nanomatch": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.7.tgz", @@ -3123,6 +3327,17 @@ "integrity": "sha512-8eRaxn8u/4wN8tGkhlc2cgwwvOLMLUMUn4IYTexMgWd+LyUDfeXVkk2ygQR0hvIHbJQXgHujia3ieUUDwNGkEA==", "dev": true }, + "ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=", + "optional": true + }, + "negotiator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", + "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=" + }, "node.extend": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/node.extend/-/node.extend-1.1.6.tgz", @@ -3320,6 +3535,11 @@ } } }, + "obuf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.1.tgz", + "integrity": "sha1-EEEktsYCxnlogaBCVB0220OlJk4=" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3491,6 +3711,11 @@ "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" }, + "pidusage": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-1.2.0.tgz", + "integrity": "sha512-OGo+iSOk44HRJ8q15AyG570UYxcm5u+R99DI8Khu8P3tKGkVu5EZX4ywHglWSTMNNXQ274oeGpYrvFEhDIFGPg==" + }, "pinkie": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", @@ -3555,8 +3780,12 @@ "process-nextick-args": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", - "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=", - "dev": true + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=" }, "punycode": { "version": "1.4.1", @@ -3628,7 +3857,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", - "dev": true, "requires": { "core-util-is": "1.0.2", "inherits": "2.0.3", @@ -3759,6 +3987,57 @@ "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", "dev": true }, + "restify": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/restify/-/restify-6.3.4.tgz", + "integrity": "sha512-kYmXwCtj7gJ6e7vMwTjkFkygMTdcxwbJGJ+JXdsREMLx0h23mVBwLpyi2tqgI6DE1S0Al7//y7gbLXP79RqKaw==", + "requires": { + "assert-plus": "1.0.0", + "bunyan": "1.8.12", + "clone-regexp": "1.0.0", + "csv": "1.2.1", + "dtrace-provider": "0.8.6", + "escape-regexp-component": "1.0.2", + "ewma": "2.0.1", + "formidable": "1.1.1", + "http-signature": "1.2.0", + "lodash": "4.17.4", + "lru-cache": "4.1.1", + "mime": "1.6.0", + "negotiator": "0.6.1", + "once": "1.4.0", + "pidusage": "1.2.0", + "qs": "6.5.1", + "restify-errors": "5.0.0", + "semver": "5.4.1", + "spdy": "3.4.7", + "uuid": "3.1.0", + "vasync": "1.6.4", + "verror": "1.10.0" + }, + "dependencies": { + "lru-cache": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.1.tgz", + "integrity": "sha512-q4spe4KTfsAS1SUHLO0wz8Qiyf1+vMIAgpRYioFYDMNqKfHQbg+AVDH3i4fvpl71/P1L0dBl+fQi+P37UYf0ew==", + "requires": { + "pseudomap": "1.0.2", + "yallist": "2.1.2" + } + } + } + }, + "restify-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/restify-errors/-/restify-errors-5.0.0.tgz", + "integrity": "sha512-+vby9Kxf7qlzvbZSTIEGkIixkeHG+pVCl34dk6eKnL+ua4pCezpdLT/1/eabzPZb65ADrgoc04jeWrrF1E1pvQ==", + "requires": { + "assert-plus": "1.0.0", + "lodash": "4.17.4", + "safe-json-stringify": "1.0.4", + "verror": "1.10.0" + } + }, "rimraf": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", @@ -3778,11 +4057,21 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" }, + "safe-json-stringify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.0.4.tgz", + "integrity": "sha1-gaCY9Efku8P/MxKiQ1IbwGDvWRE=", + "optional": true + }, + "select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=" + }, "semver": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.4.1.tgz", - "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==", - "dev": true + "integrity": "sha512-WfG/X9+oATh81XtllIo/I8gOiY9EXRdv1cQdyykeXK17YcUW3EXUAi2To4pcH6nZtJPr7ZOpM5OMyWJZm+8Rsg==" }, "sequencify": { "version": "0.0.7", @@ -4024,6 +4313,63 @@ "integrity": "sha1-Gsu/tZJDbRC76PeFt8xvgoFQEsM=", "dev": true }, + "spdy": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-3.4.7.tgz", + "integrity": "sha1-Qv9B7OXMD5mjpsKKq7c/XDsDrLw=", + "requires": { + "debug": "2.6.9", + "handle-thing": "1.2.5", + "http-deceiver": "1.2.7", + "safe-buffer": "5.1.1", + "select-hose": "2.0.0", + "spdy-transport": "2.0.20" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "spdy-transport": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-2.0.20.tgz", + "integrity": "sha1-c15yBUxIayNU/onnAiVgBKOazk0=", + "requires": { + "debug": "2.6.9", + "detect-node": "2.0.3", + "hpack.js": "2.1.6", + "obuf": "1.1.1", + "readable-stream": "2.3.3", + "safe-buffer": "5.1.1", + "wbuf": "1.7.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, "split": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", @@ -4188,6 +4534,11 @@ "integrity": "sha1-1cdSgl5TZ+eG944Y5EXqIjoVWVI=", "dev": true }, + "stream-transform": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-0.2.2.tgz", + "integrity": "sha1-dYZ0h/SVKPi/HYJJllh1PQLfeDg=" + }, "streamfilter": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/streamfilter/-/streamfilter-1.0.7.tgz", @@ -4207,7 +4558,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", - "dev": true, "requires": { "safe-buffer": "5.1.1" } @@ -4805,8 +5155,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "uuid": { "version": "3.1.0", @@ -4828,6 +5177,29 @@ "integrity": "sha1-G5BKWWCfsyjvB4E4Qgk09rhnCaY=", "dev": true }, + "vasync": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-1.6.4.tgz", + "integrity": "sha1-3+k2Fq0OeugBszKp2Iv8XNyOHR8=", + "requires": { + "verror": "1.6.0" + }, + "dependencies": { + "extsprintf": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.2.0.tgz", + "integrity": "sha1-WtlGwi9bMrp/jNdCZxHG6KP8JSk=" + }, + "verror": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.6.0.tgz", + "integrity": "sha1-fROyex+swuLakEBetepuW90lLqU=", + "requires": { + "extsprintf": "1.2.0" + } + } + } + }, "verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", @@ -5007,6 +5379,14 @@ "winreg": "1.2.3" } }, + "wbuf": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.2.tgz", + "integrity": "sha1-1pe5nx9ZUS3ydRvkJ2nBWAtYAf4=", + "requires": { + "minimalistic-assert": "1.0.0" + } + }, "which": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/which/-/which-1.3.0.tgz", @@ -5032,6 +5412,11 @@ "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", "dev": true }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" + }, "yamljs": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/yamljs/-/yamljs-0.2.10.tgz", diff --git a/package.json b/package.json index 4543cdd05..60f9e2051 100644 --- a/package.json +++ b/package.json @@ -511,6 +511,8 @@ "opn": "^5.2.0", "pluralize": "^4.0.0", "portfinder": "^1.0.13", + "restify": "^6.3.4", + "@types/restify": "^5.0.7", "shelljs": "^0.7.7", "tmp": "^0.0.31", "uuid": "^3.1.0", diff --git a/src/azure.ts b/src/azure.ts deleted file mode 100644 index 7aca6d244..000000000 --- a/src/azure.ts +++ /dev/null @@ -1,203 +0,0 @@ -'use strict'; - -import { Shell } from './shell'; -import { FS } from './fs'; -import { Errorable, StageData } from './wizard'; -import * as compareVersions from 'compare-versions'; -import { sleep } from './sleep'; - -export interface Context { - readonly fs: FS; - readonly shell: Shell; -} - -export interface ServiceLocation { - readonly displayName: string; - readonly isPreview: boolean; -} - -export interface Locations { - readonly locations: any; -} - -export interface LocationRenderInfo { - readonly location: string; - readonly displayText: string; -} - -const MIN_AZ_CLI_VERSION = '2.0.23'; - -export async function getSubscriptionList(context: Context, forCommand: string) : Promise { - // check for prerequisites - const prerequisiteErrors = await verifyPrerequisitesAsync(context, forCommand); - if (prerequisiteErrors.length > 0) { - return { - actionDescription: 'checking prerequisites', - result: { succeeded: false, result: false, error: prerequisiteErrors } - }; - } - - // list subs - const subscriptions = await listSubscriptionsAsync(context); - return { - actionDescription: 'listing subscriptions', - result: subscriptions - }; -} - -async function verifyPrerequisitesAsync(context: Context, forCommand: string) : Promise { - const errors = new Array(); - - const azVersion = await azureCliVersion(context); - if (azVersion === null) { - errors.push('Azure CLI 2.0 not found - install Azure CLI 2.0 and log in'); - } else if (compareVersions(azVersion, MIN_AZ_CLI_VERSION) < 0) { - errors.push(`Azure CLI required version is ${MIN_AZ_CLI_VERSION} (you have ${azVersion}) - you need to upgrade Azure CLI 2.0`); - } - - if (forCommand == 'acs') { - prereqCheckSSHKeys(context, errors); - } - - return errors; -} - -async function azureCliVersion(context: Context) : Promise { - const sr = await context.shell.exec('az --version'); - if (sr.code !== 0 || sr.stderr) { - return null; - } else { - const versionMatches = /azure-cli \(([^)]+)\)/.exec(sr.stdout); - if (versionMatches === null || versionMatches.length < 2) { - return null; - } - return versionMatches[1]; - } -} - -function prereqCheckSSHKeys(context: Context, errors: Array) { - const sshKeyFile = context.shell.combinePath(context.shell.home(), '.ssh/id_rsa'); - if (!context.fs.existsSync(sshKeyFile)) { - errors.push('SSH keys not found - expected key file at ' + sshKeyFile); - } -} - -async function listSubscriptionsAsync(context: Context) : Promise> { - const sr = await context.shell.exec("az account list --all --query [*].name -ojson"); - - if (sr.code === 0 && !sr.stderr) { // az account list returns exit code 0 even if not logged in - const accountNames : string[] = JSON.parse(sr.stdout); - return { succeeded: true, result: accountNames, error: [] }; - } else { - return { succeeded: false, result: [], error: [sr.stderr] }; - } -} - -export async function setSubscriptionAsync(context: Context, subscription: string) : Promise> { - const sr = await context.shell.exec(`az account set --subscription "${subscription}"`); - - if (sr.code === 0 && !sr.stderr) { - return { succeeded: true, result: null, error: [] }; - } else { - return { succeeded: false, result: null, error: [sr.stderr] }; - } -} - -export async function configureCluster(context: Context, clusterType: string, clusterName: string, clusterGroup: string) : Promise { - const downloadKubectlCliPromise = downloadKubectlCli(context, clusterType); - const getCredentialsPromise = getCredentials(context, clusterType, clusterName, clusterGroup, 5); - - const [cliResult, credsResult] = await Promise.all([downloadKubectlCliPromise, getCredentialsPromise]); - - const result = { - gotCli: cliResult.succeeded, - cliInstallFile: cliResult.installFile, - cliOnDefaultPath: cliResult.onDefaultPath, - cliError: cliResult.error, - gotCredentials: credsResult.succeeded, - credentialsError: credsResult.error - }; - - return { - actionDescription: 'configuring Kubernetes', - result: { succeeded: cliResult.succeeded && credsResult.succeeded, result: result, error: [] } // TODO: this ends up not fitting our structure very well - fix? - }; -} - -async function downloadKubectlCli(context: Context, clusterType: string) : Promise { - const cliInfo = installKubectlCliInfo(context, clusterType); - - const sr = await context.shell.exec(cliInfo.commandLine); - if (sr.code === 0) { - return { - succeeded: true, - installFile: cliInfo.installFile, - onDefaultPath: !context.shell.isWindows() - }; - } else { - return { - succeeded: false, - error: sr.stderr - }; - } -} - -async function getCredentials(context: Context, clusterType: string, clusterName: string, clusterGroup: string, maxAttempts: number) : Promise { - let attempts = 0; - while (true) { - attempts++; - const cmd = `az ${getClusterCommandAndSubcommand(clusterType)} get-credentials -n ${clusterName} -g ${clusterGroup}`; - const sr = await context.shell.exec(cmd); - - if (sr.code === 0 && !sr.stderr) { - return { - succeeded: true - }; - } else if (attempts < maxAttempts) { - await sleep(15000); - } else { - return { - succeeded: false, - error: sr.stderr - }; - } - } -} - -function installKubectlCliInfo(context: Context, clusterType: string) { - const cmdCore = `az ${getClusterCommandAndSubcommand(clusterType)} install-cli`; - const isWindows = context.shell.isWindows(); - if (isWindows) { - // The default Windows install location requires admin permissions; install - // into a user profile directory instead. We process the path explicitly - // instead of using %LOCALAPPDATA% in the command, so that we can render the - // physical path when notifying the user. - const appDataDir = process.env['LOCALAPPDATA']; - const installDir = appDataDir + '\\kubectl'; - const installFile = installDir + '\\kubectl.exe'; - const cmd = `(if not exist "${installDir}" md "${installDir}") & ${cmdCore} --install-location="${installFile}"`; - return { installFile: installFile, commandLine: cmd }; - } else { - // Bah, the default Linux install location requires admin permissions too! - // Fortunately, $HOME/bin is on the path albeit not created by default. - const homeDir = process.env['HOME']; - const installDir = homeDir + '/bin'; - const installFile = installDir + '/kubectl'; - const cmd = `mkdir -p "${installDir}" ; ${cmdCore} --install-location="${installFile}"`; - return { installFile: installFile, commandLine: cmd }; - } -} - -export function getClusterCommand(clusterType: string) : string { - if (clusterType == 'Azure Container Service') { - return 'acs'; - } - return 'aks'; -} - -export function getClusterCommandAndSubcommand(clusterType: string) : string { - if (clusterType == 'Azure Container Service') { - return 'acs kubernetes'; - } - return 'aks'; -} diff --git a/src/components/clusterprovider/azure/azure.ts b/src/components/clusterprovider/azure/azure.ts new file mode 100644 index 000000000..973a7e6a4 --- /dev/null +++ b/src/components/clusterprovider/azure/azure.ts @@ -0,0 +1,388 @@ +'use strict'; + +import { Shell } from '../../../shell'; +import { FS } from '../../../fs'; +import { Errorable, ActionResult, fromShellJson, fromShellExitCode } from '../../../wizard'; +import * as compareVersions from 'compare-versions'; +import { sleep } from '../../../sleep'; + +export interface Context { + readonly fs: FS; + readonly shell: Shell; +} + +export interface ServiceLocation { + readonly displayName: string; + readonly isPreview: boolean; +} + +export interface Locations { + readonly locations: any; +} + +export interface LocationRenderInfo { + readonly location: string; + readonly displayText: string; +} + +export interface ClusterInfo { + readonly name: string; + readonly resourceGroup: string; +} + +export interface ConfigureResult { + readonly gotCli: boolean; + readonly cliInstallFile: string; + readonly cliOnDefaultPath: boolean; + readonly cliError: string; + readonly gotCredentials: boolean; + readonly credentialsError: string; +} + +export interface WaitResult { + readonly stillWaiting?: boolean; +} + +const MIN_AZ_CLI_VERSION = '2.0.23'; + +export async function getSubscriptionList(context: Context, forCommand: string) : Promise> { + // check for prerequisites + const prerequisiteErrors = await verifyPrerequisitesAsync(context, forCommand); + if (prerequisiteErrors.length > 0) { + return { + actionDescription: 'checking prerequisites', + result: { succeeded: false, result: [], error: prerequisiteErrors } + }; + } + + // list subs + const subscriptions = await listSubscriptionsAsync(context); + return { + actionDescription: 'listing subscriptions', + result: subscriptions + }; +} + +async function verifyPrerequisitesAsync(context: Context, forCommand: string) : Promise { + const errors = new Array(); + + const azVersion = await azureCliVersion(context); + if (azVersion === null) { + errors.push('Azure CLI 2.0 not found - install Azure CLI 2.0 and log in'); + } else if (compareVersions(azVersion, MIN_AZ_CLI_VERSION) < 0) { + errors.push(`Azure CLI required version is ${MIN_AZ_CLI_VERSION} (you have ${azVersion}) - you need to upgrade Azure CLI 2.0`); + } + + if (forCommand == 'acs') { + prereqCheckSSHKeys(context, errors); + } + + return errors; +} + +async function azureCliVersion(context: Context) : Promise { + const sr = await context.shell.exec('az --version'); + if (sr.code !== 0 || sr.stderr) { + return null; + } else { + const versionMatches = /azure-cli \(([^)]+)\)/.exec(sr.stdout); + if (versionMatches === null || versionMatches.length < 2) { + return null; + } + return versionMatches[1]; + } +} + +function prereqCheckSSHKeys(context: Context, errors: Array) { + const sshKeyFile = context.shell.combinePath(context.shell.home(), '.ssh/id_rsa'); + if (!context.fs.existsSync(sshKeyFile)) { + errors.push('SSH keys not found - expected key file at ' + sshKeyFile); + } +} + +async function listSubscriptionsAsync(context: Context) : Promise> { + const sr = await context.shell.exec("az account list --all --query [*].name -ojson"); + + return fromShellJson(sr); +} + +export async function setSubscriptionAsync(context: Context, subscription: string) : Promise> { + const sr = await context.shell.exec(`az account set --subscription "${subscription}"`); + + return fromShellExitCode(sr); +} + +export async function getClusterList(context: Context, subscription: string, clusterType: string) : Promise> { + // log in + const login = await setSubscriptionAsync(context, subscription); + if (!login.succeeded) { + return { + actionDescription: 'logging into subscription', + result: { succeeded: false, result: [], error: login.error } + }; + } + + // list clusters + const clusters = await listClustersAsync(context, clusterType); + return { + actionDescription: 'listing clusters', + result: clusters + }; +} + +async function listClustersAsync(context: Context, clusterType: string) : Promise> { + let cmd = getListClustersCommand(context, clusterType); + const sr = await context.shell.exec(cmd); + + return fromShellJson(sr); +} + +function listClustersFilter(clusterType: string): string { + if (clusterType === 'acs') { + return '?orchestratorProfile.orchestratorType==`Kubernetes`'; + } + return ''; +} + +function getListClustersCommand(context: Context, clusterType: string) : string { + let filter = listClustersFilter(clusterType); + let query = `[${filter}].{name:name,resourceGroup:resourceGroup}`; + if (context.shell.isUnix()) { + query = `'${query}'`; + } + return `az ${getClusterCommand(clusterType)} list --query ${query} -ojson`; +} + +async function listLocations(context: Context) : Promise> { + let query = "[].{name:name,displayName:displayName}"; + if (context.shell.isUnix()) { + query = `'${query}'`; + } + + const sr = await context.shell.exec(`az account list-locations --query ${query} -ojson`); + + return fromShellJson(sr, (response) => { + let locations : any = {}; + for (const r of response) { + locations[r.name] = r.displayName; + } + return { locations: locations }; + }); +} + +export async function listAcsLocations(context: Context) : Promise> { + const locationInfo = await listLocations(context); + if (!locationInfo.succeeded) { + return { succeeded: false, result: [], error: locationInfo.error }; + } + const locations = locationInfo.result; + + const sr = await context.shell.exec(`az acs list-locations -ojson`); + + return fromShellJson(sr, (response) => + locationDisplayNamesEx(response.productionRegions, response.previewRegions, locations)); +} + +export async function listAksLocations(context: Context) : Promise> { + const locationInfo = await listLocations(context); + if (!locationInfo.succeeded) { + return { succeeded: false, result: [], error: locationInfo.error }; + } + const locations = locationInfo.result; + + // There's no CLI for this, so we have to hardwire it for now + const productionRegions = []; + const previewRegions = ["centralus", "eastus", "westeurope"]; + const result = locationDisplayNamesEx(productionRegions, previewRegions, locations); + return { succeeded: true, result: result, error: [] }; +} + +function locationDisplayNames(names: string[], preview: boolean, locationInfo: Locations) : ServiceLocation[] { + return names.map((n) => { return { displayName: locationInfo.locations[n], isPreview: preview }; }); +} + +function locationDisplayNamesEx(production: string[], preview: string[], locationInfo: Locations) : ServiceLocation[] { + let result = locationDisplayNames(production, false, locationInfo) ; + result = result.concat(locationDisplayNames(preview, true, locationInfo)); + return result; +} + +export async function listVMSizes(context: Context, location: string) : Promise> { + const sr = await context.shell.exec(`az vm list-sizes -l "${location}" -ojson`); + + return fromShellJson(sr, (response) => response.map((r) => r.name as string)); +} + +async function resourceGroupExists(context: Context, resourceGroupName: string) : Promise { + const sr = await context.shell.exec(`az group show -n "${resourceGroupName}" -ojson`); + + if (sr.code === 0 && !sr.stderr) { + return sr.stdout !== null && sr.stdout.length > 0; + } else { + return false; + } +} + +async function ensureResourceGroupAsync(context: Context, resourceGroupName: string, location: string) : Promise> { + if (await resourceGroupExists(context, resourceGroupName)) { + return { succeeded: true, result: null, error: [] }; + } + + const sr = await context.shell.exec(`az group create -n "${resourceGroupName}" -l "${location}"`); + + return fromShellExitCode(sr); +} + +async function execCreateClusterCmd(context: Context, options: any) : Promise> { + const clusterCmd = getClusterCommand(options.clusterType); + let createCmd = `az ${clusterCmd} create -n "${options.metadata.clusterName}" -g "${options.metadata.resourceGroupName}" -l "${options.metadata.location}" --no-wait `; + if (clusterCmd == 'acs') { + createCmd = createCmd + `--agent-count ${options.agentSettings.count} --agent-vm-size "${options.agentSettings.vmSize}" -t Kubernetes`; + } else { + createCmd = createCmd + `--node-count ${options.agentSettings.count} --node-vm-size "${options.agentSettings.vmSize}"`; + } + + const sr = await context.shell.exec(createCmd); + + return fromShellExitCode(sr); +} + +export async function createCluster(context: Context, options: any) : Promise> { + const description = ` + Created ${options.clusterType} cluster ${options.metadata.clusterName} in ${options.metadata.resourceGroupName} with ${options.agentSettings.count} agents. + `; + + const login = await setSubscriptionAsync(context, options.subscription); + if (!login.succeeded) { + return { + actionDescription: 'logging into subscription', + result: login + }; + } + + const ensureResourceGroup = await ensureResourceGroupAsync(context, options.metadata.resourceGroupName, options.metadata.location); + if (!ensureResourceGroup.succeeded) { + return { + actionDescription: 'ensuring resource group exists', + result: ensureResourceGroup + }; + } + + const createCluster = await execCreateClusterCmd(context, options); + + return { + actionDescription: 'creating cluster', + result: createCluster + }; +} + +export async function waitForCluster(context: Context, clusterType: string, clusterName: string, clusterResourceGroup: string): Promise> { + const clusterCmd = getClusterCommand(clusterType); + const waitCmd = `az ${clusterCmd} wait --created --timeout 15 -n ${clusterName} -g ${clusterResourceGroup} -o json`; + const sr = await context.shell.exec(waitCmd); + + if (sr.code === 0) { + return { succeeded: true, result: { stillWaiting: sr.stdout !== "" }, error: [] }; + } else { + return { succeeded: false, result: { }, error: [sr.stderr] }; + } +} + +export async function configureCluster(context: Context, clusterType: string, clusterName: string, clusterGroup: string) : Promise> { + const downloadKubectlCliPromise = downloadKubectlCli(context, clusterType); + const getCredentialsPromise = getCredentials(context, clusterType, clusterName, clusterGroup, 5); + + const [cliResult, credsResult] = await Promise.all([downloadKubectlCliPromise, getCredentialsPromise]); + + const result = { + gotCli: cliResult.succeeded, + cliInstallFile: cliResult.installFile, + cliOnDefaultPath: cliResult.onDefaultPath, + cliError: cliResult.error, + gotCredentials: credsResult.succeeded, + credentialsError: credsResult.error + }; + + return { + actionDescription: 'configuring Kubernetes', + result: { succeeded: cliResult.succeeded && credsResult.succeeded, result: result, error: [] } // TODO: this ends up not fitting our structure very well - fix? + }; +} + +async function downloadKubectlCli(context: Context, clusterType: string) : Promise { + const cliInfo = installKubectlCliInfo(context, clusterType); + + const sr = await context.shell.exec(cliInfo.commandLine); + if (sr.code === 0) { + return { + succeeded: true, + installFile: cliInfo.installFile, + onDefaultPath: !context.shell.isWindows() + }; + } else { + return { + succeeded: false, + error: sr.stderr + }; + } +} + +async function getCredentials(context: Context, clusterType: string, clusterName: string, clusterGroup: string, maxAttempts: number) : Promise { + let attempts = 0; + while (true) { + attempts++; + const cmd = `az ${getClusterCommandAndSubcommand(clusterType)} get-credentials -n ${clusterName} -g ${clusterGroup}`; + const sr = await context.shell.exec(cmd); + + if (sr.code === 0 && !sr.stderr) { + return { + succeeded: true + }; + } else if (attempts < maxAttempts) { + await sleep(15000); + } else { + return { + succeeded: false, + error: sr.stderr + }; + } + } +} + +function installKubectlCliInfo(context: Context, clusterType: string) { + const cmdCore = `az ${getClusterCommandAndSubcommand(clusterType)} install-cli`; + const isWindows = context.shell.isWindows(); + if (isWindows) { + // The default Windows install location requires admin permissions; install + // into a user profile directory instead. We process the path explicitly + // instead of using %LOCALAPPDATA% in the command, so that we can render the + // physical path when notifying the user. + const appDataDir = process.env['LOCALAPPDATA']; + const installDir = appDataDir + '\\kubectl'; + const installFile = installDir + '\\kubectl.exe'; + const cmd = `(if not exist "${installDir}" md "${installDir}") & ${cmdCore} --install-location="${installFile}"`; + return { installFile: installFile, commandLine: cmd }; + } else { + // Bah, the default Linux install location requires admin permissions too! + // Fortunately, $HOME/bin is on the path albeit not created by default. + const homeDir = process.env['HOME']; + const installDir = homeDir + '/bin'; + const installFile = installDir + '/kubectl'; + const cmd = `mkdir -p "${installDir}" ; ${cmdCore} --install-location="${installFile}"`; + return { installFile: installFile, commandLine: cmd }; + } +} + +function getClusterCommand(clusterType: string) : string { + if (clusterType === 'Azure Container Service' || clusterType === 'acs') { + return 'acs'; + } + return 'aks'; +} + +function getClusterCommandAndSubcommand(clusterType: string) : string { + if (clusterType === 'Azure Container Service' || clusterType === 'acs') { + return 'acs kubernetes'; + } + return 'aks'; +} diff --git a/src/components/clusterprovider/azure/azureclusterprovider.ts b/src/components/clusterprovider/azure/azureclusterprovider.ts new file mode 100644 index 000000000..425932e61 --- /dev/null +++ b/src/components/clusterprovider/azure/azureclusterprovider.ts @@ -0,0 +1,448 @@ +import * as restify from 'restify'; +import * as portfinder from 'portfinder'; +import * as clusterproviderregistry from '../clusterproviderregistry'; +import * as azure from './azure'; +import { Errorable, script, styles, formStyles, waitScript, ActionResult } from '../../../wizard'; + +// HTTP request dispatch + +// TODO: de-globalise +let wizardServer : restify.Server; +let wizardPort : number; + +type HtmlRequestHandler = ( + step: string | undefined, + context: azure.Context, + requestData: any, +) => Promise; + +export async function init(registry: clusterproviderregistry.ClusterProviderRegistry, context: azure.Context) : Promise { + if (!wizardServer) { + wizardServer = restify.createServer({ + formatters: { + 'text/html': (req, resp, body) => body + } + }); + + wizardPort = await portfinder.getPortPromise({ port: 44000 }); + + const htmlServer = new HtmlServer(context); + + wizardServer.use(restify.plugins.queryParser(), restify.plugins.bodyParser()); + wizardServer.listen(wizardPort, '127.0.0.1'); + + // You MUST use fat arrow notation for the handler callbacks: passing the + // function reference directly will foul up the 'this' pointer. + wizardServer.get('/create', (req, resp, n) => htmlServer.handleGetCreate(req, resp, n)); + wizardServer.post('/create', (req, resp, n) => htmlServer.handlePostCreate(req, resp, n)); + wizardServer.get('/configure', (req, resp, n) => htmlServer.handleGetConfigure(req, resp, n)); + wizardServer.post('/configure', (req, resp, n) => htmlServer.handlePostConfigure(req, resp, n)); + + registry.register({id: 'acs', displayName: "Azure Container Service", port: wizardPort, supportedActions: ['create','configure']}); + registry.register({id: 'aks', displayName: "Azure Kubernetes Service", port: wizardPort, supportedActions: ['create','configure']}); + } +} + +class HtmlServer { + constructor(private readonly context: azure.Context) {} + + async handleGetCreate(request: restify.Request, response: restify.Response, next: restify.Next) { + await this.handleCreate(request, { clusterType: request.query["clusterType"] }, response, next); + } + + async handlePostCreate(request: restify.Request, response: restify.Response, next: restify.Next) { + await this.handleCreate(request, request.body, response, next); + } + + async handleGetConfigure(request: restify.Request, response: restify.Response, next: restify.Next) { + await this.handleConfigure(request, { clusterType: request.query["clusterType"] }, response, next); + } + + async handlePostConfigure(request: restify.Request, response: restify.Response, next: restify.Next) { + await this.handleConfigure(request, request.body, response, next); + } + + async handleCreate(request: restify.Request, requestData: any, response: restify.Response, next: restify.Next) : Promise { + await this.handleRequest(getHandleCreateHtml, request, requestData, response, next); + } + + async handleConfigure(request: restify.Request, requestData: any, response: restify.Response, next: restify.Next) : Promise { + await this.handleRequest(getHandleConfigureHtml, request, requestData, response, next); + } + + async handleRequest(handler: HtmlRequestHandler, request: restify.Request, requestData: any, response: restify.Response, next: restify.Next) { + const html = await handler(request.query["step"], this.context, requestData); + + response.contentType = 'text/html'; + response.send("" + html + ""); + + next(); + } +} + +async function getHandleCreateHtml(step: string | undefined, context: azure.Context, requestData: any): Promise { + if (!step) { + return await promptForSubscription(requestData, context, "create", "metadata"); + } else if (step === "metadata") { + return await promptForMetadata(requestData, context); + } else if (step === "agentSettings") { + return await promptForAgentSettings(requestData, context); + } else if (step === "create") { + return await createCluster(requestData, context); + } else if (step === "wait") { + return await waitForClusterAndReportConfigResult(requestData, context); + } else { + return renderInternalError(`AzureStepError (${step})`); + } +} + +async function getHandleConfigureHtml(step: string | undefined, context: azure.Context, requestData: any): Promise { + if (!step) { + return await promptForSubscription(requestData, context, "configure", "cluster"); + } else if (step === "cluster") { + return await promptForCluster(requestData, context); + } else if (step === "configure") { + return await configureKubernetes(requestData, context); + } else { + return renderInternalError(`AzureStepError (${step})`); + } +} + +// HTML rendering boilerplate + +function propagationFields(previousData: any) : string { + let formFields = ""; + for (const k in previousData) { + formFields = formFields + `\n`; + } + return formFields; +} + +interface FormData { + stepId: string; + title: string; + waitText: string; + action: string; + nextStep: string; + submitText: string; + previousData: any; + formContent: string; +} + +function formPage(fd: FormData) : string { + return ` +

${fd.title}

+ ${formStyles()} + ${styles()} + ${waitScript(fd.waitText)} +
+
+ ${propagationFields(fd.previousData)} + ${fd.formContent} +

+ +

+
+
`; +} + +// Pages for the various wizard steps + +async function promptForSubscription(previousData: any, context: azure.Context, action: clusterproviderregistry.ClusterProviderAction, nextStep: string) : Promise { + const subscriptionList = await azure.getSubscriptionList(context, previousData.id); + if (!subscriptionList.result.succeeded) { + return renderCliError('PromptForSubscription', subscriptionList); + } + + const subscriptions : string[] = subscriptionList.result.result; + + if (!subscriptions || !subscriptions.length) { + return renderNoOptions('PromptForSubscription', 'No Azure subscriptions', 'You have no Azure subscriptions.'); + } + + const options = subscriptions.map((s) => ``).join('\n'); + return formPage({ + stepId: 'PromptForSubscription', + title: 'Choose subscription', + waitText: 'Contacting Microsoft Azure', + action: action, + nextStep: nextStep, + submitText: 'Next', + previousData: previousData, + formContent: ` +

+ Azure subscription: +

+ +

Important! The selected subscription will be set as the active subscription for the Azure CLI.

+ ` + }); +} + +async function promptForCluster(previousData: any, context: azure.Context) : Promise { + const clusterList = await azure.getClusterList(context, previousData.subscription, previousData.clusterType); + + if (!clusterList.result.succeeded) { + return renderCliError('PromptForCluster', clusterList); + } + + const clusters = clusterList.result.result; + + if (!clusters || clusters.length === 0) { + return renderNoOptions('PromptForCluster', 'No clusters', 'There are no Kubernetes clusters in the selected subscription.'); + } + + const options = clusters.map((c) => ``).join('\n'); + return formPage({ + stepId: 'PromptForCluster', + title: 'Choose cluster', + waitText: 'Configuring Kubernetes', + action: 'configure', + nextStep: 'configure', + submitText: 'Configure', + previousData: previousData, + formContent: ` +

+ Kubernetes cluster: +

+ ` + }); +} + +async function configureKubernetes(previousData: any, context: azure.Context) : Promise { + const selectedCluster = parseCluster(previousData.cluster); + const configureResult = await azure.configureCluster(context, previousData.clusterType, selectedCluster.name, selectedCluster.resourceGroup); + return renderConfigurationResult(configureResult); +} + +async function promptForMetadata(previousData: any, context: azure.Context) : Promise { + const serviceLocations = previousData.clusterType === 'acs' ? + await azure.listAcsLocations(context) : + await azure.listAksLocations(context); + + if (!serviceLocations.succeeded) { + return renderCliError('PromptForMetadata', { + actionDescription: 'listing available regions', + result: serviceLocations + }); + } + + const options = serviceLocations.result.map((s) => ``).join('\n'); + + return formPage({ + stepId: 'PromptForMetadata', + title: 'Azure cluster settings', + waitText: 'Contacting Microsoft Azure', + action: 'create', + nextStep: 'agentSettings', + submitText: 'Next', + previousData: previousData, + formContent: ` +

Cluster name: +

Resource group name: +

+ Location: +

+ ` + }); +} + +async function promptForAgentSettings(previousData: any, context: azure.Context) : Promise { + const vmSizes = await azure.listVMSizes(context, previousData.location); + if (!vmSizes.succeeded) { + return renderCliError('PromptForAgentSettings', { + actionDescription: 'listing available node sizes', + result: vmSizes + }); + } + + const defaultSize = "Standard_D2_v2"; + const options = vmSizes.result.map((s) => ``).join('\n'); + + return formPage({ + stepId: 'PromptForAgentSettings', + title: 'Azure agent settings', + waitText: 'Contacting Microsoft Azure', + action: 'create', + nextStep: 'create', + submitText: 'Create cluster', + previousData: previousData, + formContent: ` +

Agent count: +

+ Agent VM size: +

+ ` + }); +} + +async function createCluster(previousData: any, context: azure.Context) : Promise { + + const options = { + clusterType: previousData.clusterType, + subscription: previousData.subscription, + metadata: { + location: previousData.location, + resourceGroupName: previousData.resourcegroupname, + clusterName: previousData.clustername + }, + agentSettings: { + count: previousData.agentcount, + vmSize: previousData.agentvmsize + + } }; + const createResult = await azure.createCluster(context, options); + + const title = createResult.result.succeeded ? 'Cluster creation has started' : `Error ${createResult.actionDescription}`; + const additionalDiagnostic = diagnoseCreationError(createResult.result); + const message = createResult.result.succeeded ? + `
+ ${formStyles()} + ${styles()} + ${waitScript('Contacting Microsoft Azure')} +
+ ${propagationFields(previousData)} +

Azure is creating the cluster, but this may take some time. You can now close this window, + or wait for creation to complete so that we can configure the extension to use the cluster.

+

+
+
` : + `

An error occurred while creating the cluster.

+ ${additionalDiagnostic} +

Details

+

${createResult.result.error[0]}

`; + return ` +

${title}

+ ${styles()} + ${waitScript('Waiting for cluster - this will take several minutes')} + ${message}`; + +} + +let refreshCount = 0; // TODO: ugh + +function refreshCountIndicator() : string { + return ".".repeat(refreshCount % 4); +} + +async function waitForClusterAndReportConfigResult(previousData: any, context: azure.Context) : Promise { + + ++refreshCount; + + const waitResult = await azure.waitForCluster(context, previousData.clusterType, previousData.clustername, previousData.resourcegroupname); + if (!waitResult.succeeded) { + return `

Error creating cluster

Error details: ${waitResult.error[0]}

`; + } + + if (waitResult.result.stillWaiting) { + return `

Waiting for cluster - this will take several minutes${refreshCountIndicator()}

+
+ ${propagationFields(previousData)} +
+ `; + } + + const configureResult = await azure.configureCluster(context, previousData.clusterType, previousData.clustername, previousData.resourcegroupname); + + return renderConfigurationResult(configureResult); +} + +function renderConfigurationResult(configureResult: ActionResult) : string { + const title = configureResult.result.succeeded ? 'Configuration completed' : `Error ${configureResult.actionDescription}`; + const configResult = configureResult.result.result; + const pathMessage = configResult.cliOnDefaultPath ? '' : + '

This location is not on your system PATH. Add this directory to your path, or set the VS Code vs-kubernetes.kubectl-path config setting.

'; + const getCliOutput = configResult.gotCli ? + `

kubectl installed at ${configResult.cliInstallFile}

${pathMessage}` : + `

An error occurred while downloading kubectl.

+

Details

+

${configResult.cliError}

`; + const getCredsOutput = configResult.gotCredentials ? + `

Successfully configured kubectl with Azure Container Service cluster credentials.

` : + `

An error occurred while getting Azure Container Service cluster credentials.

+

Details

+

${configResult.credentialsError}

`; + return ` +

${title}

+ ${styles()} + ${getCliOutput} + ${getCredsOutput}`; +} + +// Error rendering helpers + +function diagnoseCreationError(e: Errorable) : string { + if (e.succeeded) { + return ''; + } + if (e.error[0].indexOf('unrecognized arguments') >= 0) { + return '

You may be using an older version of the Azure CLI. Check Azure CLI version is 2.0.23 or above.

'; + } + return ''; +} + +function renderCliError(stageId: string, last: ActionResult) : string { + return ` +

Error ${last.actionDescription}

+

The Azure command line failed. See below for the error message. You may need to:

+ +

Details

+

${last.result.error}

`; +} + +function renderNoOptions(stageId: string, title: string, message: string) : string { + return ` +

${title}

+${styles()} +

${message}

+`; +} + +function renderInternalError(error: string) : string { + return ` +

Internal extension error

+${styles()} +

An internal error occurred in the vscode-kubernetes-tools extension.

+

This is not an Azure or Kubernetes issue. Please report error text '${error}' to the extension authors.

+`; +} + +function renderExternalError(last: ActionResult) : string { + return ` +

Error ${last.actionDescription}

+ ${styles()} +

An error occurred while ${last.actionDescription}.

+

Details

+

${last.result.error}

`; +} + +// Utility helpers + +function formatCluster(cluster: any) : string { + return cluster.resourceGroup + '/' + cluster.name; +} + +function parseCluster(encoded: string) : azure.ClusterInfo { + const delimiterPos = encoded.indexOf('/'); + return { + resourceGroup: encoded.substr(0, delimiterPos), + name: encoded.substr(delimiterPos + 1) + }; +} diff --git a/src/components/clusterprovider/clusterproviderregistry.ts b/src/components/clusterprovider/clusterproviderregistry.ts new file mode 100644 index 000000000..61aebe0b9 --- /dev/null +++ b/src/components/clusterprovider/clusterproviderregistry.ts @@ -0,0 +1,34 @@ +export type ClusterProviderAction = 'create' | 'configure'; + +export interface ClusterProvider { + readonly id: string; + readonly displayName: string; + readonly port: number; + readonly supportedActions: ClusterProviderAction[]; +} + +export interface ClusterProviderRegistry { + register(clusterProvider: ClusterProvider): void; + list(): Array; +} + +class RegistryImpl implements ClusterProviderRegistry { + private readonly providers = new Array(); + + public register(clusterProvider: ClusterProvider) { + console.log(`You registered ${clusterProvider.id} for port ${clusterProvider.port}`); + this.providers.push(clusterProvider); + } + + public list(): Array { + let copy = new Array(); + copy = copy.concat(this.providers); + return copy; + } +} + +const registryImpl = new RegistryImpl(); + +export function get(): ClusterProviderRegistry { + return registryImpl; +} diff --git a/src/components/clusterprovider/clusterproviderserver.ts b/src/components/clusterprovider/clusterproviderserver.ts new file mode 100644 index 000000000..b03a9690a --- /dev/null +++ b/src/components/clusterprovider/clusterproviderserver.ts @@ -0,0 +1,84 @@ +import * as restify from 'restify'; +import * as portfinder from 'portfinder'; +import * as clusterproviderregistry from './clusterproviderregistry'; +import { styles, script, waitScript } from '../../wizard'; + +let cpServer : restify.Server; +let cpPort : number; + +export async function init() : Promise { + if (!cpServer) { + cpServer = restify.createServer({ + formatters: { + 'text/html': (req, resp, body) => body + } + }); + + cpPort = await portfinder.getPortPromise({ port: 44000 }); + + cpServer.use(restify.plugins.queryParser()); + cpServer.listen(cpPort, '127.0.0.1'); + cpServer.get('/', handleGetProviderList); + } +} + +export function url(action: clusterproviderregistry.ClusterProviderAction) : string { + return `http://localhost:${cpPort}/?action=${action}`; +} + +function handleGetProviderList(request: restify.Request, response: restify.Response, next: restify.Next) : void { + const action = request.query["action"]; + + const html = handleGetProviderListHtml(action); + + response.contentType = 'text/html'; + response.send(html); + next(); +} + +function handleGetProviderListHtml(action: clusterproviderregistry.ClusterProviderAction) : string { + const clusterTypes = clusterproviderregistry.get().list().filter((cp) => cp.supportedActions.indexOf(action) >= 0); + + if (clusterTypes.length === 0) { + return `

No suitable providers

+ + ${styles()} +
+

There aren't any providers loaded that support this command. + You could try looking for Kubernetes providers in the Visual Studio + Code Marketplace.

+
`; + } + + const initialUri = `http://localhost:${clusterTypes[0].port}/${action}?clusterType=${clusterTypes[0].id}`; + const options = clusterTypes.map((cp) => ``).join('\n'); + + const selectionChangedScript = script(` + function selectionChanged() { + var selectCtrl = document.getElementById('selector'); + var selection = selectCtrl.options[selectCtrl.selectedIndex].value; + document.getElementById('nextlink').href = selection; + } + `); + + const html = `

Choose cluster type

+ + ${styles()} + ${selectionChangedScript} + ${waitScript('Loading provider')} +
+

+ Cluster type: +

+ +

+ Next > +

+
`; + + return html; +} diff --git a/src/components/clusterprovider/clusterproviderutils.ts b/src/components/clusterprovider/clusterproviderutils.ts new file mode 100644 index 000000000..484ab36cc --- /dev/null +++ b/src/components/clusterprovider/clusterproviderutils.ts @@ -0,0 +1,50 @@ +'use strict'; + +import * as clusterproviderregistry from './clusterproviderregistry'; +import * as clusterproviderserver from './clusterproviderserver'; + +export async function renderWizardContainer(action: clusterproviderregistry.ClusterProviderAction) : Promise { + await clusterproviderserver.init(); + + return ` + + + + + + +

Theme canary - if you see this, it's a bug

+