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-04-10 15:33:39 +02:00
|
|
|
import forEach from 'lodash.foreach'
|
|
|
|
|
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
|
|
|
|
|
|
|
|
// ===================================================================
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isNetworkError = (error) => NETWORK_ERRORS[error.code]
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const XAPI_NETWORK_ERRORS = {
|
|
|
|
|
HOST_STILL_BOOTING: true,
|
|
|
|
|
HOST_HAS_NO_MANAGEMENT_IP: true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isXapiNetworkError = (error) => XAPI_NETWORK_ERRORS[error.code]
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const areEventsLost = (error) => error.code === 'EVENTS_LOST'
|
|
|
|
|
|
2015-04-10 16:40:45 +02:00
|
|
|
const isHostSlave = (error) => error.code === 'HOST_IS_SLAVE'
|
2015-04-10 16:00:20 +02:00
|
|
|
|
|
|
|
|
const isSessionInvalid = (error) => error.code === 'SESSION_INVALID'
|
|
|
|
|
|
2015-03-31 18:44:33 +02:00
|
|
|
// -------------------------------------------------------------------
|
|
|
|
|
|
2015-04-10 15:33:39 +02:00
|
|
|
class XapiError extends BaseError {
|
2015-03-31 18:44:33 +02:00
|
|
|
constructor (error) {
|
2015-04-10 15:33:39 +02:00
|
|
|
super(error[0])
|
2015-03-31 18:44:33 +02:00
|
|
|
|
|
|
|
|
this.code = error[0]
|
|
|
|
|
this.params = error.slice(1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===================================================================
|
|
|
|
|
|
2015-04-13 14:13:56 +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)
|
|
|
|
|
}
|
|
|
|
|
|
2015-04-13 16:16:30 +02:00
|
|
|
const [, protocol, , host, port] = matches
|
|
|
|
|
let [, , isSecure] = matches
|
|
|
|
|
|
|
|
|
|
if (!protocol) {
|
|
|
|
|
isSecure = true
|
|
|
|
|
}
|
2015-03-31 18:44:33 +02:00
|
|
|
|
|
|
|
|
return {
|
2015-04-13 16:16:30 +02:00
|
|
|
isSecure: Boolean(isSecure),
|
2015-03-31 18:44:33 +02:00
|
|
|
host,
|
|
|
|
|
port: port !== undefined ?
|
|
|
|
|
+port :
|
|
|
|
|
isSecure ? 443 : 80
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2015-04-16 16:18:11 +02:00
|
|
|
// -------------------------------------------------------------------
|
|
|
|
|
|
2015-04-10 16:45:40 +02:00
|
|
|
const noop = () => {}
|
|
|
|
|
|
2015-04-16 16:18:11 +02:00
|
|
|
// -------------------------------------------------------------------
|
|
|
|
|
|
2015-04-13 16:16:30 +02:00
|
|
|
const notConnectedPromise = Bluebird.reject(new Error('not connected'))
|
|
|
|
|
|
|
|
|
|
// Does nothing but avoid a Bluebird message error.
|
|
|
|
|
notConnectedPromise.catch(noop)
|
|
|
|
|
|
2015-03-31 18:44:33 +02:00
|
|
|
// ===================================================================
|
|
|
|
|
|
2015-04-14 17:57:31 +02:00
|
|
|
const MAX_TRIES = 5
|
|
|
|
|
|
2015-04-16 16:18:11 +02:00
|
|
|
// -------------------------------------------------------------------
|
|
|
|
|
|
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-04-13 16:16:30 +02:00
|
|
|
this._sessionId = notConnectedPromise
|
2015-03-31 18:44:33 +02:00
|
|
|
|
|
|
|
|
this._init()
|
|
|
|
|
|
2015-04-16 16:34:09 +02:00
|
|
|
this._pool = null
|
2015-04-16 16:18:11 +02:00
|
|
|
this._objectsByRefs = Object.create(null)
|
|
|
|
|
this._objectsByRefs['OpaqueRef:NULL'] = null
|
2015-04-10 15:33:39 +02:00
|
|
|
this._objects = new Collection()
|
|
|
|
|
this._objects.getId = (object) => object.$id
|
|
|
|
|
|
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-04-13 16:16:30 +02:00
|
|
|
this._objects.clear()
|
2015-04-10 16:41:17 +02:00
|
|
|
})
|
2015-03-31 18:44:33 +02:00
|
|
|
}
|
|
|
|
|
|
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.host}`
|
|
|
|
|
}
|
|
|
|
|
|
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 Bluebird.reject('already disconnected')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (status === 'connecting') {
|
|
|
|
|
return this._sessionId.cancel().catch(Bluebird.CancellationError, () => {
|
|
|
|
|
debug('%s: disconnected', this._humanId)
|
|
|
|
|
|
|
|
|
|
this.emit('disconnected')
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._sessionId = notConnectedPromise
|
|
|
|
|
|
|
|
|
|
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.
|
2015-04-13 16:42:30 +02:00
|
|
|
call (method, ...args) {
|
2015-03-31 18:44:33 +02:00
|
|
|
return this._sessionCall(method, args)
|
|
|
|
|
}
|
|
|
|
|
|
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))
|
|
|
|
|
}).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)
|
|
|
|
|
|
|
|
|
|
this._sessionId = null
|
|
|
|
|
|
2015-03-31 18:44:33 +02:00
|
|
|
return this._sessionCall(method, args)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Low level call: handle transport errors.
|
2015-04-15 18:23:49 +02:00
|
|
|
_transportCall (method, args, tries = 1) {
|
2015-04-10 16:00:20 +02:00
|
|
|
debug('%s: %s(...)', this._humanId, method)
|
2015-03-31 18:44:33 +02:00
|
|
|
|
2015-04-15 18:23:49 +02:00
|
|
|
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
|
|
|
|
2015-04-15 18:23:49 +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
|
|
|
|
2015-04-15 18:23:49 +02:00
|
|
|
throw error
|
2015-03-31 18:44:33 +02:00
|
|
|
}
|
|
|
|
|
|
2015-04-15 18:23:49 +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.
|
|
|
|
|
|
|
|
|
|
return this._transportCall(method, args, 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)
|
|
|
|
|
|
|
|
|
|
this._url.host = master
|
|
|
|
|
this._init()
|
|
|
|
|
|
|
|
|
|
return this._transportCall(method, args)
|
|
|
|
|
})
|
2015-04-15 18:23:49 +02:00
|
|
|
}
|
2015-04-10 16:41:59 +02:00
|
|
|
|
2015-04-15 18:23:49 +02:00
|
|
|
// Lowest level call: do not handle any errors.
|
|
|
|
|
_rawCall (method, args) {
|
|
|
|
|
return this._xmlRpcCall(method, args)
|
|
|
|
|
.then(result => {
|
|
|
|
|
const {Status: status} = result
|
2015-04-14 17:57:31 +02:00
|
|
|
|
2015-04-15 18:23:49 +02:00
|
|
|
// Return the plain result if it does not have a valid XAPI
|
|
|
|
|
// format.
|
|
|
|
|
if (!status) {
|
|
|
|
|
return result
|
|
|
|
|
}
|
2015-03-31 18:44:33 +02:00
|
|
|
|
2015-04-15 18:23:49 +02:00
|
|
|
if (status === 'Success') {
|
|
|
|
|
return result.Value
|
|
|
|
|
}
|
2015-03-31 18:44:33 +02:00
|
|
|
|
2015-04-15 18:23:49 +02:00
|
|
|
throw new XapiError(result.ErrorDescription)
|
2015-03-31 18:44:33 +02:00
|
|
|
})
|
2015-04-10 16:41:43 +02:00
|
|
|
.cancellable()
|
2015-03-31 18:44:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_init () {
|
|
|
|
|
const {isSecure, host, port} = this._url
|
|
|
|
|
|
|
|
|
|
const client = (isSecure ?
|
|
|
|
|
createSecureXmlRpcClient :
|
|
|
|
|
createXmlRpcClient
|
|
|
|
|
)({
|
|
|
|
|
host,
|
|
|
|
|
port,
|
|
|
|
|
rejectUnauthorized: false,
|
|
|
|
|
timeout: 10
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
this._xmlRpcCall = promisify(client.methodCall, client)
|
|
|
|
|
}
|
|
|
|
|
|
2015-04-10 15:33:39 +02:00
|
|
|
_normalizeObject (type, ref, object) {
|
2015-04-16 16:18:11 +02:00
|
|
|
const {_objectsByRefs: objectsByRefs} = this
|
|
|
|
|
const REF_RE = /^OpaqueRef:/
|
|
|
|
|
|
|
|
|
|
forEach(object, function resolveIfLink(value, key, object) {
|
|
|
|
|
if (typeof value === 'string' && REF_RE.test(value)) {
|
|
|
|
|
Object.defineProperty(object, key, {
|
|
|
|
|
enumerable: true,
|
|
|
|
|
get: () => objectsByRefs[value]
|
|
|
|
|
})
|
|
|
|
|
} else if (typeof value === 'object') {
|
|
|
|
|
forEach(value, resolveIfLink)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2015-04-10 15:33:39 +02:00
|
|
|
object.$id = object.uuid || ref
|
|
|
|
|
object.$ref = ref
|
|
|
|
|
object.$type = type
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(object, '$pool', {
|
2015-04-16 16:18:11 +02:00
|
|
|
enumerable: true,
|
2015-04-13 13:27:29 +02:00
|
|
|
get: () => this._pool
|
2015-04-10 15:33:39 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2015-03-31 18:44:33 +02:00
|
|
|
_watchEvents () {
|
2015-04-13 16:48:01 +02:00
|
|
|
this.call(
|
|
|
|
|
'event.from', ['*'], this._fromToken, 1e3 + 0.1
|
|
|
|
|
).then(({token, events}) => {
|
2015-03-31 18:44:33 +02:00
|
|
|
this._fromToken = token
|
|
|
|
|
|
2015-04-16 16:18:11 +02:00
|
|
|
const {
|
|
|
|
|
_objects: objects,
|
|
|
|
|
_objectsByRefs: objectsByRefs
|
|
|
|
|
} = this
|
2015-04-10 15:33:39 +02:00
|
|
|
|
|
|
|
|
forEach(events, event => {
|
|
|
|
|
const {operation: op} = event
|
|
|
|
|
|
|
|
|
|
const {ref} = event
|
|
|
|
|
if (op === 'del') {
|
2015-04-16 16:18:11 +02:00
|
|
|
const objects = objectsByRefs[ref]
|
2015-04-10 15:33:39 +02:00
|
|
|
|
2015-04-16 16:18:11 +02:00
|
|
|
if (objects) {
|
|
|
|
|
objects.remove(objects.$id)
|
|
|
|
|
delete objectsByRefs[ref]
|
2015-04-10 15:33:39 +02:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const {class: type, snapshot: object} = event
|
|
|
|
|
|
|
|
|
|
this._normalizeObject(type, ref, object)
|
|
|
|
|
objects.set(object)
|
2015-04-16 16:18:11 +02:00
|
|
|
objectsByRefs[ref] = object
|
2015-04-10 15:33:39 +02:00
|
|
|
|
|
|
|
|
if (object.$type === 'pool') {
|
2015-04-13 13:27:29 +02:00
|
|
|
this._pool = object
|
2015-04-10 15:33:39 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
2015-03-31 18:44:33 +02:00
|
|
|
}).catch(areEventsLost, () => {
|
2015-04-10 15:33:39 +02:00
|
|
|
this._objects.clear()
|
2015-03-31 18:44:33 +02:00
|
|
|
}).then(() => {
|
2015-04-10 16:45:40 +02:00
|
|
|
this._watchEvents()
|
|
|
|
|
}).catch(noop)
|
2015-03-31 18:44:33 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===================================================================
|
|
|
|
|
|
|
|
|
|
// The default value is a factory function.
|
|
|
|
|
export const createClient = (opts) => new Xapi(opts)
|