From 9c121305882f8db391f6a8e656f5e2c3723afb3b Mon Sep 17 00:00:00 2001 From: Ryan Clarke Date: Fri, 10 Jun 2016 13:41:32 -0400 Subject: [PATCH] [release] Initial commit Release version 1.0 See the README.md and linked documentation pages for deatils. --- .gitignore | 8 + .npmignore | 3 + LICENSE.md | 29 + Makefile | 88 ++ README.md | 336 +++++++ app.js | 27 + index.js | 32 + jsdoc.conf.json | 37 + lib/auth.js | 108 +++ lib/client.js | 434 +++++++++ lib/endpoints.js | 997 ++++++++++++++++++++ lib/errors.js | 173 ++++ lib/spec.js | 631 +++++++++++++ package.json | 49 + test/extended/nodes.js | 92 ++ test/extended/replicationControllers.js | 64 ++ test/json/endpoints-patch.json | 12 + test/json/endpoints.json | 17 + test/json/replicationControllers-patch.json | 5 + test/json/replicationControllers.json | 28 + test/json/services-patch.json | 11 + test/json/services.json | 19 + test/kubernetes/componentStatuses.js | 3 + test/kubernetes/endpoints.js | 3 + test/kubernetes/events.js | 3 + test/kubernetes/limitRanges.js | 3 + test/kubernetes/namespaces.js | 3 + test/kubernetes/nodes.js | 3 + test/kubernetes/persistentVolumeClaims.js | 3 + test/kubernetes/persistentVolumes.js | 3 + test/kubernetes/podTemplates.js | 3 + test/kubernetes/pods.js | 3 + test/kubernetes/replicationControllers.js | 3 + test/kubernetes/resourceQuotas.js | 3 + test/kubernetes/secrets.js | 3 + test/kubernetes/serviceAccounts.js | 3 + test/kubernetes/services.js | 3 + test/oshift.js | 7 + test/test.js | 156 +++ test/util.js | 46 + 40 files changed, 3454 insertions(+) create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 LICENSE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 app.js create mode 100644 index.js create mode 100644 jsdoc.conf.json create mode 100644 lib/auth.js create mode 100644 lib/client.js create mode 100644 lib/endpoints.js create mode 100644 lib/errors.js create mode 100644 lib/spec.js create mode 100644 package.json create mode 100644 test/extended/nodes.js create mode 100644 test/extended/replicationControllers.js create mode 100644 test/json/endpoints-patch.json create mode 100644 test/json/endpoints.json create mode 100644 test/json/replicationControllers-patch.json create mode 100644 test/json/replicationControllers.json create mode 100644 test/json/services-patch.json create mode 100644 test/json/services.json create mode 100644 test/kubernetes/componentStatuses.js create mode 100644 test/kubernetes/endpoints.js create mode 100644 test/kubernetes/events.js create mode 100644 test/kubernetes/limitRanges.js create mode 100644 test/kubernetes/namespaces.js create mode 100644 test/kubernetes/nodes.js create mode 100644 test/kubernetes/persistentVolumeClaims.js create mode 100644 test/kubernetes/persistentVolumes.js create mode 100644 test/kubernetes/podTemplates.js create mode 100644 test/kubernetes/pods.js create mode 100644 test/kubernetes/replicationControllers.js create mode 100644 test/kubernetes/resourceQuotas.js create mode 100644 test/kubernetes/secrets.js create mode 100644 test/kubernetes/serviceAccounts.js create mode 100644 test/kubernetes/services.js create mode 100644 test/oshift.js create mode 100644 test/test.js create mode 100644 test/util.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..174aca9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.swp +._* +.idea +.DS_Store +*.min.js +docs/ +node_modules/ +config.json diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..bdbeb6d --- /dev/null +++ b/.npmignore @@ -0,0 +1,3 @@ +* +!index.js +!lib/*.min.js diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..5dae623 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,29 @@ +Copyright (c) 2016, Cisco Systems, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, +with or without modification, are permitted provided +that the following conditions are met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8aa9175 --- /dev/null +++ b/Makefile @@ -0,0 +1,88 @@ +# Project target files +# +TARGETS = \ + lib/client.min.js \ + lib/endpoints.min.js \ + lib/auth.min.js \ + lib/errors.min.js \ + lib/spec.min.js + +# Project source files +# +SOURCE = \ + lib/client.js \ + lib/endpoints.js \ + lib/auth.js \ + lib/errors.js \ + lib/spec.js + +.PHONY: all clean fake publish test test-util test-extended test-kubernetes test-oshift + +all : $(TARGETS) + +# Publish the project to NPM +# +publish : all + npm publish + +# Create a tar file of the project +# +tar : + npm pack + +# Run project unit tests +# +test : test-util test-kubernetes test-extended + +# Test utility methods +# +test-util : all + node_modules/.bin/mocha --timeout $(TIMEOUT) --recursive test/util + +# Test extended methods +# +test-extended : all + node_modules/.bin/mocha --timeout $(TIMEOUT) --recursive test/extended + +# Test core Kubernetes methods +# +test-kubernetes : all + node_modules/.bin/mocha --timeout $(TIMEOUT) --recursive test/kubernetes + +# Test core OpenShift methods +# +test-oshift : all + node_modules/.bin/mocha --timeout $(TIMEOUT) --recursive test/oshift + +# Generate project documentation using JSDoc and the jsdoc-oblivion theme +# +docs : node_modules/.bin/jsdoc $(SOURCE) jsdoc.conf.json + node_modules/.bin/jsdoc -c jsdoc.conf.json --verbose + +# Pretend to minimize by creating symbolic links +# +fake : + ln -sf client.js lib/client.min.js + ln -sf endpoints.js lib/endpoints.min.js + ln -sf auth.js lib/auth.min.js + +# Remove target files and documentation +# +clean : + rm -rf $(TARGETS) docs/*.html docs/scripts docs/styles + +# Minimize the target javascript file using uglifyjs +# +%.min.js : %.js node_modules/.bin/uglifyjs + node_modules/.bin/uglifyjs $< -o $@ -cmv + +# Install a missing npm package +# +node_modules/.bin/uglifyjs : + npm install uglify-js + +node_modules/.bin/jsdoc : + npm install jsdoc + +node_modules/.bin/mocha : + npm install mocha should diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4493b6 --- /dev/null +++ b/README.md @@ -0,0 +1,336 @@ +![banner] + +# Cisco Kubernetes Client for Node.js +[![github-icon]][github] [![npm-icon]][npm] + +[npm]: https://nodei.co/npm/cisco-kube-client/ +[npm-icon]: https://nodei.co/npm/cisco-kube-client.png + +[github]: https://github.com/ryclarke/cisco-kube-client +[github-icon]: https://raw.githubusercontent.com/ryclarke/cisco-kube-client/gh-pages/img/github-mark.png + +[banner]: https://raw.githubusercontent.com/ryclarke/cisco-kube-client/gh-pages/img/cisco-k8s.png +[docs-page]: http://ryclarke.github.io/cisco-kube-client + +A [Node.js] client library for [Kubernetes] and [OpenShift]. This client +allows a Node.js application to easily interface with a Kubernetes or +OpenShift master using the respective [APIs][api-common]. + +Full technical documentation is provided [here][docs-page]. + +[Node.js]: https://nodejs.org +[Kubernetes]: http://kubernetes.io +[OpenShift]: https://www.openshift.org + +[api-common]: https://docs.openshift.org/latest/rest_api/index.html +[api-k8s]: https://docs.openshift.org/latest/rest_api/kubernetes_v1.html +[api-os]: https://docs.openshift.org/latest/rest_api/openshift_v1.html + +## Getting Started + +#### Installation +The latest stable version of the client is available from [npm]: + + npm install cisco-kube-client + +#### Configuration +The client constructor must be given an object with properties defining +the desired configuration. Information about both required and optional +parameters can be found below. + +**Required parameters** + +```js +{ + //String: Hostname of the API server + host: 'https://localhost:8443' + + //String: Version of the API server Default: 'v1' + , version: 'v1' + + //Object: Credentials sent to the oAuth provider + , auth: { + user: '' //String: Username + , pass: '' //String: Password + } +} +``` + +**Optional parameters** + +```js +{ + //String: Default client namespace Default: null + , namespace: 'default' + + //Number: Level of log output Default: 'fatal' + , loglevel: 'fatal' + + //Boolean: Enable Extensions endpoints Default: false + , beta: false + + //Boolean: Enable OpenShift endpoints Default: false + , oshift: false + + //Boolean: Return a client Promise Default: false + , usePromise: false + + //Number: HTTP request timeout in ms Default: null + , timeout: 10000 + + //String: Access token for oAuth Default: null + , token: null + + //String: Host protocol Default: 'https' + , protocol: 'https' + + //Number: Host port Default: 8443 + , port: 8443 + + // Note that 'protocol' and 'port' properties are only checked if + // the corresponding component is missing from the 'host' property. +} +``` + +#### Usage Pattern +The Cisco Kubernetes Client utilizes promises from the [bluebird] +Node.js module for asynchronous processing. Promises are the recommended +usage pattern for the Cisco Kubernetes Client. See [here][promises] for +more information on proper promise usage. + +The client constructor will return a Promise of the client, which will +resolve when initialization has completed. + +Almost all endpoint methods will return promises of the response data. +The only exception to this is the `watch` method, which returns an +EventEmitter object with `data` events instead. + +```js +var Client = require('cisco-kube-client'); + +//Minimum viable configuration example +var options = { + host: 'localhost' + , auth: { + user: 'johndoe' + , pass: 'password123' + } +}; + +Client(options).then(function (client) { + // Use the client here + client...then(function (result) { + // Process the result here + }); +}); +``` + +[bluebird]: https://bluebirdjs.com +[promises]: https://www.promisejs.org/ + +#### Callback support +The client also has full compatibility with Node.js style callbacks if +you prefer not to use promises in your application. + +If the client constructor or any endpoint methods receive a function as +the last parameter, instead of returning a promise that function will +be used as a callback with the promise's value as the second parameter. + +```js +var Client = require('cisco-kube-client'); + +var options = { + host: 'localhost' + , version: 'v1' +}; + +Client(options, function (error, client) { + // Use the client here +}); +``` + +## Client features +**Structure** + +All API resource endpoints are accessed with the following template: + +```js +client.. +``` + +The accepted parameters and return values for each method are consistent +across all API resource endpoints. + +Valid values for `` and `` are detailed below. + +#### Available Kubernetes resources +See the official [API specification][api-k8s] for details. +* `endpoints` +* `events` +* `limitRanges` +* `namespaces` +* `nodes` +* `persistentVolumeClaims` +* `persistentVolumes` +* `podTemplates` +* `pods` +* `replicationControllers` +* `resourceQuotas` +* `secrets` +* `serviceAccounts` +* `services` + +### Available Extensions resources +See the official [API specification][api-k8s] for details. +Only available if the client has been configured with `beta: true` + +### Available OpenShift resources +See the official [API specification][api-os] for details. +Only available if the client has been configured with `oshift: true` + +#### Available methods +**Basic methods** + +These expose fundamental API server functionality: + +```js + get ([query], [opts], [callback]) + watch ([query], [opts], [callback]) +create (body, [opts], [callback]) +update (query, body, [opts], [callback]) + patch (query, body, [opts], [callback]) +delete (query, [opts], [callback]) +``` + +The optional `opts` argument is an object containing properties to be +applied to the internal http request. This allows for per-call overrides +of library default functionality. The most common use for this is to +specify request headers and/or query strings. The `namespace` property +is also recognized to override the default namespace filter settings for +the client. Additionally, the `labels` and `fields` properties may +contain labelSelector and fieldSelector options, respectively. + +**Watch method** + +The `watch` method is unique in that it returns a Promise of a custom +EventEmitter object rather than an API response body. The initial state +of the watched resource is fetched and is available as the +`initialState` property of the emitter object. The watch connection will +not be initialized until the emitter's `start` method is called. This +allows for the user to set up all event listeners without missing any +watch events. The events that will be emitted are `response`, `create`, +`update`, `delete`, and `error`. + +```js +// Watch for updates to all resources of type +client..watch().then(function (em) { + console.log(em.initialState); // Current state of the resource + + // Set up event listeners + em.on('create', createHandler); + em.on('update', updateHandler); + em.on('delete', deleteHandler); + + em.start(); // Start the watch connection with the API server +}); +``` + +[request-streaming]: https://github.com/request/request#streaming + +**Nested endpoints** + +Some resources have nested resource endpoints available within them. +These are exposed by name under the parent resource. Inspect the client +object for access to the complete list of available methods. + +```js +console.log(client); // Print available resources and methods +``` + +Nested endpoints may be accessed as shown below. Note that *all* nested +endpoints operate on a single top level resource, so the query parameter +is always required. + +```js +//Base endpoint: client.. + +var podPromise = client.pods.get(''); + +//Nested endpoint: client... + +var podLogPromise = client.pods.logs.get(''); +``` + +**Compound methods** + +In addition to the base functionality offered by the API itself, this +client also implements some methods for batch operations. These methods +are exposed through the `nodes` resource and act on all pods running on +the given node. + +```js + getPods (query, [opts], callback) // Get all pods + patchPods (query, body, [opts], callback) // Patch all pods +deletePods (query, [opts], callback) // Delete all pods + evacuate (query, [opts], callback) // Evacuate a node +``` + +The `evacuate` method flags the given node as unschedulable via an +internal call to `patch` and removes all pods residing on the given node +via an internal call to `deleteFrom`. + +#### Proxy resources + +These can be accessed using: `client['proxy/'].` + +Available resources are `nodes`, `pods`, and `services`. These proxy +resources have been implemented based on the Kubernetes API, but they +have not been tested. **USE AT YOUR OWN RISK!** + +## Examples +#### Getting from pods +To get all pods: + +```js +client.pods.get().then(function (pods) { + console.log('pods:', pods); +}); +``` + +#### Defining a custom API group + +Custom API groups can be defined according to the Kubernetes +specification. These APIS are reached at apis/{name} by default, but +a custom `prefix` parameter can be defined to change this. Nested +endpoints and per-endpoint request options can also be specified. + +All endpoints defined in the API specification's `spec` property will +be added to the client's list of available endpoints. + +```js +client.defineAPI({ + name: 'apiGroup' + , spec: { + endpoints: { + kind: 'Endpoint' + } + } +}); +// Adds a property `client.endpoints` -> {host}/apis/apiGroup/endpoints +``` + +#### Defining a custom resource endpoint + +Individual endpoints can also be added on demand. Use the following +method to define a single custom endpoint: + +```js +client.createEndpoint('test', spec); +// then use the test resource like any other top level endpoint +``` + +## Version compatibility +This client is built to interface with version `v1` of the official +Kubernetes and OpenShift APIs. While backward compatibility with older +versions has been attempted, it cannot be guaranteed. No further support +will be offered for API versions that have been deprecated. diff --git a/app.js b/app.js new file mode 100644 index 0000000..6d6ed26 --- /dev/null +++ b/app.js @@ -0,0 +1,27 @@ +'use strict'; +require('sugar'); + +// Import the client library +var Client = require('cisco-kube-client'); + +// Permit usage of self-signed SSL certificates +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + +// Initialize the client with options specified in the file `config.json` +// Options can also be defined as properties of a local object +var options = require('./config'); +var client = new Client(options); + +// Uncomment to print list of available endpoints +//console.log(client); + +////////////////////////////////////////////// +// // +// Try out the initialized client below // +// // +////////////////////////////////////////////// + +client.pods.get().then(function (rc) { + console.log(rc); +}); + diff --git a/index.js b/index.js new file mode 100644 index 0000000..d70be86 --- /dev/null +++ b/index.js @@ -0,0 +1,32 @@ +module.exports = require('./lib/client.min'); +/** + * @module * index + * @description The main entry point for [cisco-kube-client]{@link https://npmjs.org/packages/cisco-kube-client} + * + * The [client]{@link module:client} module is exported to the end-user. + * + * @see {module:client} + * @example + * // Import the client module + * var Client = require('cisco-kube-client') + * + * // Define client configuration options + * var options = { + * host: 'http://localhost:8080' + * , version: 'v1' + * , auth: { + * user: 'JohnDoe' + * , pass: 'password123' + * } + * }; + * + * // Initialize client + * var client = Client(options); + * + * // Use client to list Kubernetes namespaces + * client.namespaces.get().then(function (ns) { + * console.log(ns); + * }).catch(function (err) { + * console.log(err); + * }); + */ diff --git a/jsdoc.conf.json b/jsdoc.conf.json new file mode 100644 index 0000000..a6f84ac --- /dev/null +++ b/jsdoc.conf.json @@ -0,0 +1,37 @@ +{ + "opts": { + "template": "node_modules/jsdoc-oblivion/template", + "readme": "README.md", + "destination": "docs/" + }, + "source": { + "include": [ + "index.js", + "lib/" + ], + "excludePattern": "\\.min\\.js$" + }, + "tags": { + "allowUnknownTags": true + }, + "plugins": ["plugins/markdown"], + "templates": { + "cleverLinks": false, + "monospaceLinks": true, + "default": { + "outputSourceFiles": true + }, + "systemName" : "Cisco Kubernetes Client", + "footer" : "", + "copyright" : "Copyright © 2016 Cisco Systems, Inc.", + "navType" : "vertical", + "theme" : "oblivion", + "linenums" : true, + "collapseSymbols" : false, + "inverseNav" : true + }, + "markdown": { + "parser": "gfm", + "hardwrap": false + } +} diff --git a/lib/auth.js b/lib/auth.js new file mode 100644 index 0000000..7e8ff89 --- /dev/null +++ b/lib/auth.js @@ -0,0 +1,108 @@ +'use strict'; +require('sugar'); +var Promise = require('bluebird') + , request = require('request') + , url = require('url') + , errors = require('./errors.min'); + +/** + * @name call + * @public + * @function + * @memberof module:auth + * @description Authenticate and save the new oAuth token + * + * Authenticate with the Kubernetes deployment's oAuth server. The `token` property of the given + * [ClientConfig]{@link module:client~ClientConfig} is updated to match the new token. + * + * @param {module:client~ClientConfig} config - Client configuration to update + * @param {boolean} [flush=false] - Delete the previous token if it already exists + * @param {function} [next] - Node.js callback (replaces Promise output) + * + * @throws {module:errors.TokenParseError} + * + * @returns {?Promise.} `ClientConfig` with updated `token` property + */ +/** + * @module auth + * @description Authentication Management Module + */ +module.exports = function Authenticate(config, flush, next) { + if (flush && config.token !== null) { + config.token = null; + } + if (config.auth) { + return getNewToken(config, next); + } else { + return Promise.resolve(config).nodeify(next); + } +}; + +/** + * @private + * @description Request a new token if one is not already defined + * + * @param {module:client~ClientConfig} config - Client configuration to update + * @param {string} config.token - Replace this value with the new oAuth token + * @param {function} [next] - Node.js callback (replaces Promise output) + * + * @throws {module:errors.TokenParseError} + * + * @returns {?Promise.} `ClientConfig` with updated `token` property + */ +function getNewToken(config, next) { + return new Promise(function (resolve, reject) { + if (config.token) { + return resolve(config); + } + var options = Object.merge({ + url: config.host.replace(/^[a-z]+:\/\//, 'https://') + '/oauth/authorize' + , followRedirect: false + , auth: config.auth + , headers: { 'X-CSRF-Token': Math.random().toString() } + , qs: { + response_type: 'token' + , client_id: 'openshift-challenging-client' + } + }, config.authOptions || {}, true); + request(options, function (error, response) { + error = errors(error, response); + if (error) { + reject(error); + } else { + config.token = parseToken(response); + resolve(config); + } + }); + }).nodeify(next); +} + +/** + * @private + * @description Extract an oAuth token from the server response + * + * @param {object} response - Response from the oAuth server + * + * @throws {module:errors.TokenParseError} + * + * @returns {string} New oAuth token + */ +function parseToken(response) { + if (!response.headers || !response.headers.location) { + // Response header is not found + throw new errors.TokenParseError(null, new ReferenceError('\'location\' is not defined in \'headers\' object')); + } + try { + // Parse the header to extract the token + var token = url.parse(response.headers.location).hash + .split('#')[1].split('&')[0].split('=')[1]; + } catch (error) { + // Error thrown while parsing header + throw new errors.TokenParseError(response.headers.location, error); + } + if (typeof token !== 'string' || token.length < 10) { + // Unspecified parsing error without an exception + throw new errors.TokenParseError(response.headers.location, new TypeError(token)); + } + return token; +} diff --git a/lib/client.js b/lib/client.js new file mode 100644 index 0000000..8af87d7 --- /dev/null +++ b/lib/client.js @@ -0,0 +1,434 @@ +'use strict'; +require('sugar'); +var Promise = require('bluebird') + , bunyan = require('bunyan') + , endpoints = require('./endpoints.min') + , errors = require('./errors.min') + , auth = require('./auth.min') + , spec = require('./spec.min'); + +/** + * @name call + * @public + * @function + * @memberof module:client + * @description Initialize a new Kubernetes Client with the given configuration + * + * @param {object|module:client~ClientConfig} config - Client configuration options + * @param {number|string} [config.logLevel=bunyan.FATAL] - Level of log data to display + * @param {boolean} [config.beta=false] - Enable the core API beta extensions + * @param {boolean} [config.oshift=false] - Enable the OpenShift API endpoints + * @param {array} [config.extensions] - List of additional API extension specifications to include + * @param {boolean} [config.usePromise=false] - Wrap the initialized client in a [Promise]{http://bluebirdjs.com} + * + * @returns {module:client.KubernetesClient|bluebird|Promise.} + * + * @throws {module:errors.ParameterError|module:errors.VersionError} + */ +/** + * @module client + * @description Kubernetes API Client Module + */ +module.exports = function CreateClient(config) { + if (config.usePromise) { + return new Promise(function (resolve) { + resolve(new KubernetesClient(config)); + }); + } else { + return new KubernetesClient(config); + } +}; +module.exports.spec = spec; + +/** + * @class + * @static + * @memberof module:client + * + * @summary Kubernetes API Client + * @classdesc Initialized KubernetesClient objects are used to interface with the Kubernetes API server specified in + * the [ClientConfig]{@link module:client~ClientConfig}. The client's [Endpoint]{@link module:endpoints~Endpoint} + * properties are generated at runtime from the [Kubernetes resource specification]{@link KubernetesSpecification}. + * + * @description Create a new `KubernetesClient` object. If the given object is not of type `ClientConfig` then it is + * used as input to the `ClientConfig` constructor to define a new one. Parses the Kubernetes API spec to generate + * `Endpoint` properties. + * + * @param {object|module:client~ClientConfig} options - Sets + * `KubernetesClient#[config]{@link module:client.KubernetesClient#config}` + * + * @param {number|string} [options.logLevel=bunyan.FATAL] - Maximum log output level + * @param {string|boolean} [options.oshift] - Define OpenShift API resource endpoints + * @param {string|boolean} [options.beta] - Define Kubernetes beta API resource endpoints + * + * @param {APISpecification[]} [options.apis] - List of API specifications to define + * + * @throws {module:errors.ParameterError|module:errors.VersionError} + */ +function KubernetesClient(options) { + var self = this; + + /** + * @private + * @name log + * @memberof module:client.KubernetesClient# + * @description Bunyan logger for the Client + * @type {bunyan} + */ + Object.defineProperty(this, 'log', { + value: bunyan.createLogger({ name: 'cisco-kube-client', level: options.logLevel || bunyan.FATAL }) + }); + /** + * @name config + * @memberof module:client.KubernetesClient# + * @description Client object configuration + * @type {module:client~ClientConfig} + */ + try { + Object.defineProperty(this, 'config', { + value: (options instanceof ClientConfig) ? options : new ClientConfig(options) + }); + this.log.debug('client initialization options parsed'); + } catch (error) { + this.log.fatal(error); // Log client configuration errors + throw error; + } + // Define all Kubernetes API resources for the core version + try { + this.defineAPI(spec.Kubernetes[this.config.version]); + } catch (error) { + this.log.fatal(error); // Log client configuration error + throw error; + } + + // Define beta API resources for the given version + // Matches the latest beta version that matches the base version by default (if options.beta === true) + if (options.beta) { + if (typeof options.beta === 'boolean') { + options.beta = Object.keys(spec.KubernetesBeta) + .sort().reverse().find(new RegExp('^' + this.config.version + 'beta')); + } + try { + this.defineAPI(spec.KubernetesBeta[parseVersion(options.beta, spec.KubernetesBeta)]); + } catch (error) { + this.log.fatal(error); // Log client configuration error + throw error; + } + } + + // Define all OpenShift API resources for the given version + // Matches the Kubernetes API version by default (if options.oshift === true) + if (options.oshift) { + if (typeof options.oshift === 'boolean') { + options.oshift = this.config.version; + } + try { + this.defineAPI(spec.OpenShift[parseVersion(options.oshift, spec.OpenShift)]); + } catch (error) { + this.log.fatal(error); // Log client configuration error + throw error; + } + } + + // Define endpoints for all API extensions + if (options.apis) { + //this.log.info('Defining endpoints for Kubernetes API extensions'); + options.apis.each(function (api) { + self.defineAPI(api); + }); + } + + this.log.info({host: this.config.host, namespace: this.config.namespace}, 'client initialized'); +} +/** @private */ +KubernetesClient.prototype.toString = function () { + return '[KubernetesClient \'' + this.config.host + '\']'; +}; + +/** + * @public + * @description Authenticate with the oAuth server + * + * The new token is added to the [ClientConfig]{@link module:client~ClientConfig}. + * Returns a `Promise` of the updated `ClientConfig` with the new token parameter. + * + * @see {@link module:auth} + * + * @param {boolean} flush - Delete the client's token if it already exists + * @param {function} [next] - Node.js callback (replaces Promise output) + * + * @returns {?Promise.} + */ +KubernetesClient.prototype.authenticate = function(flush, next) { + return auth(this.config, flush, next); +}; + +/** + * @public + * @description Define a new API server resource endpoint. The returned object will be an instantiated + * [Endpoint]{@link module:endpoints~Endpoint} or a relevant subtype. + * + * @see {@link module:endpoints} + * + * @param {string} resource - Resource name + * @param {?EndpointSpecification} [spec] - Endpoint specification for the resource + * @param {?string} [spec.name] - Custom name for the endpoint property + */ +KubernetesClient.prototype.createEndpoint = function(resource, spec) { + if (!spec) spec = {}; + + // Define main resource Endpoint + Object.defineProperty(this, resource, { + enumerable: true + , configurable: true + , value: endpoints(this, resource, spec.nested, spec.options) + }); + + // Link nickname to main resource + if (spec.nickname) { + Object.defineProperty(this, spec.nickname, { + configurable: true + , value: this[resource] + }); + } +}; + +/** + * @public + * @description Create all resource endpoints defined in the given API specification + * + * @param {APISpecification} api - API endpoints to define + */ +KubernetesClient.prototype.defineAPI = function (api) { + var self = this; + + // Define all of the resource endpoints for the API + Object.keys(api.spec, function (endpoint, spec) { + self.createEndpoint(endpoint, Object.merge({ + options: { version: api.name, prefix: api.prefix } + }, spec, true)); + }); + this.log.debug({prefix: api.prefix}, 'api endpoints created: ' + api.name); +}; + +/** + * @class + * @inner + * @memberof module:client + * + * @summary Kubernetes API Client Configuration + * @classdesc Describes the configuration of a [KubernetesClient]{@link module:client.KubernetesClient} object. All + * options relevant to the client initialization are provided as instance properties. The token property is managed + * by the Auth module if user credentials are given. + * + * @description Create a new `ClientConfig` object. The given object will supply the necessary fields to initialize + * the object. If any required parameters are missing an error will be thrown. + * + * @param {object|module:client~ClientConfig} options - Input parameters + * @param {?object} [options.auth=null] - Sets + * `ClientConfig#[auth]{@link module:client~ClientConfig#auth}` + * @param {string} options.auth.user - Client username + * @param {string} options.auth.pass - Client password + * @param {?object} [options.authOptions=null] - Sets + * `ClientConfig#[authOptions]{@link module:client~ClientConfig#authOptions}` + * @param {!string} options.host - Sets + * `ClientConfig#[host]{@link module:client~ClientConfig#host}` + * @param {string} [options.hostname] - Alias for the `host` parameter + * @param {?string} [options.namespace=null] - Sets + * `ClientConfig#[namespace]{@link module:client~ClientConfig#namespace}` + * @param {string|number} [options.port] - Sets port component of `ClientConfig#host` + * @param {string} [options.protocol] - Sets protocol component of `ClientConfig#host` + * @param {?object} [options.requestOptions=null] - Sets + * `ClientConfig#[requestOptions]{@link module:client~ClientConfig#requestOptions}` + * @param {?int} [options.timeout=null] - Sets + * `ClientConfig#[timeout]{@link module:client~ClientConfig#timeout}` + * @param {?string} [options.token=null] - Sets + * `ClientConfig#[token]{@link module:client~ClientConfig#token}` + * @param {!string|number} options.version - Sets + * `ClientConfig#[version]{@link module:client~ClientConfig#version}` + * + * @throws {module:errors.ParameterError|module:errors.VersionError} + */ +function ClientConfig(options) { + /** + * @name auth + * @memberof module:client~ClientConfig# + * @description User credentials for authentication with the API server + * + * Specify a username and password in the object's 'user' and 'pass' properties, respectively. If authentication is + * handled externally and a valid token has been supplied, or if authentication is not required for the given API + * server host, then this parameter may be excluded. + * + * @type {?object} + * @readonly + * @default null + * @property {string} user - Client username + * @property {string} pass - Client password + */ + Object.defineProperty(this, 'auth', { value: options.auth || null }); + /** + * @name authOptions + * @memberof module:client~ClientConfig# + * @description Request options for authentication + * + * These are passed directly to the request module and override the default configuration. Consult the documentation + * for the `request` module (linked below) for information on valid properties for this object. + * + * @see https://github.com/request/request + * + * @type {?object} + * @readonly + * @default null + */ + Object.defineProperty(this, 'authOptions', { enumerable: true, value: options.authOptions || null }); + /** + * @name host + * @memberof module:client~ClientConfig# + * @description Kubernetes API server host + * + * Defines the full URL endpoint for the API server, including protocol and port. If port or protocol are excluded + * from the host specification, then the ClientConfig will look for them as `port` or `protocol` input parameters, + * respectively. If they are not defined in either format then the default values will be used to complete the URL. + * + * Default if only hostname is provided: `https://{hostname}:8443` + * + * @type {!string} + * @readonly + * @example 'http://localhost:8080' + */ + Object.defineProperty(this, 'host', { enumerable: true + , value: parseHostname(options.host || options.hostname, options.port, options.protocol) + }); + /** + * @name namespace + * @memberof module:client~ClientConfig# + * @description Default Kubernetes project namespace + * + * This is used to restrict the scope of all methods to the given project. If defined, all of the client's methods + * will use this namespace as their project scope. Individual calls to an API endpoint may override this default by + * defining the `namespace` property of their request options. An empty namespace is equivalent to the global scope. + * + * @type {?string} + * @readonly + * @default null + */ + Object.defineProperty(this, 'namespace', { enumerable: true, value: options.namespace || null }); + /** + * @name timeout + * @memberof module:client~ClientConfig# + * @description Request timeout in milliseconds + * + * @type {?number} + * @readonly + * @default null + */ + Object.defineProperty(this, 'timeout', { enumerable: true, value: options.timeout || null }); + /** + * @name token + * @memberof module:client~ClientConfig# + * @description oAuth token for authentication + * + * If this is omitted and the `auth` property is set, then the credentials found in `auth` will be used to request + * a new oAuth token from the API server automatically. Invalid or expired tokens will also be silently refreshed + * without an error being thrown for the first authentication failure. + * + * If the `auth` property is not defined then this token must be set manually for authentication of API requests. + * + * @type {?string} + * @default null + */ + Object.defineProperty(this, 'token', { writable: true, value: options.token || null }); + /** + * @name requestOptions + * @memberof module:client~ClientConfig# + * @description Request options for API endpoints + * + * These are passed directly to the request module and override the default configuration. Consult the documentation + * for the `request` module (linked below) for information on valid properties for this object. + * + * @see https://github.com/request/request + * + * @type {?object} + * @readonly + * @default null + */ + Object.defineProperty(this, 'requestOptions', { enumerable: true, value: options.requestOptions || null }); + /** + * @name version + * @memberof module:client~ClientConfig# + * @description Kubernetes API version + * + * If a number is provided, it is converted to a string and prepended with 'v' for compatibility with the + * Kubernetes API schema for API versions. + * + * @see http://kubernetes.io/docs/api + * + * @type {!string} + * @readonly + * @example 'v1' + */ + Object.defineProperty(this, 'version', { enumerable: true + , value: parseVersion((typeof options.version === 'number') + ? 'v' + options.version : options.version, spec.Kubernetes) + }); +} +/** @private */ +ClientConfig.prototype.toString = function () { + return '[ClientConfig \'' + this.host + '\']'; +}; + +/** + * @private + * @description Parse the given hostname + * + * If necessary, add the given port and protocol to the hostname to produce a fully resolved URL. + * If either port or protocol is already part of the host string then the separate parameter for + * that URL component will be ignored. Returns the combined hostname. + * + * @param {string} host - API server hostname or URL + * @param {?string|number} [port=8433] - Separately defined URL port + * @param {?string} [protocol='https'] - Separately defined URl protocol + * + * @returns {string} + * + * @throws {module:errors.ParameterError} + */ +function parseHostname(host, port, protocol) { + if (!host) throw new errors.ParameterError('host'); + // Strip trailing slash from host definition + host = host.replace(/\/$/, ''); + + // Host definition is missing protocol component. Check the protocol parameter, or use the default. + if (!host.has(/^[a-z]+:\/\//)) { + host = (protocol || 'https') + '://' + host; + } + // Host definition is missing port component. Check the port parameter, or use the default. + if (!host.has(/:[0-9]+$/)) { + host += ':' + (port || 8443); + } + return host; +} + +/** + * @private + * @description Validate the API version + * + * Returns the given version string if it is a valid version for the given API specification. Otherwise, an + * error will be thrown. + * + * @param {string} version - Proposed API version + * @param {object.} spec - API group to check for the version + * + * @returns {string} + * + * @throws {module:errors.ParameterError|module:errors.VersionError} + */ +function parseVersion(version, spec) { + if (!version) throw new errors.ParameterError('version'); + + if (Object.keys(spec).none(version)) { + throw new errors.VersionError(version, spec); + } else { + return version; + } +} diff --git a/lib/endpoints.js b/lib/endpoints.js new file mode 100644 index 0000000..24fbcd6 --- /dev/null +++ b/lib/endpoints.js @@ -0,0 +1,997 @@ +'use strict'; +require('sugar'); +var EventEmitter = require('events') + , Promise = require('bluebird') + , request = require('request') + , errors = require('./errors.min'); + +/** + * @private + * @name EventEmitter + * @property prototype + */ + +/** + * @name call + * @public + * @function + * @memberof module:endpoints + * @description Initialize a new `Endpoint` object with the given configuration + * + * @param {module:client.KubernetesClient} client - Reference to the parent + * [KubernetesClient]{@link module:client.KubernetesClient} object + * @param {string} resource - Resource endpoint name + * @param {NestedResourceDefinition[]} nested - List of nested endpoints + * @param {object} options - Endpoint-wide request options + * + * @returns {module:endpoints~Endpoint} + */ +/** + * @module endpoints + * @description API Resource Endpoints Module + */ +module.exports = function CreateEndpoint(client, resource, nested, options) { + var endpoint; + switch (resource) { + case 'nodes': + endpoint = NodesEndpoint; + break; + case 'replicationControllers': + endpoint = ReplicationControllersEndpoint; + break; + default: + endpoint = Endpoint; + break; + } + return new endpoint(client, resource, nested, options); +}; + +/** + * @class + * @inner + * @memberof module:endpoints + * + * @summary Kubernetes API Endpoint + * @classdesc Exposes all methods for an API resource endpoint. If there are any nested resources, + * they are exposed as methods of the parent resource. The HTTP verb used for the nested resource + * is prepended to the name. + * + * @description Define a new `Endpoint` object corresponding to a resource on the API + * server. Specify options to override the library defaults for this endpoint. + * + * @this {module:endpoints~Endpoint} + * + * @param {module:client.KubernetesClient} client - Sets `Endpoint#[client]{@link module:endpoints~Endpoint#client}` + * @param {string} resource - Sets `Endpoint#[resource]{@link module:endpoints~Endpoint#resource}` + * @param {?NestedResourceDefinition[]} [nested] - List of nested endpoints + * @param {?object} [options] - Sets `Endpoint#[options]{@link module:endpoints~Endpoint#options}` + * @param {string} options.version - API version name + * @param {string} [options.prefix] - Custom API prefix + * @param {string[]} [options.methods] - If defined, only the listed base methods will be available to the Endpoint + */ +function Endpoint(client, resource, nested, options) { + var self = this; + + if (!options) options = { version: client.config.version }; + /** + * @name client + * @memberof module:endpoints~Endpoint# + * @description Reference to the parent client object + * @type {module:client.KubernetesClient} + */ + Object.defineProperty(this, 'client', { value: client }); + /** + * @name options + * @memberof module:endpoints~Endpoint# + * @description Endpoint option overrides + * @type {?object} + */ + Object.defineProperty(this, 'options', { value: options }); + if (!this.options.prefix) { + this.options.prefix = (this.options.version.has('/')) ? 'apis' : 'api'; + } + /** + * @name resource + * @memberof module:endpoints~Endpoint# + * @description Server resource name + * @type {string} + */ + Object.defineProperty(this, 'resource', { value: resource }); + /** + * @private + * @name _log + * @memberof module:endpoints~Endpoint# + * @description Bunyan logger for the Endpoint + * @type {bunyan} + */ + Object.defineProperty(this, '_log', { value: this.client.log.child({ + endpoint: getPath(this.options.prefix, this.options.version, resource) + })}); + + // Add nested resource endpoints + /** + * client.pods.exec.get(podName, {qs: command: '["echo","Hello, Kubernetes!"]'}, next) + */ + (nested || []).each(function (each) { + Object.defineProperty(self, each.resource, { + enumerable: true + , value: new Endpoint(self.client, self.resource, null, Object.merge({ + child: each.resource + , methods: each.methods + }, self.options, true, false)) + }); + }); + + // Remove invalid methods from endpoints + if (this.options.methods) { + Object.keys(Object.getPrototypeOf(this), function (each) { + if (self.options.methods.none(each)) { + Object.defineProperty(self, each, { value: undefined }); + } + }); + delete this.options.methods; + } + + // Expose prototype methods as enumerable properties + Object.keys(Object.getPrototypeOf(this), function (key, value) { + if (self[key]) { + Object.defineProperty(self, key, { enumerable: true, value: value }); + } + }); + + //this.client.log.trace('endpoint created: ' + // + getPath(this.options.prefix, this.options.version, this.resource, this.options.child)); +} +Object.defineProperty(Endpoint.prototype, 'toString', {value: function () { + return '[Endpoint ' + this.resource + ']'; +}}); + +/** + * @private + * @description Base request method wrapper for API resource endpoints + * + * If opts is omitted from the parameter list, it will be checked as a possible candidate for the callback. + * + * @param {module:endpoints~Endpoint} self - Endpoint object making the request + * @param {string} method - Method verb for the request + * @param {?string} query - Server resource name + * @param {?object} body - Resource object to send + * @param {?object} opts - Method options + * @param {boolean} [opts.verbose=false] - Return full response instead of body only + * @param {?string} [opts.child] - Name of nested child resource + * @param {?function|*} next - Node.js callback (replaces Promise output) + * + * @returns {?Promise.} Promise of the response body from the API server + */ +function baseRequest(self, method, query, body, opts, next) { + if (typeof opts === 'function') { + next = opts; // Parameter 'opts' is optional and can be safely dropped + opts = null; // baseRequest(self, method, query, body, next) + } + return new Promise(function (resolve, reject) { + // Authenticate before proceeding with request + self.client.authenticate(false).then(function (config) { + // Safely merge options objects - precedence: request > endpoint > default + opts = Object.merge(Object.clone(self.options || {}), opts || {}, true); + + // Set request verbosity + var verbose = opts.verbose || false; + if (opts.hasOwnProperty('verbose')) delete opts.verbose; + + // Collapse and parse request options + opts = parseOptions(Object.merge({ + endpoint: getPath(self.resource, query, opts.child) + , method: method + , body: body + }, opts, true), config); + + // Make API request and resolve Promise to the response + request(opts, function (error, response, body) { + // Propagate errors not caught by the callback + /** @type {?Error|module:errors.HttpError} */ + error = errors(error, response); + if (error) { + // Refresh the token and retry once if the token is invalid + if (error.statusCode === 401) { + self.client.authenticate(true).then(function (config) { + /** + * @name opts + * @property {object} headers + */ + opts.headers.Authorization = 'Bearer ' + config.token; + request(opts, function (error, response, body) { + error = errors(error, response); + if (error) { + self._log.error(error); + self._log.error(Object.merge({Authentication: '********'}, opts, true, false)); + reject(error); + } else { + if (typeof body === 'string') { + body = JSON.parse(body); + response.body = body; + } + self._log.debug({ + statusCode: response.statusCode, kind: body.kind + }, 'server response received'); + self._log.trace({body: body}, 'response body'); + resolve((verbose) ? response : body); + } + }); + }).catch(function (error) { + self._log.fatal(error); + self._log.error(JSON.stringify(error)); + throw error; + }); + } else { + if (error.code === 'ENOTFOUND') { + self._log.fatal(error); + self._log.fatal(Object.merge({Authentication: '********'}, opts, true, false)); + } else { + self._log.error(error); + self._log.error(Object.merge({Authentication: '********'}, opts, true, false)); + } + reject(error); + } + } else { + // Convert body into an object + if (typeof body === 'string') { + body = JSON.parse(body); + response.body = body; + } + self._log.debug({ + statusCode: response.statusCode, kind: body.kind + }, 'server response received'); + self._log.trace({body: body}, 'response body'); + resolve((verbose) ? response : body); + } + }); + }).catch(function (error) { + self._log.fatal(error); + throw error; + }); + }).nodeify(next); // Enable node.js style callback support +} + +/** + * @public + * @description Request resources from the API server + * + * Corresponds to the 'GET' http method. + * + * Missing parameters can be stripped from the beginning of the parameter list and will be checked for type + * appropriately. If `query` is not defined, the returned `Promise` will be for a KubernetesList. Otherwise + * it will be a `Promise` for a KubernetesResource. + * + * @param {?string} [query] - Server resource name + * @param {?object} [opts] - Method options + * @param {?function|*} [next] - Node.js callback (replaces Promise output) + * + * @this {module:endpoints~Endpoint} + * + * @returns {?Promise.} + */ +Endpoint.prototype.get = function (query, opts, next) { + if ((typeof query === 'object' && query !== null) || typeof query === 'function') { + next = opts; // parameter 'query' is optional and can be safely dropped + opts = query; // get(opts, next) + query = null; // get(next) + } + this._log.info({ + query: query || null + , namespace: (opts || {}).namespace || this.client.config.namespace + }, 'getting ' + this.resource); + return baseRequest(this, 'GET', query, null, opts, next); +}; + +/** + * @public + * @description Create new resource on the API server + * + * Corresponds to the 'POST' http method. + * + * @param {KubernetesResource|*} body - Resource object to send + * @param {?object} [opts] - Method options + * @param {?function|*} [next] - Node.js callback (replaces Promise output) + * + * @this {module:endpoints~Endpoint} + * + * @returns {?Promise.} + */ +Endpoint.prototype.create = function (body, opts, next) { + this._log.info({ + namespace: (opts || {}).namespace || this.client.config.namespace + }, 'creating ' + this.resource); + return baseRequest(this, 'POST', null, body, opts, next); +}; + +/** + * @public + * @description Update resource on the API server + * + * Corresponds to the 'PUT' http method. + * + * @param {string} query - Server resource resource + * @param {KubernetesResource|*} body - Resource object to send + * @param {?object} [opts] - Method options + * @param {?function|*} [next] - Node.js callback (replaces Promise output) + * + * @this {module:endpoints~Endpoint} + * + * @returns {?Promise.} + */ +Endpoint.prototype.update = function (query, body, opts, next) { + this._log.info({ + query: query + , namespace: (opts || {}).namespace || this.client.config.namespace + }, 'updating ' + this.resource); + return baseRequest(this, 'PUT', query, body, opts, next); +}; + +/** + * @public + * @description Partially update resource on the API server + * + * Corresponds to the 'PATCH' http method. + * + * Currently only the `application/strategic-merge-patch+json` content type is supported by default, but other types may + * be used by manually setting the `Content-Type` header in the method options. + * + * @param {string} query - Server resource resource + * @param {object} body - Resource patch to send + * @param {?object} [opts] - Method options + * @param {?function|*} [next] - Node.js callback (replaces Promise output) + * + * @this {module:endpoints~Endpoint} + * + * @returns {?Promise.} + */ +Endpoint.prototype.patch = function (query, body, opts, next) { + this._log.info({ + query: query + , namespace: (opts || {}).namespace || this.client.config.namespace + }, 'patching ' + this.resource); + return baseRequest(this, 'PATCH', query, body, opts, next); +}; + +/** + * @public + * @description Delete resource on the API server + * + * Corresponds to the 'DELETE' http method. + * + * @param {string} query - Server resource resource + * @param {?object} [opts] - Method options + * @param {?function|*} [next] - Node.js callback (replaces Promise output) + * + * @this {module:endpoints~Endpoint} + * + * @returns {?Promise.} + */ +Endpoint.prototype.delete = function (query, opts, next) { + this._log.info({ + query: query + , namespace: (opts || {}).namespace || this.client.config.namespace + }, 'deleting ' + this.resource); + return baseRequest(this, 'DELETE', query, null, opts, next); +}; + +/** + * @public + * @description Watch server resource(s) for changes + * + * Corresponds to the 'GET' http method with the `watch` query parameter set. + * + * Gets the current state of the requested resources and returns a custom event emitter (see below) with the current + * state set to the `initialState` property. Watch events will not be received until the `start` method is called on the + * emitter. This allows the user to define all event listeners before requesting events from the API server and prevent + * events from being missed during the initial setup. + * + * Missing parameters can be stripped from the beginning of the parameter list and will be checked for type + * appropriately. If query is not defined, the watched resource will be a KubernetesList. Otherwise it will be a + * KubernetesResource. + * + * @param {?string} [query] - Server resource resource + * @param {?object|*} [opts] - Method options + * @param {boolean} [opts.verbose=false] - Return full response instead of body only + * @param {?string} [opts.child] - Name of nested child resource + * @param {?function|*} [next] - Node.js callback (replaces Promise output) + * + * @this {module:endpoints~Endpoint} + * + * @returns {module.endpoints~WatchEmitter} + */ +Endpoint.prototype.watch = function (query, opts, next) { + var self = this; + if ((typeof query === 'object' && query !== null) || typeof query === 'function') { + next = opts; // Parameter 'query' is optional and can be safely dropped + opts = query; // watch(opts, next) + query = null; // watch(next) + } + if (typeof opts === 'function') { + next = opts; // Parameter 'opts' is optional and can be safely dropped + opts = null; // watch(query, next) + } + return this.get(query, opts, next).then(function (response) { + // Safely merge options objects - precedence: request > endpoint > default + opts = Object.merge(Object.merge({timeout: null}, self.options || {}, true, false), opts || {}, true); + if (opts.hasOwnProperty('verbose')) delete opts.verbose; + + // Set up request listeners + return new WatchEmitter(response, parseOptions(Object.merge({ + endpoint: getPath(self.resource, query, opts.child) + , method: 'GET' + , qs: { watch: true, resourceVersion: response.metadata.resourceVersion } + }, opts, true), self.client.config), self._log); + }).nodeify(next); +}; + +/** + * @class + * @inner + * @memberof module:endpoints + * @extends EventEmitter + * + * @summary Event Emitter for Watch Events + * @classdesc Parses and propagates watch events from the API server + * + * Call `start` to initialize the watch socket after all listeners have been set up. + * + * @description Initialize a new `WatchEmitter` object. + * + * @param {KubernetesResource|KubernetesList} response - Initial response body + * @param {object} options - Request options for watch socket + * + * @param logger + * + * @fires event:response + * @fires event:create + * @fires event:update + * @fires event:delete + * @fires event:error + */ +function WatchEmitter(response, options, logger) { + EventEmitter.call(this); + /** + * @name started + * @memberof module:endpoints~WatchEmitter# + * @description If true then the watch socket has been started + * @type {boolean} + */ + Object.defineProperty(this, 'started', { writable: true, value: false }); + /** + * @name initialState + * @memberof module:endpoints~WatchEmitter# + * @description Initial state of the watched API resource + * @type {KubernetesResource|KubernetesList} + */ + Object.defineProperty(this, 'initialState', { enumerable: true, value: response }); + /** + * @name options + * @memberof module:endpoints~WatchEmitter# + * @description Request options for the watch socket + * @type {Object} + * + * @property {object} qs + * @property {string} qs.resourceVersion - Last known version of the API resource + */ + Object.defineProperty(this, 'options', { value: Object.clone(options, true) }); + /** + * @private + * @name log + * @memberof module:endpoints~WatchEmitter# + * @description Bunyan logger for the WatchEmitter + * @type {bunyan} + */ + Object.defineProperty(this, 'log', { value: logger.child() }); + this.log.info({resourceVersion: this.options.qs.resourceVersion}, 'created new watch listener'); +} +WatchEmitter.prototype = Object.create(EventEmitter.prototype); +WatchEmitter.prototype.constructor = WatchEmitter; +Object.defineProperty(WatchEmitter.prototype, 'toString', { value: function () { + /** @this {module:endpoints~WatchEmitter} */ + return '[WatchEmitter \'' + this.options.url + '\']'; +}}); + +/** + * @public + * @description Initialize the watch socket for the WatchEmitter + * + * Events will not be sent by the WatchEmitter until this method is called. This allows the caller to set up all event + * listeners without missing any events. + * + * By default the WatchEmitter will always attempt to reconnect automatically whenever a timeout occurs on the watch + * socket. This behavior can be changed by specifying the desired `retryCount` parameter. If set, the WatchEmitter will + * only attempt to reconnect a maximum of `retryCount` times. + * + * By default this method will only operate once per WatchEmitter object. Repeated calls will simply return immediately. + * Set the `force` parameter to `true` to initialize a new watch socket regardless. + * (WARNING: this may result in multiple events being fired per resource update!) + * + * @param {?number} [retryCount] - Number of times to reconnect (null for infinite) + * @param {boolean} [force=false] - Start even if already started + */ +WatchEmitter.prototype.start = function (retryCount, force) { + var self = this; + if (typeof retryCount !== 'number') retryCount = null; + if (this.started) { + if (!force) return; + } else { + this.started = true; + } + + // Request a new watch stream + this.log.debug({resourceVersion: this.options.qs.resourceVersion}, 'watching changes to resources'); + /** @type {EventEmitter} */ + var requestListener = request(this.options); + + // Propagate server response to WatchEmitter + requestListener.on('response', function (response) { + var error = errors(null, response); + if (error) { + self.log.error(error); + self.emit('error', error); + } + /** + * Response from the API server for the established watch socket + * @event response + * @type {object} + */ + else self.emit('response', response); + // Catch watch socket errors + }).on('error', function (error) { + // [DEFAULT] Try again with unlimited retryCount + if (retryCount === null && (error.code === 'ESOCKETTIMEDOUT' || error.code === 'ETIMEDOUT')) { + self.start(null, true); + + // Subtract one from retryCount and try again + } else if (retryCount > 0 && (error.code === 'ESOCKETTIMEDOUT' || error.code === 'ETIMEDOUT')) { + self.start(--retryCount, true); + + // Out of attempts or not a timeout - propagate error as a WatchEmitter 'error' event + } else { + self.log.error(error); + /** + * Error with the watch socket + * @event error + * @type {Error} + */ + self.emit('error', error); + } + }); + + // Propagate data events to the WatchEmitter + var buffer = null; + requestListener.on('data', function (data) { + // Merge partial data objects + if (buffer instanceof Buffer) { + buffer = Buffer.concat([buffer, data]); + } else { + buffer = data; + } + try { + // Emit events for incoming WatchEvent data + var updateData = JSON.parse(buffer.toString()); + buffer = null; + + switch (updateData.type) { + case 'ADDED': + /** + * Resource has been created + * @event create + * @type {KubernetesResource} + */ + self.emit('create', updateData.object); + self.options.qs.resourceVersion = updateData.object.metadata.resourceVersion + || self.options.qs.resourceVersion; + break; + case 'MODIFIED': + /** + * Resource has been modified + * @event update + * @type {KubernetesResource} + */ + self.emit('update', updateData.object); + self.options.qs.resourceVersion = updateData.object.metadata.resourceVersion + || self.options.qs.resourceVersion; + break; + case 'DELETED': + /** + * Resource has been deleted + * @event delete + * @type {KubernetesResource} + */ + self.emit('delete', updateData.object); + self.options.qs.resourceVersion = updateData.object.metadata.resourceVersion + || self.options.qs.resourceVersion; + break; + default: + self.log.error(error); + self.emit('error', updateData.object); + return; + } + var logObject = { resourceVersion: self.options.qs.resourceVersion }; + logObject[updateData.type.toLowerCase()] = updateData.object.metadata.name; + self.log.debug(logObject, 'watch event received'); + self.log.trace(updateData, 'update data'); + } catch (error) { + // Suppress any SyntaxErrors from JSON.parse + if (! error instanceof SyntaxError) { + self.log.error(error); + self.emit('error', error); + } + } + }); +}; + +/** + * @class + * @inner + * @memberof module:endpoints + * @extends module:endpoints~Endpoint + * + * @summary Kubernetes API Endpoint for Nodes + * @classdesc Extended endpoint for 'nodes' + * + * @description Define a new `NodesEndpoint` object. + */ +function NodesEndpoint(client, resource, nested, options) { + Endpoint.call(this, client, resource, nested, options); + Object.merge(this, Endpoint.prototype, true, false); +} +NodesEndpoint.prototype = Object.create(Endpoint.prototype); +Object.defineProperty(NodesEndpoint, 'constructor', { + value: NodesEndpoint +}); + +/** + * @public + * @description Find all pods running on the given node + * + * @param {string} query - Server resource resource + * @param {?object} [opts] - Method options + * @param {?function|*} [next] - Node.js callback (replaces Promise output) + * + * @returns {Promise} - Resolves to the response body from the API server + */ +NodesEndpoint.prototype.getPods = function (query, opts, next) { + if (typeof opts === 'function') { + next = opts; + opts = null; + } + return this.client.pods.get(Object.merge({fields: { 'spec.nodeName': query}}, opts || {}, true), next); +}; + +/** + * @public + * @description Apply the same patch to all pods on a node + * + * @param {string} query - Server resource resource + * @param {object} body - The patch object to apply + * @param {?object} [opts] - Method options + * @param {?function|*} [next] - Node.js callback (replaces Promise output) + * + * @returns {Promise} - Resolves to the response body from the API server + */ +NodesEndpoint.prototype.patchPods = function (query, body, opts, next) { + var self = this; + var podList; + if (typeof opts === 'function') { + next = opts; + opts = null; + } + return this.getPods(query, opts) + .then(function (pods) { + podList = pods; + return pods.items; + }).map(function (pod) { + return self.client.pods.patch(pod.metadata.name, body, {namespace: pod.metadata.namespace}) + .then(function (pod) { + delete pod.kind; + delete pod.apiVersion; + return pod; + }); + }).then(function (pods) { + podList.items = pods; + return podList; + }).nodeify(next); +}; + +/** + * @public + * @description Delete all pods on the given node + * + * @param {string} query - Server resource resource + * @param {?object} [opts] - Method options + * @param {?function|*} [next] - Node.js callback (replaces Promise output) + * + * @returns {Promise} - Resolves to the response body from the API server + */ +NodesEndpoint.prototype.deletePods = function (query, opts, next) { + var self = this; + var podList; + if (typeof opts === 'function') { + next = opts; + opts = null; + } + return this.getPods(query, opts) + .then(function (pods) { + podList = pods; + return pods.items; + }).map(function (pod) { + return self.client.pods.delete(pod.metadata.name, {namespace: pod.metadata.namespace}); + }).then(function (pods) { + podList.items = pods; + return podList; + }).nodeify(next); +}; + +/** + * @public + * @description Remove a node from the scheduling pool and remove its pods + * + * @param {string} query - Server resource resource + * @param {?object} [opts] - Method options + * @param {?function|*} [next] - Node.js callback (replaces Promise output) + * + * @returns {Promise.} - Resolves to the response body from the API server + */ +NodesEndpoint.prototype.evacuate = function (query, opts, next) { + var self = this; + if (typeof opts === 'function') { + next = opts; + opts = null; + } + return this.patch(query, { + spec: { unschedulable: true } + }, opts).tap(function (node) { + return self.deletePods(node.metadata.name); + }).nodeify(next); +}; + +/** + * @public + * @description Define a node as schedulable by the API server + * + * @param {string} query - Server resource name + * @param {?object} [opts] - Method options + * @param {?function|*} [next] - Node.js callback (replaces Promise output) + * + * @returns {Promise.} - Resolves to the response body from the API server + */ +NodesEndpoint.prototype.schedule = function (query, opts, next) { + return this.patch(query, { + spec: {unschedulable: false} + }, opts, next); +}; + +/** + * @class + * @inner + * @memberof module:endpoints + * @extends module:endpoints~Endpoint + * + * @summary Kubernetes API Endpoint for ReplicationControllers + * @classdesc Extended endpoint for 'replicationControllers' + * + * @description Define a new `ReplicationControllersEndpoint` object. + */ +function ReplicationControllersEndpoint(client, resource, nested, options) { + Endpoint.call(this, client, resource, nested, options); + Object.merge(this, Endpoint.prototype, true, false); +} +ReplicationControllersEndpoint.prototype = Object.create(Endpoint.prototype); +Object.defineProperty(ReplicationControllersEndpoint.prototype, 'constructor', { + value: ReplicationControllersEndpoint +}); + +/** + * @public + * @description Scale the Replication Controller's replica count + * + * Specify a negative increment to scale down. + * + * @param {string} query - Server resource name + * @param {number} [increment=1] - Number of replicas to add + * @param {?object} [opts] - Method options + * @param {boolean} [opts.prune=false] - Delete replication controller if replicas becomes 0 + * @param {?function|*} [next] - Node.js callback (replaces Promise output) + * + * @returns {Promise} - Resolves to the response body from the API server + */ +ReplicationControllersEndpoint.prototype.scale = function (query, increment, opts, next) { + var self = this; + if (typeof opts === 'function') { + next = opts; + opts = null; + } + var prune = (opts) ? !!opts.prune : false; + if (opts && opts.hasOwnProperty('prune')) delete opts.prune; + return self.get(query, opts).then(function (rc) { + var count = rc.spec.replicas + (increment || 1); + if (count < 0) count = 0; + return self.patch(query, { spec: { replicas: count }}, {namespace: rc.metadata.namespace}); + }).then(function (rc) { + if (prune && increment === 0) { + return self.delete(query); + } else return rc; + }).nodeify(next); +}; + +/** + * @global + * @typedef {object} RequestOptions + * @description Options for an API request method + * + * Additional properties not recognized by the underlying `request` module are documented below. + * + * @property {string} endpoint - Target API resource endpoint + * @property {string} method - Request method verb + * @property {object} [body] - Request body for PUT/POST/PATCH methods + * @property {boolean} [json] - Request body is encoded in JSON format + * @property {string} [namespace] - Namespace for the request + * @property {string} [version] - API version + * @property {string} [prefix] - API path prefix + * @property {object} [labels] - Filter results by label + * @property {object} [fields] - Filter results by field + */ + +/** + * @private + * @description Parse options for an API request + * + * @param {object} options - Method request options + * @param {module:client~ClientConfig} config - Client configuration + * + * @returns {object} - Parsed method options + */ +function parseOptions(options, config) { + options = Object.clone(options, true); + var endpoint = options.endpoint + , version = options.version + , prefix = options.prefix; + delete options.endpoint; + delete options.version; + delete options.prefix; + + // Define request namespace + var ns; + if (!options.hasOwnProperty('ns') || options.ns) { + if (options.hasOwnProperty('namespace')) { + ns = options.namespace; + delete options.namespace; + } else { + ns = config.namespace; + } + } else { + ns = null; + } + if (options.hasOwnProperty('ns')) { + delete options.ns; + } + + // Parse label selectors and add to request options + if (options.labels) { + options = Object.merge({ + qs: { labelSelector: parseFilter(options.labels) } + }, options, true); + delete options.labels; + } + + // Parse field selectors and add to request options + if (options.fields) { + options = Object.merge({ + qs: { fieldSelector: parseFilter(options.fields) } + }, options, true); + delete options.fields; + } + + // Version compatibility handling + switch (version) { + case 'v1beta1': // Legacy support for minions endpoints + case 'v1beta2': // and query string namespaces + endpoint = endpoint.replace('nodes', 'minions'); + if (ns) { + options = Object.merge(options, { + qs: { namespace: ns } + }, true); + } + break; + + case 'v1beta3': // New versions use lowercase endpoint + case 'v1': // names and path namespaces + default: + endpoint = endpoint.toLowerCase(); + if (ns) { + endpoint = getPath('namespaces', ns, endpoint); + } + break; + } + + // Set correct proxy endpoints + if (endpoint.match(/^proxy\//)) { + endpoint = 'proxy/' + endpoint.replace('proxy/', ''); + } + + // Set Content-Type header for PATCH methods + if (options.method === 'PATCH') { + options = Object.merge({ + headers: { + 'Content-Type': 'application/strategic-merge-patch+json' + } + }, options, true); + } + + // Set json and body options for PUT/POST/PATCH methods + if (options.body) { + if (typeof options.body === 'object') { + options.json = true; + } + } + + // Update request endpoint with base options + return Object.merge(options, { + url: getPath(config.host, prefix, version, endpoint) + , headers: (config.token) ? { Authorization: 'Bearer ' + config.token } : {} + }, true); +} + +/** + * @private + * @description Convert a label or field selector object to a valid query string + * + * Each key in the object should be a string to match against. + * Prepend '_' (underscore) to the key resource to invert the match. + * + * Labels only: + * The key value may also be an array, to allow for set-based + * label matching as with 'in' and 'notin'. The value may also + * be an empty string, which will match all resources with the + * label regardless of value. + * + * @param {object} object - Method options + * + * @returns {string} - Formatted query string for fieldSelector or labelSelector + */ +function parseFilter(object) { + var selectors = []; + Object.keys(object, function (key, value) { + var filter = ''; + // Match all resources with the given label + if (value === '') { + filter += key; + + // Match the label or field value against the string + } else if (typeof value === 'string') { + if (key.startsWith('_')) { + key = key.replace('_', ''); + filter += key + '!=' + value; + } else { + filter += key + '=' + value; + } + + // Match the label value against the list + } else if (typeof value === 'object') { + if (key.startsWith('_')) { + key = key.replace('_', ''); + filter += key + ' notin (' + value + ')'; + } else { + filter += key + ' in (' + value + ')'; + } + } + selectors.push(filter); + }); + return selectors.toString(); +} + +/** + * @private + * @description Parse all arguments into an API path fragment + * + * @returns {string} - All non-empty arguments joined by '/' + */ +function getPath() { + // Convert arguments to true array and filter null values + return Array.prototype.filter.call(arguments, function (each) { + return each !== null && each !== undefined && each !== ''; + }).join('/'); +} diff --git a/lib/errors.js b/lib/errors.js new file mode 100644 index 0000000..edf041b --- /dev/null +++ b/lib/errors.js @@ -0,0 +1,173 @@ +'use strict'; + +/** + * @private + * @name Error + * @property {function} captureStackTrace + */ + +/** + * @name call + * @public + * @function + * @memberof module:errors + * @description Parse a response from the API server to determine error status + * + * @param {?Error} error + * @param {?object} response + * @returns {?Error|module:errors.HttpError} + */ +/** + * @module errors + * @description x + * + * @returns {?Error|module:errors.HttpError} + */ +module.exports = function GetError(error, response) { + if (error instanceof Error) { + return error; + } else if (response.statusCode > 399) { + if (http.hasOwnProperty(response.statusCode)) { + return new http[response.statusCode](response.body); + } else { + return new HttpError(response.body, response.statusCode); + } + } else if (response.body && response.body.statusCode > 399) { + if (http.hasOwnProperty(response.body.statusCode)) { + return new http[response.body.statusCode](response.body); + } else { + return new HttpError(response.body, response.body.statusCode); + } + } else return null; +}; +module.exports.HttpError = HttpError; + +/** + * @class + * @inner + * @memberof module:errors + * @description Base class for client errors + * + * @extends Error + */ +function ClientError(message, statusCode) { + Error.call(this); + Error.captureStackTrace(this, this.constructor); + Object.defineProperty(this, 'name', { value: this.constructor.name }); + /** + * Description of the error + * @type {string} + */ + this.message = message; + if (statusCode) { + /** + * HTTP status code + * @type {number} + */ + this.statusCode = statusCode; + } +} +ClientError.prototype = Object.create(Error.prototype); +ClientError.prototype.constructor = ClientError; +//module.exports.Error = ClientError; + +/** + * @class + * @static + * @memberof module:errors + * @description Missing initialization parameter + * + * @extends module:errors~ClientError + * + * @param {string|object} parameter - Missing config parameter name + */ +function ParameterError(parameter) { + ClientError.call(this, 'missing required parameter: \'' + parameter + '\''); +} +ParameterError.prototype = Object.create(ClientError.prototype); +ParameterError.prototype.constructor = ParameterError; +module.exports.ParameterError = ParameterError; + +/** + * @class + * @static + * @memberof module:errors + * @description Invalid API version + * + * @extends module:errors~ClientError + * + * @param {string} version - Requested API version + * @param {object.} spec - API versions specification + */ +function VersionError(version, spec) { + ClientError.call(this, 'invalid api version: \'' + version + '\''); + /** + * @name versions + * @memberof module:errors.VersionError + * @description List of valid versions for the given API + * @type {string[]} + */ + Object.defineProperty(this, 'versions', {enumerable: true, value: Object.keys(spec)}); +} +VersionError.prototype = Object.create(ClientError.prototype); +VersionError.prototype.constructor = VersionError; +module.exports.VersionError = VersionError; + +/** + * @class + * @static + * @memberof module:errors + * @description Parsing of the oAuth token failed + * + * @extends {module:errors~ClientError} + * + * @param {?string} header - Response header that was parsed + * @param {Error} error - The original error + */ +function TokenParseError(header, error) { + ClientError.call(this, 'failed to parse oAuth token from response'); + /** + * The response header that was parsed + * @type {string} + */ + this.header = header; + /** + * The original error + * @type {Error} + */ + this.error = error; +} +TokenParseError.prototype = Object.create(ClientError.prototype); +TokenParseError.prototype.constructor = TokenParseError; +module.exports.TokenParseError = TokenParseError; + +/** + * @class + * @static + * @memberof module:errors + * @description HTTP Request Errors + */ +function HttpError(message, code) { + ClientError.call(this, message, code); +} +HttpError.prototype = Object.create(ClientError.prototype); +HttpError.prototype.constructor = HttpError; + +var http = { + 400: function BadRequestError(message) { + HttpError.call(this, message, 400); + }, + 401: function UnauthorizedError(message) { + HttpError.call(this, message, 401); + }, + 403: function ForbiddenError(message) { + HttpError.call(this, message, 403); + }, + 404: function NotFoundError(message) { + HttpError.call(this, message, 404); + } +}; +for (var i in http) { + if (http.hasOwnProperty(i)) http[i].prototype = Object.create(HttpError.prototype); + if (http.hasOwnProperty(i)) http[i].prototype.constructor = http[i]; +} diff --git a/lib/spec.js b/lib/spec.js new file mode 100644 index 0000000..3b0c551 --- /dev/null +++ b/lib/spec.js @@ -0,0 +1,631 @@ +/** + * @name Object.keys + * @description Modified method signature from Sugar.js + * @function + * @private + * @param {object} + * @param {function=} + */ + +/** + * @global + * @typedef {object} KubernetesItem + * @description Entry in a KubernetesList + * + * @property {object} metadata - Object metadata + * @property {object} spec - Desired object specification + */ + +/** + * @global + * @typedef {object} KubernetesResource + * @description Kubernetes resource object + * + * @property {string} kind - Kubernetes API resource name + * @property {string} apiVersion - Reference API version + * @borrows metadata from KubernetesItem + * @borrows spec from KubernetesItem + */ + +/** + * @global + * @typedef {object} KubernetesList + * @description List of Kubernetes resource objects + * + * @property {KubernetesItem[]} items + * @borrows kind from KubernetesResource + * @borrows apiVersion from KubernetesResource + * @borrows metadata from KubernetesItem + */ + +/** + * @global + * @typedef {object} EndpointSpecification + * @description Specification of an API resource endpoint + * + * @property {string} kind - Kubernetes API resource name + * @property {string} [nickname] - Shortened endpoint name + * @property {NestedResourceDefinition[]} [nested] - List of nested resources + * @property {object} [options] - Request overrides for the endpoint + * @property {boolean} [options.ns=true] - Whether the endpoint uses namespaces + * @property {string[]} [options.methods] - List of valid base methods + */ + +/** + * @global + * @typedef {object} APISpecification + * @description Specification of an API group + * + * @property {string} name - Name of the API group + * @property {string} [prefix] - Custom URL prefix for endpoints + * @property {object.} spec - Specification of all resource endpoints + */ + +/** + * @global + * @typedef {object} NestedResourceDefinition + * @description Specification of nested API resource endpoints + * + * @property {string[]} methods - List of valid methods + * @property {string} resource - Nested resource name + */ + +/** + * @namespace api + * @description Specification of the base Kubernetes API + * + * All endpoints are defined as instance members of the [KubernetesClient]{@link module:client.KubernetesClient}. The + * constructor for the client parses each {@link EndpointSpecification} defined for the chosen API version to create + * the appropriate Endpoint object for the client. The specified `version` parameter for the client must match one of + * the values defined here. + * + * @see http://kubernetes.io/docs/api/ + * @type {object.} + */ +const KubernetesSpecification = { + /** + * Stable version `v1` of the base Kubernetes API + * @namespace api.v1 + * @type {APISpecification} + * @see http://kubernetes.io/docs/api-reference/v1/operations/ + */ + v1: { + name: 'v1' + , spec: { + /** + * @memberof api.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + bindings: { + kind: 'Binding' + , options: {methods: ['create']} + }, + /** + * @memberof api.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + componentStatuses: { + kind: 'ComponentStatus' + , options: {ns: false, methods: ['get', 'watch']} + }, + /** + * @memberof api.v1# + * @see http://kubernetes.io/docs/user-guide/services/ + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + endpoints: {kind: 'Endpoint'}, + /** + * @memberof api.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + events: {kind: 'Event'}, + /** + * @memberof api.v1# + * @see http://kubernetes.io/docs/admin/limitrange/ + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + limitRanges: {kind: 'LimitRange'}, + /** + * @memberof api.v1# + * @see http://kubernetes.io/docs/user-guide/namespaces/ + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + namespaces: { + kind: 'Namespace' + , nickname: 'ns' + , options: {ns: false} + , nested: [ + {resource: 'finalize', methods: ['update']} + ] + }, + /** + * @memberof api.v1# + * @see http://kubernetes.io/docs/admin/node/ + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + nodes: { + kind: 'Node' + , options: {ns: false} + }, + /** + * @memberof api.v1# + * @see http://kubernetes.io/docs/user-guide/volumes/ + * @see http://kubernetes.io/docs/user-guide/persistent-volumes/ + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + persistentVolumes: { + kind: 'PersistentVolume' + , nickname: 'pv' + , options: {ns: false} + }, + /** + * @memberof api.v1# + * @see http://kubernetes.io/docs/user-guide/volumes/ + * @see http://kubernetes.io/docs/user-guide/persistent-volumes/#persistentvolumeclaims + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + persistentVolumeClaims: { + kind: 'PersistentVolumeClaim' + , nickname: 'pvc' + }, + /** + * @memberof api.v1# + * @see http://kubernetes.io/docs/user-guide/pods/ + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + pods: { + kind: 'Pod' + , nested: [ + {resource: 'attach', methods: ['get', 'create']} + , {resource: 'binding', methods: ['get']} + , {resource: 'exec', methods: ['get', 'create']} + , {resource: 'log', methods: ['get']} + , {resource: 'portforward', methods: ['get', 'create']} + , {resource: 'proxy'} + ] + }, + /** + * @memberof api.v1# + * @see http://kubernetes.io/docs/user-guide/replication-controller/#pod-template + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + podTemplates: {kind: 'PodTemplate'}, + /** + * @private + * @memberof api.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + 'proxy/nodes': {kind: 'Proxy'}, + /** + * @private + * @memberof api.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + 'proxy/pods': {kind: 'Proxy'}, + /** + * @private + * @memberof api.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + 'proxy/services': {kind: 'Proxy'}, + /** + * @memberof api.v1# + * @see http://kubernetes.io/docs/user-guide/replication-controller/ + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + replicationControllers: { + kind: 'ReplicationController' + , nickname: 'rc' + }, + /** + * @memberof api.v1# + * @see http://kubernetes.io/docs/admin/resourcequota/ + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + resourceQuotas: {kind: 'ResourceQuota'}, + /** + * @memberof api.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + secrets: {kind: 'Secret'}, + /** + * @memberof api.v1# + * @see http://kubernetes.io/docs/user-guide/services/ + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + services: { + kind: 'Service' + , nickname: 'svc' + }, + /** + * @memberof api.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + serviceAccounts: {kind: 'ServiceAccount'} + } + } +}; + +/** + * @namespace apis + * @description Specification of the Kubernetes API groups + * + * API groups allow for easy expansion of the base Kubernetes API while maintaining compatibility with the main + * resource endpoints. This library comes prepackaged with the `extensions` API group from Kubernetes, available by + * setting the `beta` configuration property to the desired extensions version, or `true` for the latest available + * version. Below is an example for defining API groups for the client. Specify additional {@link APISpecification} + * objects in the `apis` configuration property to add custom API groups to the client. + * + * @example config.apis = [{ + * // Define name in the format: {API_GROUP}/{VERSION} + * name: 'fruits/v1' + * , spec: { + * // `apples` endpoint for resource of kind `Apple` + * apples: { + * kind: 'Apple' + * // Example of nested resource endpoint definitions + * , nested: [{ + * resource: 'peel' + * , methods: ['get', 'put'] // Defines `client.apples.getPeel` and `client.apples.putPeel` methods + * }, { + * resource: 'juice' + * , methods: ['get'] // Defines `client.oranges.getJuice` method + * }] + * }, + * // `oranges` endpoint for resources of kind `Orange` + * oranges: { + * kind: 'Orange' + * , nickname: 'oj' // Creates link to resource `client.oj === client.oranges` + * } + * } + * }]; + */ +/** + * @namespace apis.extensions + * @description Specification of the Kubernetes API group `extensions` + * + * Set the `beta` configuration property to enable these endpoints. If no version is given (i.e. `beta === true`) then + * the latest defined version will be used. + * + * If enabled, all endpoints are defined as [Endpoint]{@link module:endpoints~Endpoint} instance members of the + * [KubernetesClient]{@link module:client.KubernetesClient} object. + * + * @type {object.} + */ +const KubernetesExtensionsSpecification = { + /** + * Beta version `v1beta1` of the Kubernetes API extensions + * @namespace apis.extensions.v1beta1 + * @type {APISpecification} + * @see http://kubernetes.io/docs/api-reference/extensions/v1beta1/operations/ + */ + v1beta1: { + name: 'extensions/v1beta1' + , spec: { + /** + * @memberof apis.extensions.v1beta1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + daemonSets:{kind: 'DaemonSet'}, + /** + * @memberof apis.extensions.v1beta1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + deployments: {kind: 'Deployment'}, + /** + * @memberof apis.extensions.v1beta1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + horizontalPodAutoscalers: {kind: 'HorizontalPodAutoscaler'}, + /** + * @memberof apis.extensions.v1beta1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + ingresses: {kind: 'Ingress'}, + /** + * @memberof apis.extensions.v1beta1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + jobs: {kind: 'Job'} + } + } +}; + +/** + * @namespace oapi + * @description Specification of the base OpenShift API + * + * Set the `oshift` configuration property to enable these endpoints. If no version is given (i.e. `oshift === true`) + * then the version will match the Kubernetes API version. + * + * If enabled, all endpoints are defined as [Endpoint]{@link module:endpoints~Endpoint} instance members of the + * [KubernetesClient]{@link module:client.KubernetesClient} object. + * + * @type {object.} + */ +const OpenShiftSpecification = { + /** + * Stable version `v1` of the base OpenShift API + * @namespace oapi.v1 + * @type {APISpecification} + */ + v1: { + name: 'v1' + , prefix: 'oapi' + , spec: { + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + builds: { + kind: 'Binding' + , nested: [ + {resource: 'clone', methods: ['create']} + , {resource: 'log', methods: ['get']} + ] + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + buildConfigs: { + kind: 'BuildConfig' + , nested: [ + {resource: 'instantiate', methods: ['create']} + , {resource: 'webhooks', methods: ['create']} + ] + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + clusterNetworks: { + kind: 'ClusterNetwork' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + clusterPolicies: { + kind: 'ClusterPolicy' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + clusterPolicyBindings: { + kind: 'ClusterPolicyBinding' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + clusterRoles: { + kind: 'ClusterRole' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + clusterRoleBindings: { + kind: 'ClusterRoleBinding' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + deploymentConfigs: {kind: 'DeploymentConfig'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + deploymentConfigRollbacks: {kind: 'DeploymentConfigRollback'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + generatedDeploymentConfigs: { + kind: 'GeneratedDeploymentConfig' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + groups: { + kind: 'Group' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + hostSubnets: { + kind: 'HostSubnet' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + identities: { + kind: 'Identity' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + images: { + kind: 'Image' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + imageStreams: {kind: 'ImageStream'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + imageStreamImages: {kind: 'ImageStreamImage'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + imageStreamMappings: {kind: 'ImageStreamMapping'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + imageStreamTags: {kind: 'ImageStreamTag'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + localResourceAccessReviews: {kind: 'LocalResourceAccessReview'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + localSubjectAccessReviews: {kind: 'LocalSubjectAccessReview'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + netNamespaces: { + kind: 'NetNamespace' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + oAuthAccessTokens: { + kind: 'oAuthAccessToken' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + oAuthAuthorizeTokens: { + kind: 'oAuthAuthorizeToken' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + oAuthClients: { + kind: 'oAuthClient' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + oAuthClientAuthorizations: { + kind: 'oAuthClientAuthorization' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + policies: {kind: 'Policy'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + policyBindings: {kind: 'PolicyBinding'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + processedTemplates: {kind: 'ProcessedTemplate'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + projects: { + kind: 'Project' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + projectRequests: { + kind: 'ProjectRequest' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + remoteAccessReviews: { + kind: 'RemoteAccessReview' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + resourceAccessReviews: {kind: 'ResourceAccessReview'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + roles: {kind: 'Role'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + roleBindings: {kind: 'RoleBinding'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + routes: {kind: 'Route'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + subjectAccessReviews: {kind: 'SubjectAccessReview'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + templates: {kind: 'Template'}, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + users: { + kind: 'User' + , options: {ns: false} + }, + /** + * @memberof oapi.v1# + * @type {module:endpoints~Endpoint|EndpointSpecification} + */ + userIdentityMappings: { + kind: 'UserIdentityMapping' + , options: {ns: false} + } + } + } +}; + +module.exports = { + Kubernetes: KubernetesSpecification, + KubernetesBeta: KubernetesExtensionsSpecification, + OpenShift: OpenShiftSpecification +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..a03b1ca --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "name": "cisco-kube-client", + "version": "1.0.0", + "description": "Cisco Kubernetes and OpenShift client for Node.js", + "main": "index.js", + "engines": { + "node": "^4.3.2" + }, + "scripts": { + "prepublish": "make", + "pretest": "make", + "test": "mocha --timeout 8000 --recursive test/util test/kubernetes test/extended", + "test-oshift": "mocha --timeout 8000 --recursive test/oshift" + }, + "repository": { + "type": "git", + "url": "git://github.com/ryclarke/cisco-kube-client.git" + }, + "keywords": [ + "cisco", + "kubernetes", + "openshift", + "k8s", + "docker" + ], + "author": { + "name": "Ryan Clarke ryclarke@cisco.com" + }, + "license": "BSD-3-Clause", + "bugs": { + "url": "https://github.com/ryclarke/cisco-kube-client/issues" + }, + "dependencies": { + "bluebird": "^3.3.4", + "bunyan": "^1.8.1", + "request": "^2.26.0", + "sugar": "^1.3.9" + }, + "devDependencies": { + "debug": "^2.2.0", + "jsdoc": "^3.4.0", + "jsdoc-oblivion": "0.0.4", + "mocha": "^2.4.5", + "should": "^8.3.1", + "uglify-js": "^2.6.2" + }, + "homepage": "https://github.com/ryclarke/cisco-kube-client", + "readmeFilename": "README.md" +} diff --git a/test/extended/nodes.js b/test/extended/nodes.js new file mode 100644 index 0000000..57681b1 --- /dev/null +++ b/test/extended/nodes.js @@ -0,0 +1,92 @@ +'use strict'; +require('sugar'); +var should = require('should') + , test = require('../test') + , Client = require('../../index') + , config = require ('../../config') + , title = 'Cisco Kubernetes Client (nodes extended)' + , test_namespace = 'kube-client-test' + , client + , patch + , name; + +var debug; +try { + debug = require('debug')('test'); +} catch (error) { + debug = function(){}; +} + +should.describe(title, function () { + // Initialize the client and test namespace + should.before(function (done) { + var self = this; + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + + patch = test.importFile('nodes-patchPods.json'); + + client = Client(Object.merge({ + namespace: test_namespace, timeout: this.timeout(), promise: false + }, config, true, false)); + + client.nodes.get().then(function (nodes) { + if (nodes.items && nodes.items.length > 0) { + name = nodes.items[0].metadata.name; + done(); + } else { + return self.skip(); + } + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + + // Test 'getPods' method + should.it('should return the pod list', function (done) { + client.nodes.getPods(name).then(function () { + done(); + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + + // Test 'patchPods' method + should.it('should patch the pod list', function (done) { + if (!patch) return this.skip(); + client.nodes.patchPods(name, patch).then(function () { + done(); + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + + // Test 'deletePods' method + should.it('should delete the pod list', function (done) { + if (!patch) return this.skip(); + client.nodes.deletePods(name).then(function () { + done(); + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + + // Test 'evacuate' method + should.it('should evacuate the node', function (done) { + if (!patch) return this.skip(); + client.nodes.evacuate(name).then(function () { + done(); + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + + // Test 'schedule' method + should.it('should set the node as schedulable', function (done) { + if (!patch) return this.skip(); + client.nodes.schedule(name).then(function () { + done(); + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); +}); diff --git a/test/extended/replicationControllers.js b/test/extended/replicationControllers.js new file mode 100644 index 0000000..78761ce --- /dev/null +++ b/test/extended/replicationControllers.js @@ -0,0 +1,64 @@ +'use strict'; +require('sugar'); +var should = require('should') + , test = require('../test') + , Client = require('../../index') + , config = require ('../../config') + , title = 'Cisco Kubernetes Client (replicationControllers extended)' + , test_namespace = 'kube-client-test' + , replicas + , client + , name; + +var debug; +try { + debug = require('debug')('test'); +} catch (error) { + debug = function(){}; +} + +should.describe(title, function () { + // Initialize the client and test namespace + should.before(function (done) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + + client = Client(Object.merge({ + namespace: null, timeout: this.timeout(), promise: false + }, config, true, false)); + + client.replicationControllers.get().then(function (rc) { + name = rc.items[0].metadata.name; + replicas = rc.items[0].spec.replicas; + test_namespace = rc.items[0].metadata.namespace; + done(); + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + + // Test 'scale' method with positive scale + should.it('should scale the replicationController up', function (done) { + client.replicationControllers.scale(name, 1, {namespace: test_namespace}).then(function (rc) { + if (rc.spec.replicas == replicas + 1) { + done(); + } else { + throw new Error('spec.replicas should be incremented'); + } + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + + // Test 'scale' method with negative scale + should.it('should scale the replicationController down', function (done) { + client.replicationControllers.scale(name, -1, {namespace: test_namespace}).then(function (rc) { + if (rc.spec.replicas == replicas) { + done(); + } else { + throw new Error('spec.replicas should be decremented'); + } + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); +}); \ No newline at end of file diff --git a/test/json/endpoints-patch.json b/test/json/endpoints-patch.json new file mode 100644 index 0000000..e494fe5 --- /dev/null +++ b/test/json/endpoints-patch.json @@ -0,0 +1,12 @@ +{ + "subsets": [ + { + "addresses": [ + { "ip": "1.2.3.4" } + ], + "ports": [ + { "port": 3000 } + ] + } + ] +} \ No newline at end of file diff --git a/test/json/endpoints.json b/test/json/endpoints.json new file mode 100644 index 0000000..a9389ab --- /dev/null +++ b/test/json/endpoints.json @@ -0,0 +1,17 @@ +{ + "apiVersion": "v1", + "kind": "Endpoints", + "metadata": { + "name": "svc-test" + }, + "subsets": [ + { + "addresses": [ + { "ip": "1.2.3.4" } + ], + "ports": [ + { "port": 8000 } + ] + } + ] +} \ No newline at end of file diff --git a/test/json/replicationControllers-patch.json b/test/json/replicationControllers-patch.json new file mode 100644 index 0000000..150c62e --- /dev/null +++ b/test/json/replicationControllers-patch.json @@ -0,0 +1,5 @@ +{ + "spec": { + "replicas": 10 + } +} \ No newline at end of file diff --git a/test/json/replicationControllers.json b/test/json/replicationControllers.json new file mode 100644 index 0000000..d7b9455 --- /dev/null +++ b/test/json/replicationControllers.json @@ -0,0 +1,28 @@ +{ + "apiVersion": "v1", + "kind": "ReplicationController", + "metadata": { + "name": "rc-test" + }, + "spec": { + "replicas": 2, + "selector": { + "test": "selector" + }, + "template": { + "metadata": { + "labels": { + "test": "selector" + } + }, + "spec": { + "containers": [ + { + "name": "test", + "image": "nginx" + } + ] + } + } + } +} \ No newline at end of file diff --git a/test/json/services-patch.json b/test/json/services-patch.json new file mode 100644 index 0000000..b8a06ff --- /dev/null +++ b/test/json/services-patch.json @@ -0,0 +1,11 @@ +{ + "spec": { + "ports": [ + { + "protocol": "TCP", + "port": 443, + "targetPort": 8443 + } + ] + } +} \ No newline at end of file diff --git a/test/json/services.json b/test/json/services.json new file mode 100644 index 0000000..bf2d965 --- /dev/null +++ b/test/json/services.json @@ -0,0 +1,19 @@ +{ + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "svc-test" + }, + "spec": { + "selector": { + "test": "selector" + }, + "ports": [ + { + "protocol": "TCP", + "port": 80, + "targetPort": 8080 + } + ] + } +} \ No newline at end of file diff --git a/test/kubernetes/componentStatuses.js b/test/kubernetes/componentStatuses.js new file mode 100644 index 0000000..e9e836d --- /dev/null +++ b/test/kubernetes/componentStatuses.js @@ -0,0 +1,3 @@ +var test = require('../test') + , config = require('../../config'); +test('componentStatuses', config); diff --git a/test/kubernetes/endpoints.js b/test/kubernetes/endpoints.js new file mode 100644 index 0000000..4f17972 --- /dev/null +++ b/test/kubernetes/endpoints.js @@ -0,0 +1,3 @@ +var test = require('../test') + , config = require('../../config'); +test('endpoints', config); diff --git a/test/kubernetes/events.js b/test/kubernetes/events.js new file mode 100644 index 0000000..ca29442 --- /dev/null +++ b/test/kubernetes/events.js @@ -0,0 +1,3 @@ +var test = require('../test') + , config = require('../../config'); +test('events', config); diff --git a/test/kubernetes/limitRanges.js b/test/kubernetes/limitRanges.js new file mode 100644 index 0000000..04f14d1 --- /dev/null +++ b/test/kubernetes/limitRanges.js @@ -0,0 +1,3 @@ +var test = require('../test') + , config = require('../../config'); +test('limitRanges', config); diff --git a/test/kubernetes/namespaces.js b/test/kubernetes/namespaces.js new file mode 100644 index 0000000..2ee0c1e --- /dev/null +++ b/test/kubernetes/namespaces.js @@ -0,0 +1,3 @@ +var test = require('../test') + , config = require('../../config'); +test('namespaces', config); diff --git a/test/kubernetes/nodes.js b/test/kubernetes/nodes.js new file mode 100644 index 0000000..c446cec --- /dev/null +++ b/test/kubernetes/nodes.js @@ -0,0 +1,3 @@ +var test = require('../test') + , config = require('../../config'); +test('nodes', config); diff --git a/test/kubernetes/persistentVolumeClaims.js b/test/kubernetes/persistentVolumeClaims.js new file mode 100644 index 0000000..ed7dbab --- /dev/null +++ b/test/kubernetes/persistentVolumeClaims.js @@ -0,0 +1,3 @@ +var test = require('../test') + , config = require('../../config'); +test('persistentVolumeClaims', config); diff --git a/test/kubernetes/persistentVolumes.js b/test/kubernetes/persistentVolumes.js new file mode 100644 index 0000000..8677edc --- /dev/null +++ b/test/kubernetes/persistentVolumes.js @@ -0,0 +1,3 @@ +var test = require('../test') + , config = require('../../config'); +test('persistentVolumes', config); diff --git a/test/kubernetes/podTemplates.js b/test/kubernetes/podTemplates.js new file mode 100644 index 0000000..3b014fe --- /dev/null +++ b/test/kubernetes/podTemplates.js @@ -0,0 +1,3 @@ +var test = require('./../test') + , config = require('../../config'); +test('podTemplates', config); diff --git a/test/kubernetes/pods.js b/test/kubernetes/pods.js new file mode 100644 index 0000000..4aa5b9f --- /dev/null +++ b/test/kubernetes/pods.js @@ -0,0 +1,3 @@ +var test = require('../test') + , config = require('../../config'); +test('pods', config); diff --git a/test/kubernetes/replicationControllers.js b/test/kubernetes/replicationControllers.js new file mode 100644 index 0000000..cc8cf09 --- /dev/null +++ b/test/kubernetes/replicationControllers.js @@ -0,0 +1,3 @@ +var test = require('./../test') + , config = require('../../config'); +test('replicationControllers', config); diff --git a/test/kubernetes/resourceQuotas.js b/test/kubernetes/resourceQuotas.js new file mode 100644 index 0000000..ee5c007 --- /dev/null +++ b/test/kubernetes/resourceQuotas.js @@ -0,0 +1,3 @@ +var test = require('./../test') + , config = require('../../config'); +test('resourceQuotas', config); diff --git a/test/kubernetes/secrets.js b/test/kubernetes/secrets.js new file mode 100644 index 0000000..dfccfcf --- /dev/null +++ b/test/kubernetes/secrets.js @@ -0,0 +1,3 @@ +var test = require('./../test') + , config = require('../../config'); +test('secrets', config); diff --git a/test/kubernetes/serviceAccounts.js b/test/kubernetes/serviceAccounts.js new file mode 100644 index 0000000..032560b --- /dev/null +++ b/test/kubernetes/serviceAccounts.js @@ -0,0 +1,3 @@ +var test = require('./../test') + , config = require('../../config'); +test('serviceAccounts', config); diff --git a/test/kubernetes/services.js b/test/kubernetes/services.js new file mode 100644 index 0000000..3189f37 --- /dev/null +++ b/test/kubernetes/services.js @@ -0,0 +1,3 @@ +var test = require('./../test') + , config = require('../../config'); +test('services', config); diff --git a/test/oshift.js b/test/oshift.js new file mode 100644 index 0000000..27c07e5 --- /dev/null +++ b/test/oshift.js @@ -0,0 +1,7 @@ +var test = require('./test') + , spec = require('../lib/spec') + , config = require('../config'); + +Object.keys(spec.OpenShift, function (resource) { + test(resource, Object.merge({oshift:true}, config, true, false)); +}); diff --git a/test/test.js b/test/test.js new file mode 100644 index 0000000..74c3b7d --- /dev/null +++ b/test/test.js @@ -0,0 +1,156 @@ +'use strict'; +require('sugar'); +var should = require('should'); + +var path = require('path') + , Client = require('../index') + , config = require ('../config') + , title = 'Cisco Kubernetes Client' + , test_namespace = 'ryclarke-kube-test'; + +var debug = function(){}; + +/** + * @static + * @param {string|object} data + * @returns {?module.exports|object} + */ +function importFile(data) { + if (typeof data === 'string') { + try { + return require(path.join(__dirname, 'json', data)); + } catch (ignore) { + return null; + } + } else { + return data; + } +} + +/** + * @static + */ +function TestEndpoint(resource, config) { + should.describe(title + ' (' + resource + ')', function() { + var client + , object + , patch + , name; + + // Initialize the client and test namespace + should.before(function (done) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + + object = importFile(resource + '.json'); + patch = importFile(resource + '-patch.json'); + + client = Client(Object.merge({ + namespace: test_namespace, timeout: this.timeout(), promise: false + }, config, true, false)); + + client.namespaces.get(test_namespace).catch(function (error) { + if (error.statusCode == 404) return client.namespaces.create({metadata: {name: test_namespace}}); + throw error; + }).then(function () { + done(); + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + + // Artificial delay to allow for server tasks between requests + should.beforeEach(function (done) { setTimeout(done, 500); }); + + should.it('should return the resource list', function (done) { + if (!client[resource].get) return this.skip(); + client[resource].get().then(function () { + done(); + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + + // Test `watch` method + should.it('should watch the resource list', function (done) { + if (!client[resource].watch) return this.skip(); + client[resource].watch().then(function (emitter) { + emitter.on('response', function () { + done(); + }).on('error', function (error) { + done(error); + }).start(0); + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + + // Test `create` method + should.it('should create the resource', function (done) { + if (!client[resource].create) return this.skip(); + if (!object) return this.skip(); + client[resource].create(object).then(function (result) { + name = result.metadata.name; + done(); + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + + // Test `get` method with a query + should.it('should return the resource', function (done) { + if (!client[resource].get) return this.skip(); + if (!name) return this.skip(); + client[resource].get(name).then(function (result) { + object = result; + done(); + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + + // Test `patch` method + should.it('should patch the resource', function (done) { + if (!client[resource].patch) return this.skip(); + if (!name) return this.skip(); + if (!patch) return this.skip(); + client[resource].patch(name, patch).then(function () { + done(); + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + + // Test `update` method + should.it('should update the resource', function (done) { + if (!client[resource].update) return this.skip(); + if (!name) return this.skip(); + if (!object) return this.skip(); + client[resource].get(name).then(function (result) { + object.metadata.resourceVersion = result.metadata.resourceVersion; + return client[resource].update(name, object).then(function () { + done(); + }); + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + + // Test `delete` method + should.it('should delete the resource', function (done) { + if (!client[resource].delete) return this.skip(); + if (!name) return this.skip(); + client[resource].delete(name).then(function () { + done(); + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + }); +} + +/** + * @private + * @module test + */ +module.exports = TestEndpoint; +module.exports.importFile = importFile; diff --git a/test/util.js b/test/util.js new file mode 100644 index 0000000..3273690 --- /dev/null +++ b/test/util.js @@ -0,0 +1,46 @@ +'use strict'; +require('sugar'); +var should = require('should') + , Client = require('../lib/client.min') + , config = require ('../config') + , title = 'Cisco Kubernetes Client (miscellaneous)'; + +var debug = function(){}; + +should.describe(title, function () { + var client; + + // Initialize the client and test namespace + should.before(function () { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + + client = Client(Object.merge({ + namespace: 'kube-client-test', timeout: this.timeout(), promise: false + }, config, true, false)); + }); + + // Initialize the client as a Promise + should.it('should return a Promise of the client', function (done) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + + Client(Object.merge({ + namespace: 'kube-client-test', timeout: this.timeout(), usePromise: true + }, config, true, false)).then(function (client) { + client.namespaces.get().then(function () { + done(); + }); + }).catch(function (error) { + done(error); debug(JSON.stringify(error, null, 4)); + }); + }); + + // Use a method with a Node.js callback + should.it('should use a Node.js callback', function (done) { + client.namespaces.get(function (error) { + if (error) { + done(error); + debug(JSON.stringify(error, null, 4)); + } else done(); + }); + }); +});