Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50dc3e10e4 |
111
packages/xen-api/.USAGE2.md
Normal file
111
packages/xen-api/.USAGE2.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
```js
|
||||||
|
import { Xapi } from 'xen-api'
|
||||||
|
|
||||||
|
// bare-bones XAPI client
|
||||||
|
const xapi = new Xapi({
|
||||||
|
// URL to a host belonging to the XCP-ng/XenServer pool we want to connect to
|
||||||
|
url: 'https://xen1.company.net',
|
||||||
|
|
||||||
|
// credentials used to connect to this XAPI
|
||||||
|
auth: {
|
||||||
|
user: 'root',
|
||||||
|
password: 'important secret password',
|
||||||
|
},
|
||||||
|
|
||||||
|
// if true, only side-effects free calls will be allowed
|
||||||
|
readOnly: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ensure that the connection is working
|
||||||
|
await xapi.checkConnection()
|
||||||
|
|
||||||
|
// call a XAPI method
|
||||||
|
//
|
||||||
|
// see available methods there: https://xapi-project.github.io/xen-api/
|
||||||
|
const result = await xapi.call(
|
||||||
|
// name of the method
|
||||||
|
'VM.snapshot',
|
||||||
|
|
||||||
|
// list of params
|
||||||
|
[vm.$ref, 'My new snapshot'],
|
||||||
|
|
||||||
|
// options
|
||||||
|
{
|
||||||
|
// AbortSignal that can be used to stop the call
|
||||||
|
//
|
||||||
|
// Note: this will not stop/rollback the side-effects of the call
|
||||||
|
signal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// after a call (or checkConnection) has succeed, the following properties are available
|
||||||
|
|
||||||
|
// list of classes available on this XAPI
|
||||||
|
xapi.classes
|
||||||
|
|
||||||
|
// timestamp of the last reply from XAPI
|
||||||
|
xapi.lastReply
|
||||||
|
|
||||||
|
// pool record of this XAPI
|
||||||
|
xapi.pool
|
||||||
|
|
||||||
|
// secret identifier of the current session
|
||||||
|
//
|
||||||
|
// it might become obsolete, in that case, it will be automatically renewed by the next call
|
||||||
|
xapi.sessionId
|
||||||
|
|
||||||
|
// invalidate the session identifier
|
||||||
|
await xapi.logOut()
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { Proxy } from 'xen-api/proxy'
|
||||||
|
|
||||||
|
const proxy = new Proxy(xapi)
|
||||||
|
|
||||||
|
await proxy.VM.snapshot()
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { Events } from 'xen-api/events'
|
||||||
|
|
||||||
|
const events = new Events(xapi)
|
||||||
|
|
||||||
|
// ensure that all events until now have been received and processed
|
||||||
|
await events.barrier()
|
||||||
|
|
||||||
|
// watch events on tasks and wait for a task to finish
|
||||||
|
const task = await events.waitTask(taskRef, { signal })
|
||||||
|
|
||||||
|
// for long running actions, it's better to use an async call which will are based on tasks
|
||||||
|
const result = await events.asyncCall(method)
|
||||||
|
|
||||||
|
const stop = events.watch(
|
||||||
|
// class that we are interested in
|
||||||
|
//
|
||||||
|
// use `*` for all classes
|
||||||
|
'pool',
|
||||||
|
|
||||||
|
// called each time a new event for this class has been received
|
||||||
|
//
|
||||||
|
// https://xapi-project.github.io/xen-api/classes/event.html
|
||||||
|
event => {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// when wanting to really stop watching all events, simply remove all watchers
|
||||||
|
events.clear()
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { Cache } from 'xen-api/events'
|
||||||
|
|
||||||
|
const cache = new Cache(watcher)
|
||||||
|
|
||||||
|
const host = await cache.get('host', 'OpaqueRef:1c3f19c8-f80a-464d-9c48-a2c19d4e4fc3')
|
||||||
|
|
||||||
|
const vm = await cache.getByUuid('VM', '355ee47d-ff4c-4924-3db2-fd86ae629676')
|
||||||
|
|
||||||
|
cache.clear()
|
||||||
|
```
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
const EMPTY = 'OpaqueRef:NULL'
|
|
||||||
const PREFIX = 'OpaqueRef:'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
// Reference to use to indicate it's not pointing to an object
|
|
||||||
EMPTY,
|
|
||||||
|
|
||||||
// Whether this value is a reference (probably) pointing to an object
|
|
||||||
isNotEmpty(val) {
|
|
||||||
return val !== EMPTY && typeof val === 'string' && val.startsWith(PREFIX)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Whether this value looks like a reference
|
|
||||||
is(val) {
|
|
||||||
return (
|
|
||||||
typeof val === 'string' &&
|
|
||||||
(val.startsWith(PREFIX) ||
|
|
||||||
// 2019-02-07 - JFT: even if `value` should not be an empty string for
|
|
||||||
// a ref property, an user had the case on XenServer 7.0 on the CD VBD
|
|
||||||
// of a VM created by XenCenter
|
|
||||||
val === '' ||
|
|
||||||
// 2021-03-08 - JFT: there is an bug in XCP-ng/XenServer which leads to
|
|
||||||
// some refs to be `Ref:*` instead of being rewritten
|
|
||||||
//
|
|
||||||
// We'll consider them as empty refs in this lib to avoid issues with
|
|
||||||
// _wrapRecord.
|
|
||||||
//
|
|
||||||
// See https://github.com/xapi-project/xen-api/issues/4338
|
|
||||||
val.startsWith('Ref:'))
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { BaseError } from 'make-error'
|
|
||||||
|
|
||||||
export default class XapiError extends BaseError {
|
|
||||||
static wrap(error) {
|
|
||||||
let code, params
|
|
||||||
if (Array.isArray(error)) {
|
|
||||||
// < XenServer 7.3
|
|
||||||
;[code, ...params] = error
|
|
||||||
} else {
|
|
||||||
code = error.message
|
|
||||||
params = error.data
|
|
||||||
if (!Array.isArray(params)) {
|
|
||||||
params = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new XapiError(code, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(code, params) {
|
|
||||||
super(`${code}(${params.join(', ')})`)
|
|
||||||
|
|
||||||
this.code = code
|
|
||||||
this.params = params
|
|
||||||
|
|
||||||
// slots than can be assigned later
|
|
||||||
this.call = undefined
|
|
||||||
this.url = undefined
|
|
||||||
this.task = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import debug from 'debug'
|
|
||||||
|
|
||||||
export default debug('xen-api')
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Cancel } from 'promise-toolbox'
|
|
||||||
|
|
||||||
import XapiError from './_XapiError.mjs'
|
|
||||||
|
|
||||||
export default task => {
|
|
||||||
const { status } = task
|
|
||||||
if (status === 'cancelled') {
|
|
||||||
return Promise.reject(new Cancel('task canceled'))
|
|
||||||
}
|
|
||||||
if (status === 'failure') {
|
|
||||||
const error = XapiError.wrap(task.error_info)
|
|
||||||
error.task = task
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
if (status === 'success') {
|
|
||||||
// the result might be:
|
|
||||||
// - empty string
|
|
||||||
// - an opaque reference
|
|
||||||
// - an XML-RPC value
|
|
||||||
return Promise.resolve(task.result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
const SUFFIX = '.get_all_records'
|
|
||||||
|
|
||||||
export default method => method.endsWith(SUFFIX)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
const RE = /^[^.]+\.get_/
|
|
||||||
|
|
||||||
export default function isReadOnlyCall(method, args) {
|
|
||||||
const n = args.length
|
|
||||||
return (n === 0 || (n === 1 && typeof args[0] === 'string')) && RE.test(method)
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export default (setting, defaultValue) =>
|
|
||||||
setting === undefined
|
|
||||||
? () => defaultValue
|
|
||||||
: typeof setting === 'function'
|
|
||||||
? setting
|
|
||||||
: typeof setting === 'object'
|
|
||||||
? method => setting[method] ?? setting['*'] ?? defaultValue
|
|
||||||
: () => setting
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
const URL_RE = /^(?:(https?:)\/*)?(?:(([^:]*)(?::([^@]*))?)@)?(\[[^\]]+\]|[^:/]+)(?::([0-9]+))?(\/[^?#]*)?$/
|
|
||||||
|
|
||||||
export default url => {
|
|
||||||
const matches = URL_RE.exec(url)
|
|
||||||
if (matches === null) {
|
|
||||||
throw new Error('invalid URL: ' + url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, protocol = 'https:', auth, username = '', password = '', hostname, port, pathname = '/'] = matches
|
|
||||||
const parsedUrl = {
|
|
||||||
protocol,
|
|
||||||
hostname,
|
|
||||||
port,
|
|
||||||
pathname,
|
|
||||||
|
|
||||||
// compat with url.parse
|
|
||||||
auth,
|
|
||||||
}
|
|
||||||
if (username !== '') {
|
|
||||||
parsedUrl.username = decodeURIComponent(username)
|
|
||||||
}
|
|
||||||
if (password !== '') {
|
|
||||||
parsedUrl.password = decodeURIComponent(password)
|
|
||||||
}
|
|
||||||
return parsedUrl
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import t from 'tap'
|
|
||||||
|
|
||||||
import parseUrl from './_parseUrl.mjs'
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
'xcp.company.lan': {
|
|
||||||
hostname: 'xcp.company.lan',
|
|
||||||
pathname: '/',
|
|
||||||
protocol: 'https:',
|
|
||||||
},
|
|
||||||
'[::1]': {
|
|
||||||
hostname: '[::1]',
|
|
||||||
pathname: '/',
|
|
||||||
protocol: 'https:',
|
|
||||||
},
|
|
||||||
'http://username:password@xcp.company.lan': {
|
|
||||||
auth: 'username:password',
|
|
||||||
hostname: 'xcp.company.lan',
|
|
||||||
password: 'password',
|
|
||||||
pathname: '/',
|
|
||||||
protocol: 'http:',
|
|
||||||
username: 'username',
|
|
||||||
},
|
|
||||||
'https://username@xcp.company.lan': {
|
|
||||||
auth: 'username',
|
|
||||||
hostname: 'xcp.company.lan',
|
|
||||||
pathname: '/',
|
|
||||||
protocol: 'https:',
|
|
||||||
username: 'username',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
t.test('invalid url', function (t) {
|
|
||||||
t.throws(() => parseUrl(''))
|
|
||||||
t.end()
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const url of Object.keys(data)) {
|
|
||||||
t.test(url, function (t) {
|
|
||||||
const parsed = parseUrl(url)
|
|
||||||
for (const key of Object.keys(parsed)) {
|
|
||||||
if (parsed[key] === undefined) {
|
|
||||||
delete parsed[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.same(parsed, data[url])
|
|
||||||
t.end()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import mapValues from 'lodash/mapValues.js'
|
|
||||||
|
|
||||||
export default function replaceSensitiveValues(value, replacement) {
|
|
||||||
function helper(value, name) {
|
|
||||||
if (name === 'password' && typeof value === 'string') {
|
|
||||||
return replacement
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value !== 'object' || value === null) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.isArray(value) ? value.map(helper) : mapValues(value, helper)
|
|
||||||
}
|
|
||||||
|
|
||||||
return helper(value)
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
/* eslint-disable no-console */
|
|
||||||
import blocked from 'blocked'
|
|
||||||
import createDebug from 'debug'
|
|
||||||
import filter from 'lodash/filter.js'
|
|
||||||
import find from 'lodash/find.js'
|
|
||||||
import L from 'lodash'
|
|
||||||
import minimist from 'minimist'
|
|
||||||
import pw from 'pw'
|
|
||||||
import { asCallback, fromCallback, fromEvent } from 'promise-toolbox'
|
|
||||||
import { diff } from 'jest-diff'
|
|
||||||
import { getBoundPropertyDescriptor } from 'bind-property-descriptor'
|
|
||||||
import { start as createRepl } from 'repl'
|
|
||||||
|
|
||||||
// ===================================================================
|
|
||||||
|
|
||||||
function askPassword(prompt = 'Password: ') {
|
|
||||||
if (prompt) {
|
|
||||||
process.stdout.write(prompt)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise(resolve => {
|
|
||||||
pw(resolve)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const { getPrototypeOf, ownKeys } = Reflect
|
|
||||||
function getAllBoundDescriptors(object) {
|
|
||||||
const descriptors = { __proto__: null }
|
|
||||||
let current = object
|
|
||||||
do {
|
|
||||||
ownKeys(current).forEach(key => {
|
|
||||||
if (!(key in descriptors)) {
|
|
||||||
descriptors[key] = getBoundPropertyDescriptor(current, key, object)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} while ((current = getPrototypeOf(current)) !== null)
|
|
||||||
return descriptors
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================================================================
|
|
||||||
|
|
||||||
const usage = 'Usage: xen-api <url> [<user> [<password>]]'
|
|
||||||
|
|
||||||
export async function main(createClient) {
|
|
||||||
const opts = minimist(process.argv.slice(2), {
|
|
||||||
string: ['proxy', 'session-id', 'transport'],
|
|
||||||
boolean: ['allow-unauthorized', 'help', 'read-only', 'verbose'],
|
|
||||||
|
|
||||||
alias: {
|
|
||||||
'allow-unauthorized': 'au',
|
|
||||||
debounce: 'd',
|
|
||||||
help: 'h',
|
|
||||||
proxy: 'p',
|
|
||||||
'read-only': 'ro',
|
|
||||||
verbose: 'v',
|
|
||||||
transport: 't',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (opts.help) {
|
|
||||||
return usage
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.verbose) {
|
|
||||||
// Does not work perfectly.
|
|
||||||
//
|
|
||||||
// https://github.com/visionmedia/debug/pull/156
|
|
||||||
createDebug.enable('xen-api,xen-api:*')
|
|
||||||
}
|
|
||||||
|
|
||||||
let auth
|
|
||||||
if (opts._.length > 1) {
|
|
||||||
const [, user, password = await askPassword()] = opts._
|
|
||||||
auth = { user, password }
|
|
||||||
} else if (opts['session-id'] !== undefined) {
|
|
||||||
auth = { sessionId: opts['session-id'] }
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
const debug = createDebug('xen-api:perf')
|
|
||||||
blocked(ms => {
|
|
||||||
debug('blocked for %sms', ms | 0)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const xapi = createClient({
|
|
||||||
url: opts._[0],
|
|
||||||
allowUnauthorized: opts.au,
|
|
||||||
auth,
|
|
||||||
debounce: opts.debounce != null ? +opts.debounce : null,
|
|
||||||
httpProxy: opts.proxy,
|
|
||||||
readOnly: opts.ro,
|
|
||||||
syncStackTraces: true,
|
|
||||||
transport: opts.transport || undefined,
|
|
||||||
})
|
|
||||||
await xapi.connect()
|
|
||||||
|
|
||||||
const repl = createRepl({
|
|
||||||
prompt: `${xapi._humanId}> `,
|
|
||||||
})
|
|
||||||
|
|
||||||
{
|
|
||||||
const ctx = repl.context
|
|
||||||
ctx.xapi = xapi
|
|
||||||
|
|
||||||
ctx.diff = (a, b) => console.log('%s', diff(a, b))
|
|
||||||
ctx.find = predicate => find(xapi.objects.all, predicate)
|
|
||||||
ctx.findAll = predicate => filter(xapi.objects.all, predicate)
|
|
||||||
ctx.L = L
|
|
||||||
|
|
||||||
Object.defineProperties(ctx, getAllBoundDescriptors(xapi))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make the REPL waits for promise completion.
|
|
||||||
repl.eval = (evaluate => (cmd, context, filename, cb) => {
|
|
||||||
asCallback.call(
|
|
||||||
fromCallback(cb => {
|
|
||||||
evaluate.call(repl, cmd, context, filename, cb)
|
|
||||||
}).then(value => (Array.isArray(value) ? Promise.all(value) : value)),
|
|
||||||
cb
|
|
||||||
)
|
|
||||||
})(repl.eval)
|
|
||||||
|
|
||||||
await fromEvent(repl, 'exit')
|
|
||||||
|
|
||||||
try {
|
|
||||||
await xapi.disconnect()
|
|
||||||
} catch (error) {}
|
|
||||||
}
|
|
||||||
/* eslint-enable no-console */
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import { createClient } from './index.mjs'
|
|
||||||
import { main } from './cli-lib.mjs'
|
|
||||||
|
|
||||||
main(createClient).catch(console.error.bind(console, 'FATAL'))
|
|
||||||
115
packages/xen-api/events.mjs
Normal file
115
packages/xen-api/events.mjs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
const EVENT_TIMEOUT = 60e3
|
||||||
|
|
||||||
|
export class Watcher {
|
||||||
|
#abortController
|
||||||
|
#typeWatchers = new Map()
|
||||||
|
classes = new Map()
|
||||||
|
xapi
|
||||||
|
|
||||||
|
constructor(xapi) {
|
||||||
|
this.xapi = xapi
|
||||||
|
}
|
||||||
|
|
||||||
|
async asyncCall(method, params, { signal }) {
|
||||||
|
const taskRef = await this.xapi.call('Async.' + method, params, { signal })
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const stop = this.watch(
|
||||||
|
'task',
|
||||||
|
taskRef,
|
||||||
|
task => {
|
||||||
|
const { status } = task
|
||||||
|
if (status === 'success') {
|
||||||
|
stop()
|
||||||
|
resolve(task.status)
|
||||||
|
} else if (status === 'cancelled' || status === 'failure') {
|
||||||
|
stop()
|
||||||
|
reject(task.error_info)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async #start() {
|
||||||
|
const { xapi } = this
|
||||||
|
const { signal } = this.#abortController
|
||||||
|
const watchers = this.#typeWatchers
|
||||||
|
|
||||||
|
let token = await xapi.call('event.inject', 'pool', xapi.pool.$ref)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
signal.throwIfRequested()
|
||||||
|
|
||||||
|
const result = await xapi.call({ signal }, 'event.from', this.classes, token, EVENT_TIMEOUT)
|
||||||
|
|
||||||
|
for (const event of result.events) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#abortController = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.#abortController !== undefined) {
|
||||||
|
throw new Error('already started')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#abortController = new AbortController()
|
||||||
|
this.#start()
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.#abortController === undefined) {
|
||||||
|
throw new Error('already stopped')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#abortController.abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Cache {
|
||||||
|
// contains records indexed by type + ref
|
||||||
|
//
|
||||||
|
// plain records when retrieved by events
|
||||||
|
//
|
||||||
|
// promises to record when retrieved by a get_record call (might be a rejection if the record does not exist)
|
||||||
|
#recordCache = new Map()
|
||||||
|
#watcher
|
||||||
|
|
||||||
|
constructor(watcher) {
|
||||||
|
this.#watcher = watcher
|
||||||
|
}
|
||||||
|
|
||||||
|
async #get(type, ref) {
|
||||||
|
let record
|
||||||
|
try {
|
||||||
|
record = await this.#watcher.xapi.call(`${type}.get_record`, ref)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'HANDLE_INVALID') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
record = Promise.reject(error)
|
||||||
|
}
|
||||||
|
this.#recordCache.set(type, Promise.resolve(record))
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(type, ref) {
|
||||||
|
const cache = this.#recordCache
|
||||||
|
const key = type + ref
|
||||||
|
|
||||||
|
let record = cache.get(key)
|
||||||
|
if (record === undefined) {
|
||||||
|
record = this.#get(type, ref)
|
||||||
|
cache.set(key, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByUuid(type, uuid) {
|
||||||
|
return this.get(type, await this.#watcher.xapi.call(`${type}.get_by_uuid`, uuid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.Cache = Cache
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
ignorePatterns: ['*'],
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
if (process.env.DEBUG === undefined) {
|
|
||||||
process.env.DEBUG = 'xen-api'
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import './env.mjs'
|
|
||||||
|
|
||||||
import createProgress from 'progress-stream'
|
|
||||||
import createTop from 'process-top'
|
|
||||||
import getopts from 'getopts'
|
|
||||||
import { defer } from 'golike-defer'
|
|
||||||
import { CancelToken } from 'promise-toolbox'
|
|
||||||
|
|
||||||
import { createClient } from '../index.mjs'
|
|
||||||
|
|
||||||
import { createOutputStream, formatProgress, pipeline, resolveRecord, throttle } from './utils.mjs'
|
|
||||||
|
|
||||||
defer(async ($defer, rawArgs) => {
|
|
||||||
const {
|
|
||||||
raw,
|
|
||||||
throttle: bps,
|
|
||||||
_: args,
|
|
||||||
} = getopts(rawArgs, {
|
|
||||||
boolean: 'raw',
|
|
||||||
alias: {
|
|
||||||
raw: 'r',
|
|
||||||
throttle: 't',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (args.length < 2) {
|
|
||||||
return console.log('Usage: export-vdi [--raw] <XS URL> <VDI identifier> [<VHD file>]')
|
|
||||||
}
|
|
||||||
|
|
||||||
const xapi = createClient({
|
|
||||||
allowUnauthorized: true,
|
|
||||||
url: args[0],
|
|
||||||
watchEvents: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
await xapi.connect()
|
|
||||||
$defer(() => xapi.disconnect())
|
|
||||||
|
|
||||||
const { cancel, token } = CancelToken.source()
|
|
||||||
process.on('SIGINT', cancel)
|
|
||||||
|
|
||||||
const vdi = await resolveRecord(xapi, 'VDI', args[1])
|
|
||||||
|
|
||||||
// https://xapi-project.github.io/xen-api/snapshots.html#downloading-a-disk-or-snapshot
|
|
||||||
const exportStream = await xapi.getResource(token, '/export_raw_vdi/', {
|
|
||||||
query: {
|
|
||||||
format: raw ? 'raw' : 'vhd',
|
|
||||||
vdi: vdi.$ref,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
console.warn('Export task:', exportStream.headers['task-id'])
|
|
||||||
|
|
||||||
const top = createTop()
|
|
||||||
const progressStream = createProgress()
|
|
||||||
|
|
||||||
$defer(
|
|
||||||
clearInterval,
|
|
||||||
setInterval(() => {
|
|
||||||
console.warn('\r %s | %s', top.toString(), formatProgress(progressStream.progress()))
|
|
||||||
}, 1e3)
|
|
||||||
)
|
|
||||||
|
|
||||||
await pipeline(exportStream, progressStream, throttle(bps), createOutputStream(args[2]))
|
|
||||||
})(process.argv.slice(2)).catch(console.error.bind(console, 'error'))
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import './env.mjs'
|
|
||||||
|
|
||||||
import createProgress from 'progress-stream'
|
|
||||||
import getopts from 'getopts'
|
|
||||||
import { defer } from 'golike-defer'
|
|
||||||
import { CancelToken } from 'promise-toolbox'
|
|
||||||
|
|
||||||
import { createClient } from '../index.mjs'
|
|
||||||
|
|
||||||
import { createOutputStream, formatProgress, pipeline, resolveRecord } from './utils.mjs'
|
|
||||||
|
|
||||||
defer(async ($defer, rawArgs) => {
|
|
||||||
const {
|
|
||||||
gzip,
|
|
||||||
zstd,
|
|
||||||
_: args,
|
|
||||||
} = getopts(rawArgs, {
|
|
||||||
boolean: ['gzip', 'zstd'],
|
|
||||||
})
|
|
||||||
|
|
||||||
if (args.length < 2) {
|
|
||||||
return console.log('Usage: export-vm <XS URL> <VM identifier> [<XVA file>]')
|
|
||||||
}
|
|
||||||
|
|
||||||
const xapi = createClient({
|
|
||||||
allowUnauthorized: true,
|
|
||||||
url: args[0],
|
|
||||||
watchEvents: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
await xapi.connect()
|
|
||||||
$defer(() => xapi.disconnect())
|
|
||||||
|
|
||||||
const { cancel, token } = CancelToken.source()
|
|
||||||
process.on('SIGINT', cancel)
|
|
||||||
|
|
||||||
// https://xapi-project.github.io/xen-api/importexport.html
|
|
||||||
const exportStream = await xapi.getResource(token, '/export/', {
|
|
||||||
query: {
|
|
||||||
ref: (await resolveRecord(xapi, 'VM', args[1])).$ref,
|
|
||||||
use_compression: zstd ? 'zstd' : gzip ? 'true' : 'false',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
console.warn('Export task:', exportStream.headers['task-id'])
|
|
||||||
|
|
||||||
await pipeline(
|
|
||||||
exportStream,
|
|
||||||
createProgress({ time: 1e3 }, p => console.warn(formatProgress(p))),
|
|
||||||
createOutputStream(args[2])
|
|
||||||
)
|
|
||||||
})(process.argv.slice(2)).catch(console.error.bind(console, 'error'))
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import './env.mjs'
|
|
||||||
|
|
||||||
import getopts from 'getopts'
|
|
||||||
import { defer } from 'golike-defer'
|
|
||||||
import { CancelToken } from 'promise-toolbox'
|
|
||||||
import { createVhdStreamWithLength } from 'vhd-lib'
|
|
||||||
|
|
||||||
import { createClient } from '../index.mjs'
|
|
||||||
|
|
||||||
import { createInputStream, resolveRef } from './utils.mjs'
|
|
||||||
|
|
||||||
defer(async ($defer, argv) => {
|
|
||||||
const opts = getopts(argv, { boolean: ['events', 'raw', 'remove-length'], string: ['sr', 'vdi'] })
|
|
||||||
|
|
||||||
const url = opts._[0]
|
|
||||||
|
|
||||||
if (url === undefined) {
|
|
||||||
return console.log(
|
|
||||||
'Usage: import-vdi [--events] [--raw] [--sr <SR identifier>] [--vdi <VDI identifier>] <XS URL> [<VHD file>]'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { raw, sr, vdi } = opts
|
|
||||||
|
|
||||||
const createVdi = vdi === ''
|
|
||||||
if (createVdi) {
|
|
||||||
if (sr === '') {
|
|
||||||
throw 'requires either --vdi or --sr'
|
|
||||||
}
|
|
||||||
if (!raw) {
|
|
||||||
throw 'creating a VDI requires --raw'
|
|
||||||
}
|
|
||||||
} else if (sr !== '') {
|
|
||||||
throw '--vdi and --sr are mutually exclusive'
|
|
||||||
}
|
|
||||||
|
|
||||||
const xapi = createClient({
|
|
||||||
allowUnauthorized: true,
|
|
||||||
url,
|
|
||||||
watchEvents: opts.events && ['task'],
|
|
||||||
})
|
|
||||||
|
|
||||||
await xapi.connect()
|
|
||||||
$defer(() => xapi.disconnect())
|
|
||||||
|
|
||||||
const { cancel, token } = CancelToken.source()
|
|
||||||
process.on('SIGINT', cancel)
|
|
||||||
|
|
||||||
let input = createInputStream(opts._[1])
|
|
||||||
$defer.onFailure(() => input.destroy())
|
|
||||||
|
|
||||||
let vdiRef
|
|
||||||
if (createVdi) {
|
|
||||||
vdiRef = await xapi.call('VDI.create', {
|
|
||||||
name_label: 'xen-api/import-vdi',
|
|
||||||
other_config: {},
|
|
||||||
read_only: false,
|
|
||||||
sharable: false,
|
|
||||||
SR: await resolveRef(xapi, 'SR', sr),
|
|
||||||
type: 'user',
|
|
||||||
virtual_size: input.length,
|
|
||||||
})
|
|
||||||
$defer.onFailure(() => xapi.call('VDI.destroy', vdiRef))
|
|
||||||
} else {
|
|
||||||
vdiRef = await resolveRef(xapi, 'VDI', vdi)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts['remove-length']) {
|
|
||||||
delete input.length
|
|
||||||
console.log('length removed')
|
|
||||||
} else if (!raw && input.length === undefined) {
|
|
||||||
input = await createVhdStreamWithLength(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://xapi-project.github.io/xen-api/snapshots.html#uploading-a-disk-or-snapshot
|
|
||||||
const result = await xapi.putResource(token, input, '/import_raw_vdi/', {
|
|
||||||
query: {
|
|
||||||
format: raw ? 'raw' : 'vhd',
|
|
||||||
vdi: vdiRef,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (result !== undefined) {
|
|
||||||
console.log(result)
|
|
||||||
}
|
|
||||||
})(process.argv.slice(2)).catch(console.error.bind(console, 'Fatal:'))
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import './env.mjs'
|
|
||||||
|
|
||||||
import { defer } from 'golike-defer'
|
|
||||||
import { CancelToken } from 'promise-toolbox'
|
|
||||||
|
|
||||||
import { createClient } from '../index.mjs'
|
|
||||||
|
|
||||||
import { createInputStream, resolveRef } from './utils.mjs'
|
|
||||||
|
|
||||||
defer(async ($defer, args) => {
|
|
||||||
if (args.length < 1) {
|
|
||||||
return console.log('Usage: import-vm <XS URL> [<XVA file>] [<SR identifier>]')
|
|
||||||
}
|
|
||||||
|
|
||||||
const xapi = createClient({
|
|
||||||
allowUnauthorized: true,
|
|
||||||
url: args[0],
|
|
||||||
watchEvents: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
await xapi.connect()
|
|
||||||
$defer(() => xapi.disconnect())
|
|
||||||
|
|
||||||
const { cancel, token } = CancelToken.source()
|
|
||||||
process.on('SIGINT', cancel)
|
|
||||||
|
|
||||||
// https://xapi-project.github.io/xen-api/importexport.html
|
|
||||||
await xapi.putResource(token, createInputStream(args[1]), '/import/', {
|
|
||||||
query: args[2] && { sr_id: await resolveRef(xapi, 'SR', args[2]) },
|
|
||||||
})
|
|
||||||
})(process.argv.slice(2)).catch(console.error.bind(console, 'error'))
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import 'source-map-support/register.js'
|
|
||||||
|
|
||||||
import forEach from 'lodash/forEach.js'
|
|
||||||
import size from 'lodash/size.js'
|
|
||||||
|
|
||||||
import { createClient } from '../index.mjs'
|
|
||||||
|
|
||||||
// ===================================================================
|
|
||||||
|
|
||||||
if (process.argv.length < 3) {
|
|
||||||
throw new Error('Usage: log-events <XS URL>')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===================================================================
|
|
||||||
// Creation
|
|
||||||
|
|
||||||
const xapi = createClient({
|
|
||||||
allowUnauthorized: true,
|
|
||||||
url: process.argv[2],
|
|
||||||
})
|
|
||||||
|
|
||||||
// ===================================================================
|
|
||||||
// Method call
|
|
||||||
|
|
||||||
xapi.connect().then(() => {
|
|
||||||
xapi
|
|
||||||
.call('VM.get_all_records')
|
|
||||||
.then(function (vms) {
|
|
||||||
console.log('%s VMs fetched', size(vms))
|
|
||||||
})
|
|
||||||
.catch(function (error) {
|
|
||||||
console.error(error)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ===================================================================
|
|
||||||
// Objects
|
|
||||||
|
|
||||||
const objects = xapi.objects
|
|
||||||
|
|
||||||
objects.on('add', objects => {
|
|
||||||
forEach(objects, object => {
|
|
||||||
console.log('+ %s: %s', object.$type, object.$id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
objects.on('update', objects => {
|
|
||||||
forEach(objects, object => {
|
|
||||||
console.log('± %s: %s', object.$type, object.$id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
objects.on('remove', objects => {
|
|
||||||
forEach(objects, (value, id) => {
|
|
||||||
console.log('- %s', id)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
2647
packages/xen-api/examples/package-lock.json
generated
2647
packages/xen-api/examples/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"getopts": "^2.2.3",
|
|
||||||
"golike-defer": "^0.5.1",
|
|
||||||
"human-format": "^0.11.0",
|
|
||||||
"lodash": "^4.17.21",
|
|
||||||
"process-top": "^1.2.0",
|
|
||||||
"progress-stream": "^2.0.0",
|
|
||||||
"promise-toolbox": "^0.19.2",
|
|
||||||
"readable-stream": "^4.4.2",
|
|
||||||
"source-map-support": "^0.5.21",
|
|
||||||
"throttle": "^1.0.3",
|
|
||||||
"vhd-lib": "^4.6.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { createReadStream, createWriteStream, statSync } from 'fs'
|
|
||||||
import { fromCallback } from 'promise-toolbox'
|
|
||||||
import { PassThrough, pipeline as Pipeline } from 'readable-stream'
|
|
||||||
import humanFormat from 'human-format'
|
|
||||||
import Throttle from 'throttle'
|
|
||||||
|
|
||||||
import Ref from '../_Ref.mjs'
|
|
||||||
|
|
||||||
export const createInputStream = path => {
|
|
||||||
if (path === undefined || path === '-') {
|
|
||||||
return process.stdin
|
|
||||||
}
|
|
||||||
|
|
||||||
const { size } = statSync(path)
|
|
||||||
|
|
||||||
const stream = createReadStream(path)
|
|
||||||
stream.length = size
|
|
||||||
return stream
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createOutputStream = path => {
|
|
||||||
if (path !== undefined && path !== '-') {
|
|
||||||
return createWriteStream(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// introduce a through stream because stdout is not a normal stream!
|
|
||||||
const stream = new PassThrough()
|
|
||||||
stream.pipe(process.stdout)
|
|
||||||
return stream
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatSizeOpts = { scale: 'binary', unit: 'B' }
|
|
||||||
const formatSize = bytes => humanFormat(bytes, formatSizeOpts)
|
|
||||||
|
|
||||||
export const formatProgress = p => {
|
|
||||||
return [
|
|
||||||
formatSize(p.transferred),
|
|
||||||
' / ',
|
|
||||||
formatSize(p.length),
|
|
||||||
' | ',
|
|
||||||
p.runtime,
|
|
||||||
's / ',
|
|
||||||
p.eta,
|
|
||||||
's | ',
|
|
||||||
formatSize(p.speed),
|
|
||||||
'/s',
|
|
||||||
].join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const pipeline = (...streams) => {
|
|
||||||
return fromCallback(cb => {
|
|
||||||
streams = streams.filter(_ => _ != null)
|
|
||||||
streams.push(cb)
|
|
||||||
Pipeline.apply(undefined, streams)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveRef = (xapi, type, refOrUuidOrNameLabel) =>
|
|
||||||
Ref.is(refOrUuidOrNameLabel)
|
|
||||||
? refOrUuidOrNameLabel
|
|
||||||
: xapi.call(`${type}.get_by_uuid`, refOrUuidOrNameLabel).catch(() =>
|
|
||||||
xapi.call(`${type}.get_by_name_label`, refOrUuidOrNameLabel).then(refs => {
|
|
||||||
if (refs.length === 1) {
|
|
||||||
return refs[0]
|
|
||||||
}
|
|
||||||
throw new Error(`no single match for ${type} with name label ${refOrUuidOrNameLabel}`)
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
export const resolveRecord = async (xapi, type, refOrUuidOrNameLabel) =>
|
|
||||||
xapi.getRecord(type, await resolveRef(xapi, type, refOrUuidOrNameLabel))
|
|
||||||
|
|
||||||
export { resolveRef }
|
|
||||||
|
|
||||||
export const throttle = opts => (opts != null ? new Throttle(opts) : undefined)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
|||||||
import makeError from 'make-error'
|
|
||||||
|
|
||||||
export default makeError('UnsupportedTransport')
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
// Prepare values before passing them to the XenAPI:
|
|
||||||
//
|
|
||||||
// - cast integers to strings
|
|
||||||
export default function prepare(param) {
|
|
||||||
if (Number.isInteger(param)) {
|
|
||||||
return String(param)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof param !== 'object' || param === null) {
|
|
||||||
return param
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(param)) {
|
|
||||||
return param.map(prepare)
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = {}
|
|
||||||
Object.keys(param).forEach(key => {
|
|
||||||
const value = param[key]
|
|
||||||
if (value !== undefined) {
|
|
||||||
values[key] = prepare(value)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import jsonRpc from './json-rpc.mjs'
|
|
||||||
import UnsupportedTransport from './_UnsupportedTransport.mjs'
|
|
||||||
import xmlRpc from './xml-rpc.mjs'
|
|
||||||
|
|
||||||
const factories = [jsonRpc, xmlRpc]
|
|
||||||
const { length } = factories
|
|
||||||
|
|
||||||
export default opts => {
|
|
||||||
let i = 0
|
|
||||||
|
|
||||||
let call
|
|
||||||
function create() {
|
|
||||||
const current = factories[i++](opts)
|
|
||||||
if (i < length) {
|
|
||||||
const currentI = i
|
|
||||||
call = (method, args) =>
|
|
||||||
current(method, args).catch(error => {
|
|
||||||
if (error instanceof UnsupportedTransport) {
|
|
||||||
if (currentI === i) {
|
|
||||||
// not changed yet
|
|
||||||
create()
|
|
||||||
}
|
|
||||||
return call(method, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
call = current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
create()
|
|
||||||
|
|
||||||
return (method, args) => call(method, args)
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import auto from './auto.mjs'
|
|
||||||
import jsonRpc from './json-rpc.mjs'
|
|
||||||
import xmlRpc from './xml-rpc.mjs'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
__proto__: null,
|
|
||||||
|
|
||||||
auto,
|
|
||||||
'json-rpc': jsonRpc,
|
|
||||||
'xml-rpc': xmlRpc,
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import httpRequestPlus from 'http-request-plus'
|
|
||||||
import { format, parse } from 'json-rpc-protocol'
|
|
||||||
|
|
||||||
import XapiError from '../_XapiError.mjs'
|
|
||||||
|
|
||||||
import UnsupportedTransport from './_UnsupportedTransport.mjs'
|
|
||||||
|
|
||||||
// https://github.com/xenserver/xenadmin/blob/0df39a9d83cd82713f32d24704852a0fd57b8a64/XenModel/XenAPI/Session.cs#L403-L433
|
|
||||||
export default ({ secureOptions, url, agent }) => {
|
|
||||||
url = new URL('./jsonrpc', Object.assign(new URL('http://localhost'), url))
|
|
||||||
|
|
||||||
return async function (method, args) {
|
|
||||||
const res = await httpRequestPlus(url, {
|
|
||||||
...secureOptions,
|
|
||||||
body: format.request(0, method, args),
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
method: 'POST',
|
|
||||||
agent,
|
|
||||||
})
|
|
||||||
|
|
||||||
// content-type is `text/xml` on old hosts where JSON-RPC is unsupported
|
|
||||||
if (res.headers['content-type'] !== 'application/json') {
|
|
||||||
throw new UnsupportedTransport()
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = parse(await res.text())
|
|
||||||
|
|
||||||
if (response.type === 'response') {
|
|
||||||
return response.result
|
|
||||||
}
|
|
||||||
|
|
||||||
throw XapiError.wrap(response.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import xmlrpc from 'xmlrpc'
|
|
||||||
import { promisify } from 'promise-toolbox'
|
|
||||||
|
|
||||||
import XapiError from '../_XapiError.mjs'
|
|
||||||
|
|
||||||
import prepareXmlRpcParams from './_prepareXmlRpcParams.mjs'
|
|
||||||
|
|
||||||
const logError = error => {
|
|
||||||
if (error.res) {
|
|
||||||
console.error('XML-RPC Error: %s (response status %s)', error.message, error.res.statusCode)
|
|
||||||
console.error('%s', error.body)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseResult = result => {
|
|
||||||
const status = result.Status
|
|
||||||
|
|
||||||
// Return the plain result if it does not have a valid XAPI
|
|
||||||
// format.
|
|
||||||
if (status === undefined) {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status !== 'Success') {
|
|
||||||
throw XapiError.wrap(result.ErrorDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ({ secureOptions, url: { hostnameRaw, pathname, port, protocol }, agent }) => {
|
|
||||||
const secure = protocol === 'https:'
|
|
||||||
const client = (secure ? xmlrpc.createSecureClient : xmlrpc.createClient)({
|
|
||||||
...(secure ? secureOptions : undefined),
|
|
||||||
agent,
|
|
||||||
host: hostnameRaw,
|
|
||||||
pathname,
|
|
||||||
port,
|
|
||||||
})
|
|
||||||
const call = promisify(client.methodCall, client)
|
|
||||||
|
|
||||||
return (method, args) => call(method, prepareXmlRpcParams(args)).then(parseResult, logError)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user