diff --git a/configure-devstack.js b/configure-devstack.js index 6f569a4..b59ce8f 100644 --- a/configure-devstack.js +++ b/configure-devstack.js @@ -5,9 +5,17 @@ import path from 'path'; function getDevstackConfig() { const karmaConfig = karma.parseConfig(path.resolve('./karma.conf.js')); - return "[[post-config|$KEYSTONE_CONF]]\n" + - "[cors]\n" + - "allowed_origin=http://localhost:" + karmaConfig.port + "\n"; + + return getCorsConfig('$KEYSTONE_CONF', karmaConfig) + + getCorsConfig('$GLANCE_API_CONF', karmaConfig); + +} + +function getCorsConfig(service, karmaConfig) { + return `[[post-config|${service}]] +[cors] +allowed_origin=http://localhost:${karmaConfig.port} +`; } fs.appendFile(process.env.BASE + '/new/devstack/local.conf', getDevstackConfig(), (err) => { diff --git a/src/glance.js b/src/glance.js new file mode 100644 index 0000000..2175c07 --- /dev/null +++ b/src/glance.js @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2016 Hewlett Packard Enterprise Development L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See + * the License for the specific language governing permissions and limitations + * under the License. + */ +import Http from './util/http'; + +/** + * A list of all supported versions. Please keep this array sorted by most recent. + * + * @type {Array} + * @ignore + */ +const supportedGlanceVersions = [ + 'v2.3' +]; + +export default class Glance { + + /** + * This class provides direct, idempotent, low-level access to the Glance API of a specific + * cloud. The constructor requires that you provide a specific glance interface endpoint + * descriptor, as received from keystone's catalog list. + * + * @example + * { + * region_id: "RegionOne", + * url: "http://127.0.0.1:9292", + * region: "RegionOne", + * interface: "admin", + * id: "0b8b5f0f14904136ab5a4f83f27ec49a" + * } + * @param {{}} endpointConfig The configuration element for a specific glance endpoint. + */ + constructor (endpointConfig) { + // Sanity checks. + if (!endpointConfig || !endpointConfig.url) { + throw new Error('An endpoint configuration is required.'); + } + // Clone the config, so that this instance is immutable + // at runtime (no modifying the config after the fact). + this._config = Object.assign({}, endpointConfig); + this.http = new Http(); + } + + /** + * Retrieve all the API versions available. + * + * @returns {Promise.} A promise that will resolve with the list of API versions. + */ + versions () { + return this.http + .httpGet(this._config.url) + .then((response) => response.json()) + .then((body) => body.versions); + } + + /** + * Retrieve the API version declaration that is currently in use by this glance API. + * + * @returns {Promise.} A promise that will resolve with the specific API version. + */ + version () { + return this + .versions() + .then((versions) => { + const version = versions.find((element) => { + return supportedGlanceVersions.indexOf(element.id) > -1; + }); + if (version) { + return version; + } + throw new Error("No supported Glance API version available."); + }); + } + + /** + * Return the root API endpoint for the current supported glance version. + * + * @returns {Promise.|*} A promise which will resolve with the endpoint URL string. + */ + serviceEndpoint () { + if (!this._endpointPromise) { + this._endpointPromise = this.version() + .then((version) => { + if (version.links) { + for (let i = 0; i < version.links.length; i++) { + let link = version.links[i]; + if (link.rel === 'self' && link.href) { + return link.href; + } + } + } + throw new Error("No service endpoint discovered."); + }); + } + return this._endpointPromise; + } +} diff --git a/test/functional/glanceTest.js b/test/functional/glanceTest.js new file mode 100644 index 0000000..386eeb6 --- /dev/null +++ b/test/functional/glanceTest.js @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2016 Hewlett Packard Enterprise Development L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See + * the License for the specific language governing permissions and limitations + * under the License. + */ + +import config from "./helpers/cloudsConfig"; +import Version from '../../src/util/version'; +import Glance from "../../src/glance"; +import Keystone from "../../src/keystone"; +import log from 'loglevel'; + +log.setLevel("DEBUG"); + +describe("Glance", () => { + // Create a keystone instance and extract the glance API endpoint. + let devstackConfig = config.clouds.devstack; + let keystone = new Keystone(devstackConfig); + let tokenPromise = keystone.tokenIssue(); + + let configPromise = tokenPromise + .then((token) => keystone.catalogList(token)) + .then((catalog) => catalog.find((entry) => entry.name === 'glance')) + .then((entry) => entry.endpoints.find((endpoint) => endpoint.interface === 'public')); + + describe("versions()", () => { + it("should return a list of all versions available on this clouds' glance", (done) => { + configPromise + .then((config) => new Glance(config)) + .then((glance) => glance.versions()) + .then((versions) => { + // Quick sanity check. + expect(versions.length > 0).toBeTruthy(); + done(); + }) + .catch((error) => done.fail(error)); + }); + }); + + describe("version()", () => { + + const supportedApiVersions = [ + new Version('image 2.3') + ]; + + /** + * This test acts as a canary, to inform the SDK developers that the Glance API + * has changed in a significant way. + */ + it("should return a supported version.", (done) => { + configPromise + .then((config) => new Glance(config)) + .then((glance) => glance.version()) + .then((version) => { + + // Quick sanity check. + const apiVersion = new Version('image', version.id); + + for (let i = 0; i < supportedApiVersions.length; i++) { + let supportedVersion = supportedApiVersions[i]; + if (apiVersion.equals(supportedVersion)) { + done(); + return; + } + } + fail("Current devstack glance version is not supported."); + done(); + }) + .catch((error) => done.fail(error)); + }); + }); +}); diff --git a/test/unit/glanceTest.js b/test/unit/glanceTest.js new file mode 100644 index 0000000..1a4d1a4 --- /dev/null +++ b/test/unit/glanceTest.js @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2016 Hewlett Packard Enterprise Development L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See + * the License for the specific language governing permissions and limitations + * under the License. + */ + +import Glance from '../../src/glance.js'; +import * as mockData from './helpers/data/glance'; +import fetchMock from 'fetch-mock'; + +describe('Glance', () => { + + afterEach(fetchMock.restore); + + it('should export a class', () => { + const glance = new Glance(mockData.config); + expect(glance).toBeDefined(); + }); + + it('should throw an error for an empty config', () => { + try { + const glance = new Glance(); + glance.versions(); + } catch (e) { + expect(e.message).toEqual('An endpoint configuration is required.'); + } + }); + + describe("versions()", () => { + it("Should return a list of all versions available on this clouds' glance", (done) => { + const glance = new Glance(mockData.config); + + fetchMock.mock(mockData.root()); + + glance.versions() + .then((versions) => { + // Quick sanity check. + expect(versions.length).toBe(6); + done(); + }) + .catch((error) => done.fail(error)); + }); + + it("Should NOT cache its results", (done) => { + const glance = new Glance(mockData.config); + const mockOptions = mockData.root(); + + fetchMock.mock(mockOptions); + + glance.versions() + .then(() => { + // Validate that the mock has only been invoked once + expect(fetchMock.calls(mockOptions.name).length).toEqual(1); + return glance.versions(); + }) + .then(() => { + expect(fetchMock.calls(mockOptions.name).length).toEqual(2); + done(); + }) + .catch((error) => done.fail(error)); + }); + }); + + describe("version()", () => { + + it("Should return a supported version of the glance API.", (done) => { + const glance = new Glance(mockData.config); + + fetchMock.mock(mockData.root()); + + glance.version() + .then((version) => { + expect(version.id).toEqual('v2.3'); + done(); + }) + .catch((error) => done.fail(error)); + }); + + it("Should throw an exception if no supported version is found.", (done) => { + const glance = new Glance(mockData.config); + + // Build an invalid mock object. + const mockOptions = mockData.root(); + mockOptions.response.versions.shift(); + + fetchMock.mock(mockOptions); + + glance.version() + .then((response) => done.fail(response)) + .catch((error) => { + expect(error).not.toBeNull(); + done(); + }); + }); + + it("Should NOT cache its results", (done) => { + const glance = new Glance(mockData.config); + const mockOptions = mockData.root(); + fetchMock.mock(mockOptions); + + glance.version() + .then(() => { + // Validate that the mock has only been invoked once + expect(fetchMock.calls(mockOptions.name).length).toEqual(1); + return glance.version(); + }) + .then(() => { + expect(fetchMock.calls(mockOptions.name).length).toEqual(2); + done(); + }) + .catch((error) => done.fail(error)); + }); + }); + + describe("serviceEndpoint()", () => { + + it("Should return a valid endpoint to the glance API.", (done) => { + const glance = new Glance(mockData.config); + + fetchMock.mock(mockData.root()); + + glance.serviceEndpoint() + .then((endpoint) => { + expect(endpoint).toEqual('http://192.168.99.99:9292/v2/'); + done(); + }) + .catch((error) => done.fail(error)); + }); + + it("Should throw an exception if no endpoint is provided.", (done) => { + const glance = new Glance(mockData.config); + + // Build an exception payload. + const mockOptions = mockData.root(); + mockOptions.response.versions[0].links = []; + fetchMock.mock(mockOptions); + + glance.serviceEndpoint() + .then((response) => done.fail(response)) + .catch((error) => { + expect(error).not.toBeNull(); + done(); + }); + }); + + it("Should throw an exception if no links array exists.", (done) => { + const glance = new Glance(mockData.config); + + // Build an exception payload. + const mockOptions = mockData.root(); + delete mockOptions.response.versions[0].links; + fetchMock.mock(mockOptions); + + glance.serviceEndpoint() + .then((response) => done.fail(response)) + .catch((error) => { + expect(error).not.toBeNull(); + done(); + }); + }); + + it("Should cache its results", (done) => { + const glance = new Glance(mockData.config); + const mockOptions = mockData.root(); + fetchMock.mock(mockOptions); + + glance.serviceEndpoint() + .then(() => { + // Validate that the mock has only been invoked once + expect(fetchMock.calls(mockOptions.name).length).toEqual(1); + return glance.serviceEndpoint(); + }) + .then(() => { + expect(fetchMock.calls(mockOptions.name).length).toEqual(1); + done(); + }) + .catch((error) => done.fail(error)); + }); + }); +}); diff --git a/test/unit/helpers/data/glance.js b/test/unit/helpers/data/glance.js new file mode 100644 index 0000000..ebb4ad4 --- /dev/null +++ b/test/unit/helpers/data/glance.js @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2016 Hewlett Packard Enterprise Development L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy + * of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See + * the License for the specific language governing permissions and limitations + * under the License. + */ + +/** + * This file contains test data for fetchMock, to simplify bootstrapping of unit tests for + * keystone. Most of these are functions, as FetchMock does not perform a safe clone of the + * instances, and may accidentally modify them at runtime. + */ + +/** + * Mock cloud configuration that matches our test data below. This is not a full clouds.yaml + * format, rather just the subsection pointing to a particular cloud. + */ +const glanceConfig = { + region_id: "RegionOne", + url: "http://192.168.99.99:9292/", + region: "RegionOne", + interface: "public", + id: "0b8b5f0f14904136ab5a4f83f27ec49a" +}; + +/** + * Build a new FetchMock configuration for the root endpoint. + * + * @returns {{}} A full FetchMock configuration for Glance's Root Resource. + */ +function rootResponse () { + return { + method: 'GET', + matcher: 'http://192.168.99.99:9292/', + response: { + versions: [ + { + status: "CURRENT", + id: "v2.3", + links: [ + { + href: "http://192.168.99.99:9292/v2/", + rel: "self" + } + ] + }, + { + status: "SUPPORTED", + id: "v2.2", + links: [ + { + href: "http://192.168.99.99:9292/v2/", + rel: "self" + } + ] + }, + { + status: "SUPPORTED", + id: "v2.1", + links: [ + { + href: "http://192.168.99.99:9292/v2/", + rel: "self" + } + ] + }, + { + status: "SUPPORTED", + id: "v2.0", + links: [ + { + href: "http://192.168.99.99:9292/v2/", + rel: "self" + } + ] + }, + { + status: "SUPPORTED", + id: "v1.1", + links: [ + { + href: "http://192.168.99.99:9292/v1/", + rel: "self" + } + ] + }, + { + status: "SUPPORTED", + id: "v1.0", + links: [ + { + href: "http://192.168.99.99:9292/v1/", + rel: "self" + } + ] + } + ] + } + }; +} + +export { + glanceConfig as config, + rootResponse as root +}; diff --git a/vagrant.sh b/vagrant.sh index 7f1c243..9de9fdf 100644 --- a/vagrant.sh +++ b/vagrant.sh @@ -46,6 +46,10 @@ RECLONE=True [[post-config|\$KEYSTONE_CONF]] [cors] allowed_origin=http://localhost:9876 + +[[post-config|\$GLANCE_API_CONF]] +[cors] +allowed_origin=http://localhost:9876 EOL # Start devstack.