Merge pull request #93 from vatesfr/next-release

4.6 release
This commit is contained in:
Olivier Lambert
2015-09-25 00:28:48 +02:00
13 changed files with 252 additions and 90 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server",
"version": "4.5.0",
"version": "4.6.0",
"license": "AGPL-3.0",
"description": "Server part of Xen-Orchestra",
"keywords": [
@@ -76,7 +76,6 @@
"lodash.map": "^3.0.0",
"lodash.pick": "^3.0.0",
"lodash.result": "^3.0.0",
"lodash.snakecase": "^3.0.1",
"lodash.sortby": "^3.1.4",
"lodash.startswith": "^3.0.1",
"make-error": "^1",

View File

@@ -91,14 +91,14 @@ const checkAuthorizationByTypes = {
message: checkMemberAuthorization('$object'),
network (userId, network, permission) {
return defaultCheckAuthorization(this, userId, network, permission).catch(() => {
return checkAuthorization(this, userId, network.$pool, permission)
return defaultCheckAuthorization.call(this, userId, network, permission).catch(() => {
return checkAuthorization.call(this, userId, network.$pool, permission)
})
},
SR (userId, sr, permission) {
return defaultCheckAuthorization(this, userId, sr, permission).catch(() => {
return checkAuthorization(this, userId, sr.$pool, permission)
return defaultCheckAuthorization.call(this, userId, sr, permission).catch(() => {
return checkAuthorization.call(this, userId, sr.$pool, permission)
})
},

View File

@@ -11,16 +11,17 @@ export * as group from './group'
export * as host from './host'
export * as job from './job'
export * as message from './message'
export * as remote from './remote'
export * as pbd from './pbd'
export * as pif from './pif'
export * as pool from './pool'
export * as remote from './remote'
export * as role from './role'
export * as schedule from './schedule'
export * as scheduler from './scheduler'
export * as server from './server'
export * as session from './session'
export * as sr from './sr'
export * as tag from './tag'
export * as task from './task'
export * as test from './test'
export * as token from './token'

31
src/api/tag.js Normal file
View File

@@ -0,0 +1,31 @@
export async function add ({tag, object}) {
await this.getXAPI(object).addTag(object.id, tag)
}
add.description = 'add a new tag to an object'
add.resolve = {
object: ['id', null, 'administrate']
}
add.params = {
tag: { type: 'string' },
id: { type: 'string' }
}
// -------------------------------------------------------------------
export async function remove ({tag, object}) {
await this.getXAPI(object).removeTag(object.id, tag)
}
remove.description = 'remove an existing tag from an object'
remove.resolve = {
object: ['id', null, 'administrate']
}
remove.params = {
tag: { type: 'string' },
id: { type: 'string' }
}

View File

@@ -72,3 +72,17 @@ set.params = {
password: { type: 'string', optional: true },
permission: { type: 'string', optional: true }
}
export async function changePassword ({oldPassword, newPassword}) {
const id = this.session.get('user_id')
await this.changePassword(id, oldPassword, newPassword)
}
changePassword.description = 'change password after checking old password (user function)'
changePassword.permission = ''
changePassword.params = {
oldPassword: {type: 'string'},
newPassword: {type: 'string'}
}

View File

@@ -40,6 +40,7 @@ create = $coroutine ({
name_description
name_label
template
pv_args
VDIs
VIFs
}) ->
@@ -47,6 +48,7 @@ create = $coroutine ({
installRepository: installation && installation.repository,
nameDescription: name_description,
nameLabel: name_label,
pvArgs: pv_args,
vdis: VDIs,
vifs: VIFs
})
@@ -69,6 +71,9 @@ create.params = {
name_label: { type: 'string' }
name_description: { type: 'string', optional: true }
# PV Args
pv_args: { type: 'string', optional: true }
# TODO: add the install repository!
# VBD.insert/eject
# Also for the console!
@@ -170,14 +175,8 @@ exports.insertCd = insertCd
#---------------------------------------------------------------------
migrate = $coroutine ({vm, host}) ->
unless $isVMRunning vm
@throw 'INVALID_PARAMS', 'The VM can only be migrated when running'
xapi = @getXAPI vm
yield xapi.call 'VM.pool_migrate', vm.ref, host.ref, {'force': 'true'}
return true
yield @getXAPI(vm).migrateVm(vm.id, @getXAPI(host), host.id)
return
migrate.params = {
# Identifier of the VM to migrate.
@@ -197,62 +196,18 @@ exports.migrate = migrate
#---------------------------------------------------------------------
migratePool = $coroutine ({
vm: VM,
vm,
host
sr: SR
sr
network
migrationNetwork
}) ->
# TODO: map multiple VDI and VIF
# Optional parameters
# if no network given, try to use the management network
unless network
PIF = $findWhere (@getObjects host.$PIFs), management: true
network = @getObject PIF.$network, 'network'
# if no migrationNetwork, use the network
migrationNetwork ?= network
# if no sr is given, try to find the default Pool SR
unless SR
pool = @getObject host.poolRef, 'pool'
target_sr_id = pool.default_SR
SR = @getObject target_sr_id, 'SR'
unless $isVMRunning VM
@throw 'INVALID_PARAMS', 'The VM can only be migrated when running'
vdiMap = {}
for vbdId in VM.$VBDs
VBD = @getObject vbdId, 'VBD'
continue if VBD.is_cd_drive
VDI = @getObject VBD.VDI, 'VDI'
vdiMap[VDI.ref] = SR.ref
vifMap = {}
for vifId in VM.VIFs
VIF = @getObject vifId, 'VIF'
vifMap[VIF.ref] = network.ref
token = yield (@getXAPI host).call(
'host.migrate_receive'
host.ref
migrationNetwork.ref
{} # Other parameters
)
yield (@getXAPI VM).call(
'VM.migrate_send'
VM.ref
token
true # Live migration
vdiMap
vifMap
{'force': 'true'} # Force migration even if CPUs are different
)
return true
yield @getXAPI(vm).migrateVm(vm.id, @getXAPI(host), host.id, {
migrationNetworkId: migrationNetwork?.id
networkId: network?.id,
srId: sr?.id,
})
return
migratePool.params = {
@@ -354,6 +309,7 @@ set = $coroutine (params) ->
for param, fields of {
'name_label'
'name_description'
'PV_args'
}
continue unless param of params
@@ -383,6 +339,9 @@ set.params = {
#
# Note: static_min ≤ dynamic_min ≤ dynamic_max ≤ static_max
memory: { type: 'integer', optional: true }
# Kernel arguments for PV VM.
PV_args: { type: 'string', optional: true }
}
set.resolve = {
@@ -582,7 +541,7 @@ stop = $coroutine ({vm, force}) ->
try
yield xapi.call 'VM.clean_shutdown', vm.ref
catch error
if error.code is 'VM_MISSING_PV_DRIVERS' or error.code 'VM_LACKS_FEATURE_SHUTDOWN'
if error.code is 'VM_MISSING_PV_DRIVERS' or error.code is 'VM_LACKS_FEATURE_SHUTDOWN'
# TODO: Improve reporting: this message is unclear.
@throw 'INVALID_PARAMS'
else
@@ -661,6 +620,9 @@ exports.revert = revert
handleExport = (req, res, { stream }) ->
upstream = stream.response
# Remove the filename as it is already part of the URL.
upstream.headers['content-disposition'] = 'attachment'
res.writeHead(
upstream.statusCode,
upstream.statusMessage ? '',
@@ -677,7 +639,9 @@ export_ = $coroutine ({vm, compress, onlyMetadata}) ->
})
return {
$getFrom: yield @registerHttpRequest(handleExport, { stream })
$getFrom: yield @registerHttpRequest(handleExport, { stream }, {
suffix: encodeURI("/#{vm.name_label}.xva")
})
}
export_.params = {

View File

@@ -18,6 +18,7 @@ import proxyRequest from 'proxy-http-request'
import serveStatic from 'serve-static'
import WebSocket from 'ws'
import {compile as compileJade} from 'jade'
import {posix as posixPath} from 'path'
import {
AlreadyAuthenticated,
@@ -143,6 +144,9 @@ async function setUpPassport (express, xo) {
const SIGNIN_STRATEGY_RE = /^\/signin\/([^/]+)(\/callback)?(:?\?.*)?$/
express.use(async (req, res, next) => {
// A relative path is needed to avoid breaking reverse proxies.
const basePath = posixPath.relative(req.path, '/')
const matches = req.url.match(SIGNIN_STRATEGY_RE)
if (matches) {
return passport.authenticate(matches[1], async (err, user, info) => {
@@ -152,7 +156,7 @@ async function setUpPassport (express, xo) {
if (!user) {
req.flash('error', info ? info.message : 'Invalid credentials')
return res.redirect('signin')
return res.redirect(`${basePath}/signin`)
}
// The cookie will be set in via the next request because some
@@ -162,12 +166,7 @@ async function setUpPassport (express, xo) {
(await xo.createAuthenticationToken({userId: user.id})).id
)
// A relative path is needed to avoid breaking reverse proxies.
res.redirect(
matches[2]
? '../../'
: '../'
)
res.redirect(basePath)
})(req, res, next)
}
@@ -180,7 +179,7 @@ async function setUpPassport (express, xo) {
} else if (/fontawesome|images|styles/.test(req.url)) {
next()
} else {
res.redirect('signin')
return res.redirect(`${basePath}/signin`)
}
})
@@ -336,7 +335,7 @@ const apiHelpers = {
// Handles both properties and wrapped models.
const properties = user.properties || user
return pick(properties, 'id', 'email', 'groups', 'permission')
return pick(properties, 'id', 'email', 'groups', 'permission', 'provider')
},
getServerPublicProperties (server) {

View File

@@ -87,7 +87,7 @@ class NfsMounter {
if (this._matchesRealMount(mount)) {
try {
await this._umount(mount)
} catch(_) {
} catch (_) {
// We have to go on...
}
}
@@ -106,7 +106,7 @@ class LocalHandler {
try {
await fs.ensureDirAsync(local.path)
await fs.accessAsync(local.path, fs.R_OK | fs.W_OK)
} catch(exc) {
} catch (exc) {
local.enabled = false
local.error = exc.message
}

View File

@@ -11,6 +11,15 @@ import {randomBytes} from 'crypto'
// ===================================================================
export function camelToSnakeCase (string) {
return string.replace(
/([a-z])([A-Z])/g,
(_, prevChar, currChar) => `${prevChar}_${currChar.toLowerCase()}`
)
}
// -------------------------------------------------------------------
// Ensure the value is an array, wrap it if necessary.
export const ensureArray = (value) => {
if (value === undefined) {

View File

@@ -5,6 +5,7 @@ import expect from 'must'
// ===================================================================
import {
camelToSnakeCase,
ensureArray,
extractProperty,
formatXml,
@@ -14,6 +15,22 @@ import {
// ===================================================================
describe('camelToSnakeCase()', function () {
it('converts a string from camelCase to snake_case', function () {
expect(camelToSnakeCase('fooBar')).to.equal('foo_bar')
})
it('does not alter snake_case strings', function () {
expect(camelToSnakeCase('foo_bar')).to.equal('foo_bar')
})
it('does not alter upper case letters expect those from the camelCase', function () {
expect(camelToSnakeCase('fooBar_BAZ')).to.equal('foo_bar_BAZ')
})
})
// -------------------------------------------------------------------
describe('ensureArray()', function () {
it('wrap the value in an array', function () {
const value = 'foo'

View File

@@ -55,6 +55,7 @@ export function pool (obj) {
default_SR: link(obj, 'default_SR'),
HA_enabled: Boolean(obj.ha_enabled),
master: link(obj, 'master'),
tags: obj.tags,
name_description: obj.name_description,
name_label: obj.name_label || obj.$master.name_label
@@ -111,6 +112,7 @@ export function host (obj) {
patches: link(obj, 'patches'),
powerOnMode: obj.power_on_mode,
power_state: isRunning ? 'Running' : 'Halted',
tags: obj.tags,
version: obj.software_version.product_version,
// TODO: dedupe.
@@ -221,10 +223,12 @@ export function vm (obj) {
other: otherConfig,
os_version: guestMetrics && guestMetrics.os_version || null,
power_state: obj.power_state,
PV_args: obj.PV_args,
PV_drivers: Boolean(guestMetrics),
PV_drivers_up_to_date: Boolean(guestMetrics && guestMetrics.PV_drivers_up_to_date),
snapshot_time: toTimestamp(obj.snapshot_time),
snapshots: link(obj, 'snapshots'),
tags: obj.tags,
VIFs: link(obj, 'VIFs'),
$container: (
@@ -290,6 +294,7 @@ export function sr (obj) {
physical_usage: +obj.physical_utilisation,
size: +obj.physical_size,
SR_type: obj.type,
tags: obj.tags,
usage: +obj.virtual_allocation,
VDIs: link(obj, 'VDIs'),
@@ -357,6 +362,7 @@ export function vdi (obj) {
size: +obj.virtual_size,
snapshots: link(obj, 'snapshots'),
snapshot_time: toTimestamp(obj.snapshot_time),
tags: obj.tags,
usage: +obj.physical_utilisation,
$snapshot_of: link(obj, 'snapshot_of'),
@@ -405,6 +411,7 @@ export function network (obj) {
MTU: +obj.MTU,
name_description: obj.name_description,
name_label: obj.name_label,
tags: obj.tags,
PIFs: link(obj, 'PIFs'),
VIFs: link(obj, 'VIFs')
}

View File

@@ -8,7 +8,6 @@ import fs from 'fs-extra'
import got from 'got'
import includes from 'lodash.includes'
import map from 'lodash.map'
import snakeCase from 'lodash.snakecase'
import sortBy from 'lodash.sortby'
import unzip from 'julien-f-unzip'
import {PassThrough} from 'stream'
@@ -20,6 +19,7 @@ import {
import {debounce} from './decorators'
import {
camelToSnakeCase,
ensureArray,
noop, parseXml,
pFinally
@@ -215,7 +215,7 @@ export default class Xapi extends XapiBase {
// properties that failed to be set.
await Promise.all(map(props, (value, name) => {
if (value != null) {
return this.call(`${namespace}.set_${snakeCase(name)}`, ref, value)
return this.call(`${namespace}.set_${camelToSnakeCase(name)}`, ref, value)
}
}))
}
@@ -242,6 +242,28 @@ export default class Xapi extends XapiBase {
// =================================================================
async addTag (id, tag) {
const {
$ref: ref,
$type: type
} = this.getObject(id)
const namespace = getNamespaceForType(type)
await this.call(`${namespace}.add_tags`, ref, tag)
}
async removeTag (id, tag) {
const {
$ref: ref,
$type: type
} = this.getObject(id)
const namespace = getNamespaceForType(type)
await this.call(`${namespace}.remove_tags`, ref, tag)
}
// =================================================================
// FIXME: should be static
@debounce(24 * 60 * 60 * 1000)
async _getXenUpdates () {
@@ -448,6 +470,7 @@ export default class Xapi extends XapiBase {
async createVm (templateId, {
nameDescription = undefined,
nameLabel = undefined,
pvArgs = undefined,
cpus = undefined,
installRepository = undefined,
vdis = [],
@@ -486,6 +509,7 @@ export default class Xapi extends XapiBase {
// Set VMs params.
this._setObjectProperties(vm, {
nameDescription,
PV_args: pvArgs,
VCPUs_at_startup: cpus
})
@@ -638,6 +662,88 @@ export default class Xapi extends XapiBase {
return stream
}
async _migrateVMWithStorageMotion (vm, hostXapi, host, {
migrationNetwork = find(host.$PIFs, pif => pif.management).$network, // TODO: handle not found
sr = host.$pool.$default_SR, // TODO: handle not found
vifsMap = {}
}) {
const vdis = {}
for (const vbd of vm.$VBDs) {
if (vbd.type !== 'CD') {
vdis[vbd.$VDI.$ref] = sr.$ref
}
}
const token = await hostXapi.call(
'host.migrate_receive',
host.$ref,
migrationNetwork.$ref,
{}
)
await this.call(
'VM.migrate_send',
vm.$ref,
token,
true, // Live migration.
vdis,
vifsMap,
{
force: 'true'
}
)
}
async migrateVm (vmId, hostXapi, hostId, {
migrationNetworkId,
networkId,
srId
} = {}) {
const vm = this.getObject(vmId)
if (!isVmRunning(vm)) {
throw new Error('cannot migrate a non-running VM')
}
const host = hostXapi.getObject(hostId)
const accrossPools = vm.$pool !== host.$pool
const useStorageMotion = (
accrossPools ||
migrationNetworkId ||
networkId ||
srId
)
if (useStorageMotion) {
const vifsMap = {}
if (accrossPools || networkId) {
const {$ref: networkRef} = networkId
? this.getObject(networkId)
: find(host.$PIFs, pif => pif.management).$network
for (const vif of vm.$VIFs) {
vifsMap[vif.$ref] = networkRef
}
}
await this._migrateVMWithStorageMotion(vm, hostXapi, host, {
migrationNetwork: migrationNetworkId && this.getObject(migrationNetworkId),
sr: srId && this.getObject(srId),
vifsMap
})
} else {
try {
await this.call('VM.pool_migrate', vm.$ref, host.$ref, { force: 'true' })
} catch (error) {
if (error.code !== 'VM_REQUIRES_SR') {
throw error
}
// Retry using motion storage.
await this._migrateVMWithStorageMotion(vm, hostXapi, host, {})
}
}
}
async snapshotVm (vmId) {
return await this._getOrWaitObject(
await this._snapshotVm(

View File

@@ -28,7 +28,7 @@ import {autobind} from './decorators'
import {generateToken} from './utils'
import {Groups} from './models/group'
import {Jobs} from './models/job'
import {JsonRpcError, NoSuchObject} from './api-errors'
import {InvalidCredential, JsonRpcError, NoSuchObject} from './api-errors'
import {ModelAlreadyExists} from './collection'
import {Remotes} from './models/remote'
import {Schedules} from './models/schedule'
@@ -282,7 +282,7 @@ export default class Xo extends EventEmitter {
}
async deleteUser (id) {
if (!await this._users.remove(id)) {
if (!await this._users.remove(id)) { // eslint-disable-line space-before-keywords
throw new NoSuchUser(id)
}
}
@@ -334,6 +334,21 @@ export default class Xo extends EventEmitter {
})
}
async changePassword (id, oldPassword, newPassword) {
const user = await this._getUser(id)
if (user.get('provider')) {
throw new Error('Password change is only for locally created users')
}
const auth = await user.checkPassword(oldPassword)
if (!auth) {
throw new InvalidCredential()
}
await user.setPassword(newPassword)
await this._users.save(user.properties)
}
// -----------------------------------------------------------------
async createGroup ({name}) {
@@ -345,7 +360,7 @@ export default class Xo extends EventEmitter {
}
async deleteGroup (id) {
if (!await this._groups.remove(id)) {
if (!await this._groups.remove(id)) { // eslint-disable-line space-before-keywords
throw new NoSuchGroup(id)
}
}
@@ -698,7 +713,7 @@ export default class Xo extends EventEmitter {
}
async deleteAuthenticationToken (id) {
if (!await this._tokens.remove(id)) {
if (!await this._tokens.remove(id)) { // eslint-disable-line space-before-keywords
throw new NoSuchAuthenticationToken(id)
}
}
@@ -726,7 +741,7 @@ export default class Xo extends EventEmitter {
async unregisterXenServer (id) {
this.disconnectXenServer(id).catch(() => {})
if (!await this._servers.remove(id)) {
if (!await this._servers.remove(id)) { // eslint-disable-line space-before-keywords
throw new NoSuchXenServer(id)
}
}
@@ -969,12 +984,12 @@ export default class Xo extends EventEmitter {
)
}
async registerHttpRequest (fn, data) {
async registerHttpRequest (fn, data, { suffix = '' } = {}) {
const {_httpRequestWatchers: watchers} = this
const url = await (function generateUniqueUrl () {
return generateToken().then(token => {
const url = `/api/${token}`
const url = `/api/${token}${suffix}`
return url in watchers
? generateUniqueUrl()
@@ -1067,7 +1082,7 @@ export default class Xo extends EventEmitter {
opts.createdAt = Date.now()
const url = `/${await generateToken()}`
const url = `/${await generateToken()}` // eslint-disable-line space-before-keywords
this._proxyRequests[url] = opts
return url