Files
xen-orchestra/packages/xen-api/src/index.js

722 lines
18 KiB
JavaScript
Raw Normal View History

2015-03-31 18:44:33 +02:00
import Bluebird, {promisify} from 'bluebird'
2015-04-10 15:33:39 +02:00
import Collection from 'xo-collection'
2015-03-31 18:44:33 +02:00
import createDebug from 'debug'
2015-06-22 16:19:32 +02:00
import filter from 'lodash.filter'
2015-04-10 15:33:39 +02:00
import forEach from 'lodash.foreach'
2015-05-14 14:39:36 +02:00
import isArray from 'lodash.isarray'
import isObject from 'lodash.isobject'
2015-06-29 16:14:00 +02:00
import kindOf from 'kindof'
2015-05-14 14:39:36 +02:00
import map from 'lodash.map'
2015-06-29 16:14:00 +02:00
import ms from 'ms'
2015-04-10 15:33:39 +02:00
import startsWith from 'lodash.startswith'
import {BaseError} from 'make-error'
2015-03-31 18:44:33 +02:00
import {
createClient as createXmlRpcClient,
createSecureClient as createSecureXmlRpcClient
} from 'xmlrpc'
import {EventEmitter} from 'events'
2015-04-10 15:33:39 +02:00
const debug = createDebug('xen-api')
2015-03-31 18:44:33 +02:00
// ===================================================================
function invoke (fn) {
const n = arguments.length - 1
if (!n) {
return fn()
}
fn = arguments[n]
const args = new Array(n)
for (let i = 0; i < n; ++i) {
args[i] = arguments[i]
}
return fn.apply(undefined, args)
}
// ===================================================================
2015-03-31 18:44:33 +02:00
// http://www.gnu.org/software/libc/manual/html_node/Error-Codes.html
const NETWORK_ERRORS = {
// Connection has been closed outside of our control.
ECONNRESET: true,
// Connection has been aborted locally.
ECONNABORTED: true,
// Host is up but refuses connection (typically: no such service).
ECONNREFUSED: true,
// TODO: ??
EINVAL: true,
// Host is not reachable (does not respond).
2015-04-10 16:40:45 +02:00
EHOSTUNREACH: true,
2015-03-31 18:44:33 +02:00
// Connection configured timed out has been reach.
ETIMEDOUT: true
}
2015-06-17 15:50:52 +02:00
const isNetworkError = ({code}) => NETWORK_ERRORS[code]
2015-03-31 18:44:33 +02:00
// -------------------------------------------------------------------
const XAPI_NETWORK_ERRORS = {
HOST_STILL_BOOTING: true,
HOST_HAS_NO_MANAGEMENT_IP: true
}
2015-06-17 15:50:52 +02:00
const isXapiNetworkError = ({code}) => XAPI_NETWORK_ERRORS[code]
2015-03-31 18:44:33 +02:00
// -------------------------------------------------------------------
2015-06-17 15:50:52 +02:00
const areEventsLost = ({code}) => code === 'EVENTS_LOST'
2015-03-31 18:44:33 +02:00
2015-06-17 15:50:52 +02:00
const isHostSlave = ({code}) => code === 'HOST_IS_SLAVE'
2015-04-10 16:00:20 +02:00
2015-06-22 16:19:32 +02:00
const isMethodUnknown = ({code}) => code === 'MESSAGE_METHOD_UNKNOWN'
2015-06-17 15:50:52 +02:00
const isSessionInvalid = ({code}) => code === 'SESSION_INVALID'
2015-04-10 16:00:20 +02:00
2015-03-31 18:44:33 +02:00
// -------------------------------------------------------------------
2015-04-10 15:33:39 +02:00
class XapiError extends BaseError {
2015-06-17 15:39:33 +02:00
constructor ([code, ...params]) {
super(`${code}(${params.join(', ')})`)
2015-03-31 18:44:33 +02:00
2015-06-17 15:39:33 +02:00
this.code = code
this.params = params
2015-03-31 18:44:33 +02:00
}
}
2015-05-05 13:45:51 +02:00
export const wrapError = error => new XapiError(error)
2015-03-31 18:44:33 +02:00
// ===================================================================
const URL_RE = /^(?:(http(s)?:)\/*)?([^/]+?)(?::([0-9]+))?\/?$/
2015-03-31 18:44:33 +02:00
function parseUrl (url) {
const matches = URL_RE.exec(url)
if (!matches) {
throw new Error('invalid URL: ' + url)
}
let [, protocol, isSecure, hostname, port] = matches
2015-04-13 16:16:30 +02:00
if (!protocol) {
protocol = 'https:'
2015-04-13 16:16:30 +02:00
isSecure = true
} else {
isSecure = Boolean(isSecure)
2015-04-13 16:16:30 +02:00
}
2015-03-31 18:44:33 +02:00
return {
isSecure,
protocol, hostname, port,
path: '/json',
pathname: '/json'
2015-03-31 18:44:33 +02:00
}
}
// -------------------------------------------------------------------
2016-03-09 17:41:55 +01:00
const SPECIAL_CHARS = {
'\r': '\\r',
'\t': '\\t'
2016-03-09 17:41:55 +01:00
}
const SPECIAL_CHARS_RE = new RegExp(
Object.keys(SPECIAL_CHARS).join('|'),
'g'
)
const parseResult = invoke(() => {
const parseJson = JSON.parse
return (result) => {
2016-03-09 17:41:55 +01:00
const status = result.Status
// Return the plain result if it does not have a valid XAPI
// format.
if (!status) {
return result
}
if (status !== 'Success') {
throw wrapError(result.ErrorDescription)
}
const value = result.Value
// XAPI returns an empty string (invalid JSON) for an empty
// result.
if (!value) {
return ''
}
try {
return parseJson(value)
} catch (error) {
2016-03-09 17:41:55 +01:00
// XAPI JSON sometimes contains invalid characters.
if (error instanceof SyntaxError) {
let replaced
const fixedValue = value.replace(SPECIAL_CHARS_RE, (match) => {
replaced = true
return SPECIAL_CHARS[match]
})
if (replaced) {
return parseJson(fixedValue)
}
}
throw error
}
}
})
// -------------------------------------------------------------------
const {
create: createObject,
defineProperties,
2015-06-23 09:17:42 +02:00
defineProperty,
2015-12-16 14:15:37 +01:00
freeze: freezeObject,
prototype: { toString }
} = Object
2015-04-10 16:45:40 +02:00
const noop = () => {}
const isString = invoke(toString.call(''), tag =>
2015-12-16 14:15:37 +01:00
value => toString.call(value) === tag
)
2015-12-16 14:15:37 +01:00
// -------------------------------------------------------------------
2015-05-26 16:35:05 +02:00
let getNotConnectedPromise = function () {
const promise = Bluebird.reject(new Error('not connected'))
2015-04-13 16:16:30 +02:00
2015-05-26 16:35:05 +02:00
// Does nothing but avoid a Bluebird message error.
promise.catch(noop)
getNotConnectedPromise = () => promise
return promise
}
2015-04-13 16:16:30 +02:00
2015-06-22 23:33:52 +02:00
// -------------------------------------------------------------------
2015-03-31 18:44:33 +02:00
2015-12-18 11:23:24 +01:00
const OPAQUE_REF_PREFIX = 'OpaqueRef:'
2015-12-16 14:15:37 +01:00
const isOpaqueRef = value => isString(value) && startsWith(value, OPAQUE_REF_PREFIX)
2015-05-14 14:39:36 +02:00
2015-06-23 09:13:43 +02:00
// -------------------------------------------------------------------
const isReadOnlyCall = invoke(/^[^.]+\.get_/, RE => (method, args) => (
args.length === 1 &&
isOpaqueRef(args[0]) &&
RE.test(method)
))
// -------------------------------------------------------------------
2015-12-16 14:19:30 +01:00
const getKey = o => o.$id
// -------------------------------------------------------------------
2015-06-23 09:17:42 +02:00
const EMPTY_ARRAY = freezeObject([])
2015-06-23 09:13:43 +02:00
2015-05-14 14:39:36 +02:00
// ===================================================================
const MAX_TRIES = 5
// -------------------------------------------------------------------
2015-03-31 18:44:33 +02:00
export class Xapi extends EventEmitter {
constructor (opts) {
super()
this._url = parseUrl(opts.url)
this._auth = opts.auth
2015-05-26 16:35:05 +02:00
this._sessionId = getNotConnectedPromise()
2015-03-31 18:44:33 +02:00
this._init()
this._pool = null
this._objectsByRefs = createObject(null)
this._objectsByRefs['OpaqueRef:NULL'] = null
2015-12-16 14:19:30 +01:00
const objects = this._objects = new Collection()
objects.getKey = getKey
2015-04-10 15:33:39 +02:00
2016-04-14 17:55:41 +02:00
this._debounce = opts.debounce == null
? 200
: opts.debounce
2015-03-31 18:44:33 +02:00
this._fromToken = ''
2015-04-10 16:41:17 +02:00
this.on('connected', this._watchEvents)
this.on('disconnected', () => {
this._fromToken = ''
2015-12-16 14:19:30 +01:00
objects.clear()
2015-04-10 16:41:17 +02:00
})
2015-09-14 16:01:46 +02:00
this._readOnly = Boolean(opts.readOnly)
2015-03-31 18:44:33 +02:00
}
get readOnly () {
return this._readOnly
}
set readOnly (ro) {
this._readOnly = Boolean(ro)
}
2015-04-13 17:38:07 +02:00
get sessionId () {
if (this.status !== 'connected') {
throw new Error('sessionId is only available when connected')
}
return this._sessionId.value()
}
2015-04-13 16:16:30 +02:00
get status () {
const {_sessionId: sessionId} = this
if (sessionId.isFulfilled()) {
return 'connected'
}
if (sessionId.isPending()) {
return 'connecting'
}
return 'disconnected'
}
2015-04-10 16:00:20 +02:00
get _humanId () {
return `${this._auth.user}@${this._url.hostname}`
2015-04-10 16:00:20 +02:00
}
2015-04-10 16:41:43 +02:00
connect () {
2015-04-13 16:16:30 +02:00
const {status} = this
if (status === 'connected') {
return Bluebird.reject(new Error('already connected'))
}
if (status === 'connecting') {
return Bluebird.reject(new Error('already connecting'))
}
this._sessionId = this._transportCall('session.login_with_password', [
this._auth.user,
this._auth.password
])
return this._sessionId.then(() => {
debug('%s: connected', this._humanId)
this.emit('connected')
})
2015-04-10 16:41:43 +02:00
}
disconnect () {
2015-04-13 16:16:30 +02:00
const {status} = this
if (status === 'disconnected') {
return Promise.reject(new Error('already disconnected'))
2015-04-13 16:16:30 +02:00
}
if (status === 'connecting') {
return this._sessionId.cancel().catch(Bluebird.CancellationError, () => {
debug('%s: disconnected', this._humanId)
this.emit('disconnected')
})
}
2015-05-26 16:35:05 +02:00
this._sessionId = getNotConnectedPromise()
2015-04-13 16:16:30 +02:00
return Bluebird.resolve().then(() => {
debug('%s: disconnected', this._humanId)
this.emit('disconnected')
})
2015-04-10 16:41:43 +02:00
}
2015-03-31 18:44:33 +02:00
// High level calls.
call (method, ...args) {
return this._readOnly && !isReadOnlyCall(method, args)
2015-09-14 16:01:46 +02:00
? Promise.reject(new Error(`cannot call ${method}() in read only mode`))
: this._sessionCall(method, args)
2015-03-31 18:44:33 +02:00
}
2015-05-06 14:06:17 +02:00
// Nice getter which returns the object for a given $id (internal to
// this lib), UUID (unique identifier that some objects have) or
// opaque reference (internal to XAPI).
getObject (idOrUuidOrRef, defaultValue) {
const object = isString(idOrUuidOrRef)
? (
// if there is an UUID, it is also the $id.
this._objects.all[idOrUuidOrRef] ||
this._objectsByRefs[idOrUuidOrRef]
)
: this._objects.all[idOrUuidOrRef.$id]
2015-05-06 14:06:17 +02:00
if (object) return object
if (arguments.length > 1) return defaultValue
throw new Error('there is not object can be matched to ' + idOrUuidOrRef)
}
2015-05-06 14:06:25 +02:00
// Returns the object for a given opaque reference (internal to
// XAPI).
getObjectByRef (ref, defaultValue) {
2015-05-06 14:06:25 +02:00
const object = this._objectsByRefs[ref]
2015-05-06 14:06:25 +02:00
if (object) return object
2015-05-06 14:06:25 +02:00
if (arguments.length > 1) return defaultValue
throw new Error('there is no object with the ref ' + ref)
}
2015-05-06 14:06:25 +02:00
// Returns the object for a given UUID (unique identifier that some
// objects have).
getObjectByUuid (uuid, defaultValue) {
2015-05-06 14:06:25 +02:00
// Objects ids are already UUIDs if they have one.
const object = this._objects.all[uuid]
2015-05-06 14:06:25 +02:00
if (object) return object
2015-05-06 14:06:25 +02:00
if (arguments.length > 1) return defaultValue
throw new Error('there is no object with the UUID ' + uuid)
}
2015-04-13 13:27:29 +02:00
get pool () {
return this._pool
}
2015-04-10 15:33:39 +02:00
get objects () {
return this._objects
}
2015-03-31 18:44:33 +02:00
// Medium level call: handle session errors.
_sessionCall (method, args) {
2015-04-10 15:33:39 +02:00
if (startsWith(method, 'session.')) {
return Bluebird.reject(
new Error('session.*() methods are disabled from this interface')
)
}
2015-04-13 16:16:30 +02:00
return this._sessionId.then((sessionId) => {
2015-03-31 18:44:33 +02:00
return this._transportCall(method, [sessionId].concat(args))
2015-06-29 16:14:00 +02:00
}, error => {
debug('%s: %s(...) =!> NOT CONNECTED', this._humanId, method)
throw error
2015-03-31 18:44:33 +02:00
}).catch(isSessionInvalid, () => {
// XAPI is sometimes reinitialized and sessions are lost.
// Try to login again.
2015-04-10 16:00:20 +02:00
debug('%s: the session has been reinitialized', this._humanId)
2015-05-26 16:35:05 +02:00
this._sessionId = getNotConnectedPromise()
2015-06-19 15:15:23 +02:00
return this.connect().then(() => this._sessionCall(method, args))
2015-03-31 18:44:33 +02:00
})
}
// Low level call: handle transport errors.
2015-06-29 16:14:00 +02:00
_transportCall (method, args, startTime = Date.now(), tries = 1) {
return this._rawCall(method, args)
.catch(isNetworkError, isXapiNetworkError, error => {
debug('%s: network error %s', this._humanId, error.code)
2015-03-31 18:44:33 +02:00
if (!(tries < MAX_TRIES)) {
debug('%s too many network errors (%s), give up', this._humanId, tries)
2015-03-31 18:44:33 +02:00
throw error
2015-03-31 18:44:33 +02:00
}
// TODO: ability to cancel the connection
// TODO: ability to force immediate reconnection
// TODO: implement back-off
return Bluebird.delay(5e3).then(() => {
// TODO: handling not responding host.
2015-06-29 16:14:00 +02:00
return this._transportCall(method, args, startTime, tries + 1)
})
2015-03-31 18:44:33 +02:00
})
2015-04-10 16:00:20 +02:00
.catch(isHostSlave, ({params: [master]}) => {
debug('%s: host is slave, attempting to connect at %s', this._humanId, master)
2015-08-10 15:51:45 +02:00
this._url.hostname = master
2015-04-10 16:00:20 +02:00
this._init()
2015-06-29 16:14:00 +02:00
return this._transportCall(method, args, startTime)
}).then(
result => {
debug(
'%s: %s(...) [%s] ==> %s',
this._humanId,
method,
ms(Date.now() - startTime),
kindOf(result)
)
return result
},
error => {
debug(
'%s: %s(...) [%s] =!> %s',
this._humanId,
method,
ms(Date.now() - startTime),
error
)
throw error
}
)
}
2015-04-10 16:41:59 +02:00
// Lowest level call: do not handle any errors.
_rawCall (method, args) {
return this._xmlRpcCall(method, args)
2015-08-28 08:51:38 +02:00
.then(
parseResult,
error => {
2015-12-02 17:31:13 +01:00
// Unwrap error if necessary.
if (error instanceof Bluebird.OperationalError) {
error = error.cause
2015-12-02 17:31:13 +01:00
}
2015-08-28 08:51:38 +02:00
if (error.res) {
console.error(
'XML-RPC Error: %s (response status %s)',
error.message,
error.res.statusCode
)
console.error('%s', error.body)
}
2015-08-28 08:51:38 +02:00
throw error
}
)
2015-04-10 16:41:43 +02:00
.cancellable()
2015-03-31 18:44:33 +02:00
}
_init () {
const {isSecure, hostname, port, path} = this._url
2015-03-31 18:44:33 +02:00
2015-10-23 16:53:05 +02:00
const client = (isSecure
? createSecureXmlRpcClient
: createXmlRpcClient
2015-03-31 18:44:33 +02:00
)({
hostname,
2015-03-31 18:44:33 +02:00
port,
path,
2015-03-31 18:44:33 +02:00
rejectUnauthorized: false,
timeout: 10
})
this._xmlRpcCall = promisify(client.methodCall, client)
}
2015-06-22 16:19:32 +02:00
_addObject (type, ref, object) {
const {_objectsByRefs: objectsByRefs} = this
2015-06-22 23:33:52 +02:00
// Creates resolved properties.
forEach(object, function resolveObject (value, key, object) {
if (isArray(value)) {
2015-06-23 09:13:43 +02:00
if (!value.length) {
// If the array is empty, it isn't possible to be sure that
// it is not supposed to contain links, therefore, in
// benefice of the doubt, a resolved property is defined.
defineProperty(object, '$' + key, {
value: EMPTY_ARRAY
})
// Minor memory optimization, use the same empty array for
// everyone.
object[key] = EMPTY_ARRAY
2015-12-18 11:28:58 +01:00
} else if (isOpaqueRef(value[0])) {
2015-06-23 09:13:43 +02:00
// This is an array of refs.
defineProperty(object, '$' + key, {
2015-06-23 09:17:42 +02:00
get: () => freezeObject(map(value, (ref) => objectsByRefs[ref]))
2015-06-23 09:13:43 +02:00
})
2015-06-23 09:17:42 +02:00
freezeObject(value)
2015-06-22 23:33:52 +02:00
}
} else if (isObject(value)) {
forEach(value, resolveObject)
2015-06-23 09:17:42 +02:00
freezeObject(value)
2015-12-16 14:15:37 +01:00
} else if (isOpaqueRef(value)) {
2015-06-22 23:33:52 +02:00
defineProperty(object, '$' + key, {
2015-06-23 09:13:43 +02:00
get: () => objectsByRefs[value]
2015-06-22 23:33:52 +02:00
})
}
})
2015-06-22 23:33:52 +02:00
// All custom properties are read-only and non enumerable.
defineProperties(object, {
$id: { value: object.uuid || ref },
$pool: { get: () => this._pool },
$ref: { value: ref },
$type: { value: type }
2015-04-10 15:33:39 +02:00
})
2015-06-22 16:19:32 +02:00
2015-06-23 09:17:42 +02:00
// Finally freezes the object.
freezeObject(object)
const objects = this._objects
// An object's UUID can change during its life.
const prev = objectsByRefs[ref]
let prevUuid
if (prev && (prevUuid = prev.uuid) && prevUuid !== object.uuid) {
objects.remove(prevUuid)
}
2015-06-22 16:19:32 +02:00
this._objects.set(object)
objectsByRefs[ref] = object
if (type === 'pool') {
this._pool = object
}
2015-04-10 15:33:39 +02:00
}
2015-06-22 16:19:32 +02:00
_removeObject (ref) {
const {_objectsByRefs: objectsByRefs} = this
2015-03-31 18:44:33 +02:00
2015-06-22 16:19:32 +02:00
const object = objectsByRefs[ref]
2015-04-10 15:33:39 +02:00
2015-06-22 16:19:32 +02:00
if (object) {
this._objects.unset(object.$id)
2015-06-22 16:19:32 +02:00
delete objectsByRefs[ref]
}
}
2015-04-10 15:33:39 +02:00
2015-06-22 16:19:32 +02:00
_processEvents (events) {
forEach(events, event => {
const {operation: op} = event
2015-04-10 15:33:39 +02:00
2015-06-22 16:19:32 +02:00
const {ref} = event
if (op === 'del') {
this._removeObject(ref)
} else {
this._addObject(event.class, ref, event.snapshot)
}
})
}
2015-04-10 15:33:39 +02:00
2015-06-22 16:19:32 +02:00
_watchEvents () {
const loop = invoke(() => {
const onSuccess = ({token, events}) => {
this._fromToken = token
this._processEvents(events)
2015-04-10 15:33:39 +02:00
const debounce = this._debounce
2016-04-14 17:55:41 +02:00
return debounce != null
? Bluebird.delay(debounce).then(loop)
: loop()
}
const onFailure = error => {
if (areEventsLost(error)) {
this._fromToken = ''
this._objects.clear()
return loop()
}
throw error
}
return () => this._sessionCall('event.from', [
['*'],
this._fromToken,
1e3 + 0.1 // Force float.
]).then(onSuccess, onFailure)
})
return loop().catch(error => {
if (
isMethodUnknown(error) ||
// If the server failed, it is probably due to an excessively
// large response.
// Falling back to legacy events watch should be enough.
error && error.res && error.res.statusCode === 500
) {
return this._watchEventsLegacy()
}
if (!(error instanceof Bluebird.CancellationError)) {
throw error
}
})
2015-03-31 18:44:33 +02:00
}
2015-06-22 16:19:32 +02:00
// This method watches events using the legacy `event.next` XAPI
// methods.
//
// It also has to manually get all objects first.
_watchEventsLegacy () {
const getAllObjects = () => {
return this._sessionCall('system.listMethods', []).then(methods => {
2015-06-22 16:19:32 +02:00
// Uses introspection to determine the methods to use to get
// all objects.
const getAllRecordsMethods = filter(
methods,
::/\.get_all_records$/.test
)
return Promise.all(map(
getAllRecordsMethods,
2015-10-23 16:53:42 +02:00
method => this._sessionCall(method, []).then(
objects => {
const type = method.slice(0, method.indexOf('.')).toLowerCase()
forEach(objects, (object, ref) => {
this._addObject(type, ref, object)
})
},
error => {
if (error.code !== 'MESSAGE_REMOVED') {
throw error
}
}
)
2015-06-22 16:19:32 +02:00
))
})
}
const watchEvents = invoke(() => {
const loop = invoke(() => {
const onSuccess = events => {
this._processEvents(events)
const debounce = this._debounce
2016-04-14 17:55:41 +02:00
return debounce == null
? loop()
: Bluebird.delay(debounce).then(loop)
}
const onFailure = error => {
if (areEventsLost(error)) {
return this._sessionCall('event.unregister', [ ['*'] ]).then(watchEvents)
}
2015-06-22 16:19:32 +02:00
throw error
}
return () => this._sessionCall('event.next', []).then(onSuccess, onFailure)
})
2015-06-22 16:19:32 +02:00
return () => this._sessionCall('event.register', [ ['*'] ]).then(loop)
})
2015-06-22 16:19:32 +02:00
return getAllObjects().then(watchEvents)
}
2015-03-31 18:44:33 +02:00
}
// ===================================================================
// The default value is a factory function.
export const createClient = (opts) => new Xapi(opts)