Compare commits

..

162 Commits

Author SHA1 Message Date
Julien Fontanet
9e4924caf6 5.4.1 2016-12-02 16:37:17 +01:00
Julien Fontanet
7f391a5860 Merge branch 'next-release' into stable 2016-12-02 16:37:13 +01:00
Julien Fontanet
5c7249c8fc fix(Xapi#exportDeltaVm): remove TAG_BASE_DELTA if full export
Fixes vatesfr/xo-web#1811
2016-12-02 16:09:27 +01:00
Pierre Donias
932d00133d feat(job-executor.match): __not pattern property (#459)
See vatesfr/xo-web#1503
2016-12-01 14:56:52 +01:00
Julien Fontanet
32a371bf13 chore(package): use golike-defer instead of custom implementation 2016-11-30 15:40:30 +01:00
Julien Fontanet
5d0622d2cf 5.4.0 2016-11-23 11:10:01 +01:00
Pierre Donias
9ab9155bf0 fix(vif.set): remove old VIF before creating new one (#457)
Fixes #1784
2016-11-23 10:38:24 +01:00
Julien Fontanet
86a1ed6d46 chore(package): remove unused nyc 2016-11-23 10:00:45 +01:00
Julien Fontanet
b3c9936d74 chore(package): update xen-api to v0.9.6 2016-11-23 09:58:04 +01:00
greenkeeper[bot]
21b4d7cf11 chore(package): update nyc to version 10.0.0 (#456)
https://greenkeeper.io/
2016-11-23 09:12:26 +01:00
greenkeeper[bot]
4ec07f9ff8 fix(package): update get-stream to version 3.0.0 (#458)
https://greenkeeper.io/
2016-11-23 09:11:39 +01:00
greenkeeper[bot]
b7c89d6f64 fix(package): update http-server-plus to version 0.8.0 (#454)
https://greenkeeper.io/
2016-11-18 14:44:50 +01:00
greenkeeper[bot]
0eb168ec70 fix(package): update uuid to version 3.0.0 (#453)
https://greenkeeper.io/
2016-11-18 09:10:07 +01:00
Olivier Lambert
8ac1a66e93 feat(sr.shared): new boolean property (#452) 2016-11-17 14:33:45 +01:00
badrAZ
301da3662a fix(plugin.test): data param is optional (#451) 2016-11-16 16:08:11 +01:00
greenkeeper[bot]
e474946cb7 fix(package): update xo-common to version 0.1.0 (#450)
https://greenkeeper.io/
2016-11-16 12:01:27 +01:00
Pierre Donias
9a0ca1ebb2 feat(api): map 10 XAPI errors to XO errors (#449)
Fixes vatesfr/xo-web#1481
2016-11-16 11:22:31 +01:00
Julien Fontanet
520f7b2a77 feat(job.create,job.set): ability to set userId (#448)
See vatesfr/xo-web#1733
2016-11-14 17:42:19 +01:00
Pierre Donias
c0b3b3aab8 Fix userId. 2016-11-14 16:59:10 +01:00
Pierre Donias
d499332ce3 It should be possible to not change a job's user. 2016-11-14 15:56:54 +01:00
Pierre Donias
19ce06e0bb feat(job#create,job#set): userId parameter
See vatesfr/xo-web#1733
2016-11-14 15:33:09 +01:00
greenkeeper[bot]
ea6ff4224e fix(package): update fs-promise to version 1.0.0 (#447)
https://greenkeeper.io/
2016-11-10 08:56:37 +01:00
Julien Fontanet
871d1f8632 fix(plugins registration): params order 2016-11-09 17:05:10 +01:00
badrAZ
77ce2ff6d1 feat(plugin.test): plugins can be tested (#446)
See vatesfr/xo-web#1749
2016-11-09 14:58:19 +01:00
Pierre Donias
6383104796 fix(Xapi#editPif): destroy VLAN from each PIF before creating new VLAN (#444) 2016-11-08 16:50:12 +01:00
Julien Fontanet
b99b4159c8 feat(Redis): support aliased commands
Fixes #443
2016-11-08 10:23:53 +01:00
Olivier Lambert
8bedb1f3b9 Merge pull request #442 from vatesfr/pierre-fix-xo-error
fix(api): xoError is not an object
2016-11-07 18:18:45 +01:00
Pierre Donias
dc85804a27 fix(api): xoError is not an object 2016-11-07 17:58:16 +01:00
greenkeeper[bot]
42a31e512a fix(package): update json-rpc-peer to version 0.13.0 (#441)
https://greenkeeper.io/
2016-11-07 14:57:53 +01:00
Pierre Donias
2be7388696 feat(api-errors): throw custom errors when XAPI error is caught (#440)
See vatesfr/xo-web#1717
2016-11-07 14:15:23 +01:00
Julien Fontanet
bc5b00781b 5.3.3 2016-11-04 11:44:09 +01:00
Olivier Lambert
313e2b3de6 fix(Sr): add type cifs in deviceConfig. Fixes vatesfr/xo-web#1615 (#439) 2016-11-04 11:42:03 +01:00
Julien Fontanet
0bbd002060 fix(xo.importConfig): dont unnecessarily delete existing users
Do not delete existing users with same name & id
2016-11-04 09:42:56 +01:00
Julien Fontanet
5e785266a5 fix(xo.importConfig): correctly import ACLs
Fixes vatesfr/xo-web#1722
2016-11-04 09:40:41 +01:00
Julien Fontanet
5870769e7d fix(vm.import{,Delta}Backup): make restored VMs identifiable
Their names is prefixed with the exported date and they have a specific tag (*restored from backup*).

Fixes vatesfr/xo-web#1719
2016-11-03 16:22:42 +01:00
Julien Fontanet
79b80dcd07 fix(pif#carrier): cast to boolean 2016-11-02 16:50:12 +01:00
Olivier Lambert
6f6e547e6c feat(pif): add carrier (#438)
Fixes vatesfr/xo-web#1702
2016-11-02 16:25:44 +01:00
greenkeeper[bot]
352c9357df chore(package): update dependencies (#437)
https://greenkeeper.io/
2016-11-01 19:05:11 +01:00
Pierre Donias
1ba4641641 feat(acls): handle xo.clean (#436) 2016-10-31 15:53:50 +01:00
Greenkeeper
60e0047285 chore(package): update helmet to version 3.0.0 (#435)
https://greenkeeper.io/
2016-10-29 12:52:18 +02:00
Pierre Donias
235e7c143c fix(signin): new Bootstrap classes (#434) 2016-10-28 10:11:41 +02:00
Julien Fontanet
522d6eed92 5.3.2 2016-10-27 18:49:32 +02:00
Julien Fontanet
9d1d6ea4c5 feat(xo): export/import config (#427)
See vatesfr/xo-web#786
2016-10-27 18:48:19 +02:00
Julien Fontanet
0afd506a41 5.3.1 2016-10-27 18:25:16 +02:00
Julien Fontanet
9dfb837e3f fix(Xapi#importDeltaVm): gracefully handle missing vif.$network$uuid (#433) 2016-10-27 16:46:45 +02:00
fufroma
4ab63b569f fix(RemoteHandlerNfs): move mount points in /run/xo-server/mounts
Fixes vatesfr/xo-web#1405
2016-10-27 15:56:33 +02:00
Julien Fontanet
8d390d256d fix(http-request): handle redirections (#432) 2016-10-27 15:34:54 +02:00
Julien Fontanet
4eec5e06fc fix(package): test on Node 6, not 7 (#431) 2016-10-27 12:24:40 +02:00
Julien Fontanet
e4063b1ba8 feat(sample.config.yaml): add warning about YAML 2016-10-24 22:52:11 +02:00
Greenkeeper
0c3227cf8e chore(package): update promise-toolbox to version 0.7.0 (#428)
https://greenkeeper.io/
2016-10-24 15:01:17 +02:00
Pierre Donias
7bed200bf5 feat(pif): editVlan (#426)
Fix vatesfr/xo-web#1092
2016-10-24 10:24:44 +02:00
Julien Fontanet
4f763e2109 5.3.0 2016-10-20 16:01:53 +02:00
Pierre Donias
75167fb65b feat(pif): expose IP config modes (#424)
See vatesfr/xo-web#1651
2016-10-20 12:44:35 +02:00
Julien Fontanet
675588f780 feat(delta backups): force checksums refresh
See vatesfr/xo-web#1672
2016-10-20 12:38:26 +02:00
Julien Fontanet
2d6f94edd8 fix(vhd-merge/chainVhd): correctly await _write()
Fixes vatesfr/xo-web#1672
2016-10-20 12:31:20 +02:00
Julien Fontanet
247c66ef4b feat(IP pools): can be used in resource sets (#413)
See vatesfr/xo-web#1565
2016-10-19 11:17:05 +02:00
Greenkeeper
1076fac40f Update gulp-sourcemaps to version 2.1.1 🚀 (#422)
https://greenkeeper.io/
2016-10-14 10:44:27 +02:00
Julien Fontanet
14a4a415a2 5.2.6 2016-10-13 18:51:16 +02:00
Julien Fontanet
524355b59c fix(vhd-merge/chainVhd): correctly compute header checksum (#419)
Fixes vatesfr/xo-web#1656
2016-10-13 18:49:58 +02:00
Greenkeeper
36fe49f3f5 Update promise-toolbox to version 0.6.0 🚀 (#416)
https://greenkeeper.io/
2016-10-12 09:19:19 +02:00
Greenkeeper
c0c0af9b14 chore(package): update execa to version 0.5.0 (#411)
https://greenkeeper.io/
2016-10-05 10:40:31 +02:00
Julien Fontanet
d1e472d482 chore(package): use babel-plugin-lodash 2016-10-04 16:05:01 +02:00
Julien Fontanet
c80e43ad0d fix(vm.create): don't require view perm on VM template 2016-10-04 16:03:06 +02:00
Julien Fontanet
fdd395e2b6 fix(vm.create): correctly check resourceSet objects
Related to vatesfr/xo-web#1620
2016-10-04 15:51:04 +02:00
Julien Fontanet
e094437168 fix(package): update xo-acl-resolver to version 0.2.2
See vatesfr/xo-web#1620
2016-10-04 15:24:01 +02:00
Pierre Donias
2ee0be7466 fix(xapi/utils/makeEditObject): constraints works with user props (#410) 2016-10-04 15:02:27 +02:00
Julien Fontanet
2784a7cc92 Create ISSUE_TEMPLATE.md 2016-10-03 16:24:24 +02:00
Julien Fontanet
b09f998d6c 5.2.5 2016-10-03 09:39:52 +02:00
Nicolas Raynaud
bdeb5895f6 fix(deltaBackups): update checksum after altering VHD files (#408)
Fixes vatesfr/xo-web#1606
2016-09-30 14:31:33 +02:00
Pierre Donias
3944b8aaee feat(network): create a bonded network (#407)
Fixes vatesfr/xo-web#876
2016-09-30 13:51:33 +02:00
Nicolas Raynaud
6e66cffb92 feat(deltaBackups): correctly chain VHDs (#406)
The goal is for a tool like vhdimount to be able to mount any file and use it as a disk to recover specific file in it.
2016-09-29 17:31:36 +02:00
Pierre Donias
57092ee788 feat(vif.set): support for network, MAC and currently_attached (#403)
Fixes vatesfr/xo-web#1446
2016-09-28 15:09:17 +02:00
Julien Fontanet
70e9e1c706 chore(package): update human-format to version 0.7.0 2016-09-28 09:58:54 +02:00
Greenkeeper
9662b8fbee chore(package): update babel-eslint to version 7.0.0 (#404)
https://greenkeeper.io/
2016-09-27 23:39:30 +02:00
Julien Fontanet
9f66421ae7 fix(bootstrap): C-c twice force stop the server 2016-09-27 10:44:24 +02:00
Greenkeeper
50584c2e50 chore(package): update http-server-plus to version 0.7.0 (#402)
https://greenkeeper.io/
2016-09-27 09:30:16 +02:00
Julien Fontanet
7be4e1901a chore(package): use index-modules 2016-09-26 15:41:41 +02:00
Julien Fontanet
b47146de45 fix(pbd/attached): should be a boolean 2016-09-22 13:20:49 +02:00
Julien Fontanet
97b229b2c7 fix(vm.set): works with VM templates
Fixes vatesfr/xo-web#1569
2016-09-22 10:39:20 +02:00
Julien Fontanet
6bb5bb9403 5.2.4 2016-09-21 10:20:46 +02:00
Julien Fontanet
8c4b8271d8 fix(pool.setDefaultSr): remove pool param
Fixes vatesfr/xo-web#1558
2016-09-20 11:45:36 +02:00
Julien Fontanet
69291c0574 chore(package): update xo-vmdk-to-vhd to version 0.0.12
Fixes vatesfr/xo-web#1551
2016-09-20 10:41:42 +02:00
Julien Fontanet
2dc073dcd6 fix(vm.resourceSet): handle xo namespace 2016-09-19 13:15:23 +02:00
Julien Fontanet
1894cb35d2 feat(vm): expose resourceSet prop 2016-09-19 12:10:09 +02:00
Julien Fontanet
cd37420b07 Merge pull request #398 from vatesfr/greenkeeper-standard-8.1.0
Update standard to version 8.1.0 🚀
2016-09-18 05:17:41 +02:00
Julien Fontanet
55cb6b39db fix(Xo#removeSchedule): correctly test instance of SchedulerError 2016-09-18 05:12:36 +02:00
greenkeeperio-bot
89d13b2285 chore(package): update standard to version 8.1.0
https://greenkeeper.io/
2016-09-17 20:51:59 +02:00
Julien Fontanet
1b64b0468a fix(group.delete): remove associated ACLs
Fixes vatesfr/xo-web#899
2016-09-16 16:04:41 +02:00
Julien Fontanet
085fb83294 fix(user.delete): remove associated ACLs
See vatesfr/xo-web#899
2016-09-16 16:04:41 +02:00
Julien Fontanet
edd606563f feat(vm.revert): can snapshot before (#395)
See vatesfr/xo-web#1445
2016-09-15 14:59:43 +02:00
Julien Fontanet
fb804e99f0 5.2.3 2016-09-14 18:02:32 +02:00
Pierre Donias
1707cbcb54 feat(signin): use XO 5 style (#394)
Fixes vatesfr/xo-web#1161
2016-09-14 17:56:05 +02:00
Julien Fontanet
6d6a630c31 5.2.2 2016-09-14 17:37:42 +02:00
Julien Fontanet
ff2990e8e5 chore(package): update @marsaud/smb2-promise to version 0.2.1
Fixes vatesfr/xo-web#1511
2016-09-14 17:32:52 +02:00
Nicolas Raynaud
d679aff0fb chore(package): remove node-smb2 dependency (#393) 2016-09-14 16:23:28 +02:00
Julien Fontanet
603a444905 fix(Xapi#importVm): remove VM's VDIs on failure 2016-09-14 14:11:20 +02:00
Julien Fontanet
a002958448 fix(DR): remove previous VDIs
Fixes vatesfr/xo-web#1510
2016-09-14 14:11:20 +02:00
Julien Fontanet
cb4bc37424 fix(DR): delete VMs in all cases
Previous copies were not deleted when there were as many as the depth.

Fixes vatesfr/xo-web#1509
2016-09-14 14:11:19 +02:00
Julien Fontanet
0fc6f917e6 5.2.1 2016-09-13 16:44:35 +02:00
Julien Fontanet
ec0d012b24 feat(vm.set): support tags (#392)
Fixes vatesfr/xo-web#1431
2016-09-13 16:35:40 +02:00
Julien Fontanet
2cd4b171a1 chore(package): update json5 to version 0.5.0 2016-09-13 11:28:56 +02:00
Julien Fontanet
0cb6906c4d chore(package): is-my-json-valid to v2.13.1 2016-09-13 11:25:22 +02:00
Julien Fontanet
4c19b93c30 chore(package): update fs-promise to version 0.5.0 2016-09-13 11:23:42 +02:00
Julien Fontanet
6165f1b405 fix(vm.create): select SR of first disk-VDI (#391)
Fixes vatesfr/xo-web#1493
2016-09-12 16:32:43 +02:00
Julien Fontanet
37a4221e43 fix(vm.docker.containers): yes, again 2016-09-12 12:13:45 +02:00
Julien Fontanet
9831b222b5 fix(vm.docker.containers) 2016-09-12 12:11:15 +02:00
Julien Fontanet
7b6f44fb74 fix(vm.createInterface): syntax fix 2016-09-12 12:06:34 +02:00
Julien Fontanet
399f4d0ea3 feat(vm.docker.containers): like vm.docker.process.items but always an array 2016-09-12 11:43:36 +02:00
Julien Fontanet
26a668a875 fix(vm.createInterface): accept integers for position and mtu 2016-09-12 11:36:30 +02:00
Julien Fontanet
bf96262b6e feat(Xapi#createVif): default MTU is network's MTU 2016-09-12 11:05:31 +02:00
Julien Fontanet
1155fa1fe9 chore(vm.create): remove some console.log()s 2016-09-09 15:31:25 +02:00
Julien Fontanet
1875d31731 5.2.0 2016-09-09 15:16:03 +02:00
Julien Fontanet
6f855fd14e feat(IP pools): groups of IP addresses (#371) 2016-09-09 15:12:30 +02:00
Julien Fontanet
08e392bb46 fix(vm.create): correctly compute limits usage (#389)
Fixes vatesfr/xo-web#1365
2016-09-09 12:55:10 +02:00
Julien Fontanet
66d63e0546 fix(test.wait): fix setTimeout params order 2016-09-08 18:40:55 +02:00
Julien Fontanet
7ee56fe8bc feat(pool.installAllPatches): install all patches on a pool (#388)
See vatesfr/xo-web#1392
2016-09-07 17:54:00 +02:00
Julien Fontanet
669d04ee48 fix(vm.migrate): error on unused default SR
Fixes #1466
2016-09-05 14:21:17 +02:00
Julien Fontanet
cb1b37326e fix(vm.rollingDrCopy): avoid duplicates in VMs list (#387)
Fixes vatesfr/xo-web#1464
2016-09-05 13:41:20 +02:00
Julien Fontanet
7bb73bee67 feat(vm.rollingDrCopy): failure to destroy old copies is not fatal 2016-09-05 11:29:54 +02:00
Julien Fontanet
7286ddc338 chore(JobExecutor): use utils/serializeError() 2016-09-05 11:29:53 +02:00
Olivier Lambert
7d1f9e33fe feat(network): add defaultIsLocked to API (#385) 2016-09-01 14:49:20 +02:00
Ronan Abhamon
63c676ebfe feat(vm.import): supports OVA (#375)
See vatesfr/xo-web#709
2016-09-01 14:11:15 +02:00
Greenkeeper
fcaf6b7923 chore(package): update json-rpc-peer to version 0.12.0 (#383)
https://greenkeeper.io/
2016-08-25 11:56:54 -04:00
Julien Fontanet
9f347a170a fix(xapi/utils): correctly isPlainObject 2016-08-18 16:21:34 +02:00
Julien Fontanet
2f7cd4426d fix(xapi/utils/prepareXapiParam): array handling 2016-08-18 16:15:51 +02:00
Julien Fontanet
854f256470 fix(xapi/getNamespaceForType): add missing VIF 2016-08-18 15:27:47 +02:00
Julien Fontanet
5d0b40f752 fix(utils/camelToSnakeCase): better number handling 2016-08-18 15:23:57 +02:00
Julien Fontanet
27a2853ee8 fix(vif.set): add missing param 2016-08-18 15:13:46 +02:00
Julien Fontanet
67f6b80312 fix(vif.set): do not use an arrow function 2016-08-18 15:01:13 +02:00
Julien Fontanet
016037adc1 fix(user.set): can be used by non admins 2016-08-18 14:17:07 +02:00
Julien Fontanet
70d5c1034d 5.1.6 2016-08-18 10:54:36 +02:00
Greenkeeper
ed6fb8754f chore(package): update mocha to version 3.0.2 (#376)
https://greenkeeper.io/
2016-08-18 10:53:05 +02:00
Julien Fontanet
6d08a9b11c feat(JobExecutor): a current job will only run 2 calls at a time (#382)
Fixes vatesfr/xo-web#915
2016-08-18 10:52:29 +02:00
Julien Fontanet
cf6aa7cf79 fix(package): update xen-api to 0.9.4
Again, fixes vatesfr/xo-web#1384
2016-08-18 09:42:28 +02:00
Julien Fontanet
6c4e57aae0 chore(JobExecutor#_execCall): forEach+Array#push → mapToArray 2016-08-17 18:13:30 +02:00
Julien Fontanet
d08a04959c 5.1.5 2016-08-16 19:15:52 +02:00
Julien Fontanet
2762f74ce5 fix(package): update xen-api to 0.9.3 2016-08-16 19:12:46 +02:00
Julien Fontanet
6ebcf6eec5 5.1.4 2016-08-16 18:18:04 +02:00
Julien Fontanet
25b78fb7e1 fix(package): update xen-api to 0.9.2
Fixes vatesfr/xo-web#1384
2016-08-16 18:15:32 +02:00
Greenkeeper
670dd2dd96 chore(package): update promise-toolbox to version 0.5.0 (#381)
https://greenkeeper.io/
2016-08-16 12:22:57 +02:00
Julien Fontanet
1baf04f786 fix(NfsHandler#_unmount): use _getRealPath() (#380)
Fixes vatesfr/xo-web#1396.
2016-08-15 14:22:19 +02:00
Greenkeeper
ce05b7a041 chore(package): update nyc to version 8.1.0 (#379)
https://greenkeeper.io/
2016-08-14 19:06:00 +02:00
Olivier Lambert
290cc146c8 fix(xapi): allow to unplug VBDs when VM is running 2016-08-11 16:32:06 +02:00
Olivier Lambert
db4d46a584 fix(sr): don't share a local ISO SR. Fixes vatesfr/xo-web#1389 2016-08-10 14:39:05 +02:00
Olivier Lambert
8ed2e51dde feat(network): add network.set method 2016-08-08 14:54:23 +02:00
Olivier Lambert
33702c09a6 feat(vm copy): allow snapshot copy. Related to vatesfr/xo-web#1353 2016-08-08 14:07:27 +02:00
Olivier Lambert
45aeca3753 5.1.3 2016-08-05 11:08:11 +02:00
Olivier Lambert
deae7dfb4d fix(xen-api): avoid reserved key conflicts. Fixes vatesfr/xo-web#1369 2016-08-05 11:06:58 +02:00
Julien Fontanet
2af043ebdd chore(jshint): remove unused config file 2016-08-03 09:46:52 +02:00
Olivier Lambert
e121295735 Merge pull request #373 from nraynaud/next-release
fix (readme): fix installation documentation link
2016-08-02 12:32:05 +02:00
Nicolas Raynaud
7c1c405a64 fix installation documentation link 2016-08-02 12:22:39 +02:00
Olivier Lambert
5d7c95a34d fix(xapi): typo on host disable method. Fixes vatesfr/xo-web#1351 2016-07-30 20:22:12 +02:00
Julien Fontanet
504c934fc9 fix(JobExecutor#_execCall): xo.api.call() → xo.callApiMethod() 2016-07-29 15:28:24 +02:00
Julien Fontanet
81b0223f73 fix(JobExecutor#exec): forward the error 2016-07-29 15:27:58 +02:00
Julien Fontanet
6d1e410bfd fix(JobExecutor#exec): correctly log the error 2016-07-29 15:27:32 +02:00
Julien Fontanet
26c5c6152d fix(job-executor/map): paramName handling 2016-07-29 14:37:42 +02:00
Julien Fontanet
d83bf0ebaf fix(Xo#_watchObject): check for notify() 2016-07-29 14:29:57 +02:00
Julien Fontanet
5adfe9a552 chore(index): remove debug trace 2016-07-29 13:54:54 +02:00
ABHAMON Ronan
883f461dc7 feat(job-executor): supports dynamic param vectors (#369)
See vatesfr/xo-web#837
2016-07-29 13:26:53 +02:00
Julien Fontanet
8595ebc258 feat(api): generate logs on errors
See vatesfr/xo-web#1344
2016-07-29 10:32:48 +02:00
Julien Fontanet
2bd31f4560 chore(api): remove legacy helpers 2016-07-28 15:21:59 +02:00
Julien Fontanet
6df85ecadd fix(vm.*): add missing import 2016-07-28 15:21:59 +02:00
74 changed files with 2792 additions and 1724 deletions

View File

@@ -1,93 +0,0 @@
{
// Julien Fontanet JSHint configuration
// https://gist.github.com/julien-f/8095615
//
// Changes from defaults:
// - all enforcing options (except `++` & `--`) enabled
// - single quotes
// - indentation set to 2 instead of 4
// - almost all relaxing options disabled
// - environments are set to Node.js
//
// See http://jshint.com/docs/ for more details
"maxerr" : 50, // {int} Maximum error before stopping
// Enforcing
"bitwise" : true, // true: Prohibit bitwise operators (&, |, ^, etc.)
"camelcase" : true, // true: Identifiers must be in camelCase
"curly" : true, // true: Require {} for every new block or scope
"eqeqeq" : true, // true: Require triple equals (===) for comparison
"forin" : true, // true: Require filtering for..in loops with obj.hasOwnProperty()
"freeze" : true, // true: Prohibit overwriting prototypes of native objects (Array, Date, ...)
"immed" : true, // true: Require immediate invocations to be wrapped in parens e.g. `(function () { } ());`
"indent" : 2, // {int} Number of spaces to use for indentation
"latedef" : true, // true: Require variables/functions to be defined before being used
"newcap" : true, // true: Require capitalization of all constructor functions e.g. `new F()`
"noarg" : true, // true: Prohibit use of `arguments.caller` and `arguments.callee`
"noempty" : true, // true: Prohibit use of empty blocks
"nonbsp" : true, // true: Prohibit use of non breakable spaces
"nonew" : true, // true: Prohibit use of constructors for side-effects (without assignment)
"plusplus" : false, // true: Prohibit use of `++` & `--`
"quotmark" : "single", // Quotation mark consistency:
// false : do nothing (default)
// true : ensure whatever is used is consistent
// "single" : require single quotes
// "double" : require double quotes
"undef" : true, // true: Require all non-global variables to be declared (prevents global leaks)
"unused" : true, // true: Require all defined variables be used
"strict" : false, // true: Requires all functions run in ES5 Strict Mode
"maxcomplexity" : 7, // {int} Max cyclomatic complexity per function
"maxdepth" : 3, // {int} Max depth of nested blocks (within functions)
"maxlen" : 80, // {int} Max number of characters per line
"maxparams" : 4, // {int} Max number of formal params allowed per function
"maxstatements" : 20, // {int} Max number statements per function
// Relaxing
"asi" : false, // true: Tolerate Automatic Semicolon Insertion (no semicolons)
"boss" : false, // true: Tolerate assignments where comparisons would be expected
"debug" : false, // true: Allow debugger statements e.g. browser breakpoints.
"eqnull" : false, // true: Tolerate use of `== null`
"esnext" : true, // true: Allow ES.next (ES6) syntax (ex: `const`)
"evil" : false, // true: Tolerate use of `eval` and `new Function()`
"expr" : false, // true: Tolerate `ExpressionStatement` as Programs
"funcscope" : false, // true: Tolerate defining variables inside control statements
"globalstrict" : false, // true: Allow global "use strict" (also enables 'strict')
"iterator" : false, // true: Tolerate using the `__iterator__` property
"lastsemic" : false, // true: Tolerate omitting a semicolon for the last statement of a 1-line block
"laxbreak" : false, // true: Tolerate possibly unsafe line breakings
"laxcomma" : false, // true: Tolerate comma-first style coding
"loopfunc" : false, // true: Tolerate functions being defined in loops
"moz" : false, // true: Allow Mozilla specific syntax (extends and overrides esnext features)
// (ex: `for each`, multiple try/catch, function expression…)
"multistr" : false, // true: Tolerate multi-line strings
"notypeof" : false, // true: Tolerate typeof comparison with unknown values.
"proto" : false, // true: Tolerate using the `__proto__` property
"scripturl" : false, // true: Tolerate script-targeted URLs
"shadow" : false, // true: Allows re-define variables later in code e.g. `var x=1; x=2;`
"sub" : false, // true: Tolerate using `[]` notation when it can still be expressed in dot notation
"supernew" : false, // true: Tolerate `new function () { ... };` and `new Object;`
"validthis" : false, // true: Tolerate using this in a non-constructor function
"noyield" : false, // true: Tolerate generators without yields
// Environments
"browser" : false, // Web Browser (window, document, etc)
"browserify" : false, // Browserify (node.js code in the browser)
"couch" : false, // CouchDB
"devel" : false, // Development/debugging (alert, confirm, etc)
"dojo" : false, // Dojo Toolkit
"jquery" : false, // jQuery
"mocha" : false, // mocha
"mootools" : false, // MooTools
"node" : true, // Node.js
"nonstandard" : false, // Widely adopted globals (escape, unescape, etc)
"phantom" : false, // PhantomJS
"prototypejs" : false, // Prototype and Scriptaculous
"rhino" : false, // Rhino
"worker" : false, // Web Workers
"wsh" : false, // Windows Scripting Host
"yui" : false, // Yahoo User Interface
// Custom Globals
"globals" : {} // additional predefined global variables
}

View File

@@ -1,8 +1,7 @@
language: node_js
node_js:
# - 'stable'
- '6'
- '4'
- '0.12'
# Use containers.
# http://docs.travis-ci.com/user/workers/container-based-infrastructure/

3
ISSUE_TEMPLATE.md Normal file
View File

@@ -0,0 +1,3 @@
# ALL ISSUES SHOULD BE CREATED IN XO-WEB'S TRACKER!
https://github.com/vatesfr/xo-web/issues

View File

@@ -19,7 +19,7 @@ ___
## Installation
Manual install procedure is [available here](https://github.com/vatesfr/xo/blob/master/doc/installation/README.md#installation).
Manual install procedure is [available here](https://xen-orchestra.com/docs/from_the_sources.html).
## Compilation

View File

@@ -1,6 +1,6 @@
{
"name": "xo-server",
"version": "5.1.2",
"version": "5.4.1",
"license": "AGPL-3.0",
"description": "Server part of Xen-Orchestra",
"keywords": [
@@ -34,8 +34,8 @@
"node": ">=0.12"
},
"dependencies": {
"@marsaud/smb2": "^0.7.1",
"@marsaud/smb2-promise": "^0.2.0",
"@marsaud/smb2-promise": "^0.2.1",
"@nraynaud/struct-fu": "^1.0.1",
"app-conf": "^0.4.0",
"babel-runtime": "^6.5.0",
"base64url": "^2.0.0",
@@ -51,23 +51,25 @@
"escape-string-regexp": "^1.0.3",
"event-to-promise": "^0.7.0",
"exec-promise": "^0.6.1",
"execa": "^0.4.0",
"execa": "^0.5.0",
"express": "^4.13.3",
"express-session": "^1.11.3",
"fatfs": "^0.10.3",
"fs-extra": "^0.30.0",
"fs-promise": "^0.4.1",
"get-stream": "^2.1.0",
"hashy": "~0.4.2",
"helmet": "^2.0.0",
"fs-extra": "^1.0.0",
"fs-promise": "^1.0.0",
"get-stream": "^3.0.0",
"golike-defer": "^0.0.0",
"hashy": "~0.5.1",
"helmet": "^3.0.0",
"highland": "^2.5.1",
"http-proxy": "^1.13.2",
"http-server-plus": "^0.6.4",
"human-format": "^0.6.0",
"is-my-json-valid": "^2.12.2",
"http-server-plus": "^0.8.0",
"human-format": "^0.7.0",
"is-my-json-valid": "^2.13.1",
"is-redirect": "^1.0.0",
"js-yaml": "^3.2.7",
"json-rpc-peer": "^0.11.0",
"json5": "^0.4.0",
"json-rpc-peer": "^0.13.0",
"json5": "^0.5.0",
"julien-f-source-map-support": "0.0.0",
"julien-f-unzip": "^0.2.1",
"kindof": "^2.0.0",
@@ -86,7 +88,7 @@
"partial-stream": "0.0.0",
"passport": "^0.3.0",
"passport-local": "^1.0.0",
"promise-toolbox": "^0.3.2",
"promise-toolbox": "^0.7.0",
"proxy-agent": "^2.0.0",
"pug": "^2.0.0-alpha6",
"redis": "^2.0.1",
@@ -94,18 +96,22 @@
"semver": "^5.1.0",
"serve-static": "^1.9.2",
"stack-chain": "^1.3.3",
"struct-fu": "^1.0.0",
"tar-stream": "^1.5.2",
"through2": "^2.0.0",
"trace": "^2.0.1",
"uuid": "^3.0.0",
"ws": "^1.1.1",
"xen-api": "^0.9.0",
"xen-api": "^0.9.6",
"xml2js": "~0.4.6",
"xo-acl-resolver": "^0.2.1",
"xo-acl-resolver": "^0.2.2",
"xo-collection": "^0.4.0",
"xo-remote-parser": "^0.3"
"xo-common": "0.1.0",
"xo-remote-parser": "^0.3",
"xo-vmdk-to-vhd": "0.0.12"
},
"devDependencies": {
"babel-eslint": "^6.0.4",
"babel-eslint": "^7.0.0",
"babel-plugin-lodash": "^3.2.9",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-runtime": "^6.5.2",
"babel-preset-es2015": "^6.5.0",
@@ -117,20 +123,20 @@
"gulp-babel": "^6",
"gulp-coffee": "^2.3.1",
"gulp-plumber": "^1.0.0",
"gulp-sourcemaps": "^1.5.1",
"gulp-sourcemaps": "^2.1.1",
"gulp-watch": "^4.2.2",
"index-modules": "0.1.0",
"leche": "^2.1.1",
"mocha": "^2.2.1",
"mocha": "^3.0.2",
"must": "^0.13.1",
"nyc": "^7.0.0",
"rimraf": "^2.5.2",
"sinon": "^1.14.1",
"standard": "^7.0.0"
"standard": "^8.1.0"
},
"scripts": {
"build": "npm run build-indexes && gulp build --production",
"depcheck": "dependency-check ./package.json",
"build-indexes": "./tools/generate-index src/api src/xapi/mixins src/xo-mixins",
"build-indexes": "index-modules src/api src/xapi/mixins src/xo-mixins",
"dev": "npm run build-indexes && gulp build",
"dev-test": "mocha --opts .mocha.opts --watch --reporter=min \"dist/**/*.spec.js\"",
"lint": "standard",
@@ -140,10 +146,11 @@
"prerelease": "git checkout next-release && git pull --ff-only && git checkout stable && git pull --ff-only && git merge next-release",
"release": "npm version",
"start": "node bin/xo-server",
"test": "nyc mocha --opts .mocha.opts \"dist/**/*.spec.js\""
"test": "mocha --opts .mocha.opts \"dist/**/*.spec.js\""
},
"babel": {
"plugins": [
"lodash",
"transform-decorators-legacy",
"transform-runtime"
],

View File

@@ -1,11 +1,17 @@
# Example XO-Server configuration.
# BE *VERY* CAREFUL WHEN EDITING!
# YAML FILES ARE SUPER SUPER SENSITIVE TO MISTAKES IN WHITESPACE OR ALIGNMENT!
# visit http://www.yamllint.com/ to validate this file as needed
#=====================================================================
# Example XO-Server configuration.
#
# This file is automatically looking for at the following places:
# - `$HOME/.config/xo-server/config.yaml`
# - `/etc/xo-server/config.yaml`
#
# The first entries have priority.
#
# Note: paths are relative to the configuration file.
#=====================================================================
@@ -117,10 +123,18 @@ http:
# Connection to the Redis server.
redis:
# Syntax: redis://[db[:password]@]hostname[:port]
# Syntax: redis://[db[:password]@]hostname[:port][/db-number]
#
# Default: redis://localhost:6379
#uri: ''
# Default: redis://localhost:6379/0
#uri: redis://redis.company.lan/42
# List of aliased commands.
#
# See http://redis.io/topics/security#disabling-of-specific-commands
#renameCommands:
# del: '3dda29ad-3015-44f9-b13b-fa570de92489'
# srem: '3fd758c9-5610-4e9d-a058-dbf4cb6d8bf0'
# Directory containing the database of XO.
# Currently used for logs.

View File

@@ -6,55 +6,45 @@ html
meta(name = 'viewport' content = 'width=device-width, initial-scale=1.0')
title Xen Orchestra
meta(name = 'author' content = 'Vates SAS')
link(rel = 'stylesheet' href = 'styles/main.css')
link(rel = 'stylesheet' href = 'v4/styles/main.css')
body
.container
.row-login
.page-header
img(src = 'images/logo_small.png')
h2 Xen Orchestra
form.form-horizontal(action = 'signin/local' method = 'post')
fieldset
legend.login
h3 Sign in
if error
p.text-danger #{error}
.form-group
.col-sm-12
.input-group
span.input-group-addon
i.xo-icon-user.fa-fw
input.form-control.input-sm(
name = 'username'
type = 'text'
placeholder = 'Username'
required
)
.form-group
.col-sm-12
.input-group
span.input-group-addon
i.fa.fa-key.fa-fw
input.form-control.input-sm(
name = 'password'
type = 'password'
placeholder = 'Password'
required
)
.form-group
.col-sm-5
.checkbox
label
input(
name = 'remember-me'
type = 'checkbox'
)
| Remember me
.form-group
.col-sm-12
button.btn.btn-login.btn-block.btn-success
i.fa.fa-sign-in
| Sign in
each label, id in strategies
div: a(href = 'signin/' + id) Sign in with #{label}
link(rel = 'stylesheet' href = 'index.css')
body(style = 'display: flex; height: 100vh;')
div(style = 'margin: auto; width: 20em;')
div.mb-2(style = 'display: flex;')
img(src = 'assets/logo.png' style = 'margin: auto;')
h2.text-xs-center.mb-2 Xen Orchestra
form(action = 'signin/local' method = 'post')
fieldset
if error
p.text-danger #{error}
.input-group.mb-1
span.input-group-addon
i.xo-icon-user.fa-fw
input.form-control(
name = 'username'
type = 'text'
placeholder = 'Username'
required
)
.input-group.mb-1
span.input-group-addon
i.fa.fa-key.fa-fw
input.form-control(
name = 'password'
type = 'password'
placeholder = 'Password'
required
)
.checkbox
label
input(
name = 'remember-me'
type = 'checkbox'
)
|  
| Remember me
div
button.btn.btn-block.btn-info
i.fa.fa-sign-in
| Sign in
each label, id in strategies
div: a(href = 'signin/' + id) Sign in with #{label}

View File

@@ -1,70 +0,0 @@
import {JsonRpcError} from 'json-rpc-peer'
// ===================================================================
// Export standard JSON-RPC errors.
export { // eslint-disable-line no-duplicate-imports
InvalidJson,
InvalidParameters,
InvalidRequest,
JsonRpcError,
MethodNotFound
} from 'json-rpc-peer'
// -------------------------------------------------------------------
export class NotImplemented extends JsonRpcError {
constructor () {
super('not implemented', 0)
}
}
// -------------------------------------------------------------------
export class NoSuchObject extends JsonRpcError {
constructor (id, type) {
super('no such object', 1, {id, type})
}
}
// -------------------------------------------------------------------
export class Unauthorized extends JsonRpcError {
constructor () {
super('not authenticated or not enough permissions', 2)
}
}
// -------------------------------------------------------------------
export class InvalidCredential extends JsonRpcError {
constructor () {
super('invalid credential', 3)
}
}
// -------------------------------------------------------------------
export class AlreadyAuthenticated extends JsonRpcError {
constructor () {
super('already authenticated', 4)
}
}
// -------------------------------------------------------------------
export class ForbiddenOperation extends JsonRpcError {
constructor (operation, reason) {
super(`forbidden operation: ${operation}`, 5, reason)
}
}
// -------------------------------------------------------------------
// To be used with a user-readable message.
// The message can be destined to be displayed to the front-end user.
export class GenericError extends JsonRpcError {
constructor (message) {
super(message, 6)
}
}

0
src/api/.index-modules Normal file
View File

View File

@@ -7,8 +7,7 @@ startsWith = require 'lodash/startsWith'
{coroutine: $coroutine} = require 'bluebird'
{
extractProperty,
parseXml,
promisify
parseXml
} = require '../utils'
#=====================================================================

39
src/api/ip-pool.js Normal file
View File

@@ -0,0 +1,39 @@
import { unauthorized } from 'xo-common/api-errors'
export function create (props) {
return this.createIpPool(props)
}
create.permission = 'admin'
// -------------------------------------------------------------------
function delete_ ({ id }) {
return this.deleteIpPool(id)
}
export { delete_ as delete }
delete_.permission = 'admin'
// -------------------------------------------------------------------
export function getAll (params) {
const { user } = this
if (!user) {
throw unauthorized()
}
return this.getAllIpPools(user.permission === 'admin'
? params && params.userId
: user.id
)
}
// -------------------------------------------------------------------
export function set ({ id, ...props }) {
return this.updateIpPool(id, props)
}
set.permission = 'admin'

View File

@@ -18,7 +18,11 @@ get.params = {
}
export async function create ({job}) {
return (await this.createJob(this.session.get('user_id'), job)).id
if (!job.userId) {
job.userId = this.session.get('user_id')
}
return (await this.createJob(job)).id
}
create.permission = 'admin'
@@ -27,6 +31,7 @@ create.params = {
job: {
type: 'object',
properties: {
userId: {type: 'string', optional: true},
name: {type: 'string', optional: true},
type: {type: 'string'},
key: {type: 'string'},
@@ -38,14 +43,7 @@ create.params = {
items: {
type: 'array',
items: {
type: 'object',
properties: {
type: {type: 'string'},
values: {
type: 'array',
items: {type: 'object'}
}
}
type: 'object'
}
}
},
@@ -77,14 +75,7 @@ set.params = {
items: {
type: 'array',
items: {
type: 'object',
properties: {
type: {type: 'string'},
values: {
type: 'array',
items: {type: 'object'}
}
}
type: 'object'
}
}
},

View File

@@ -1,3 +1,9 @@
import { mapToArray } from '../utils'
export function getBondModes () {
return ['balance-slb', 'active-backup', 'lacp']
}
export async function create ({ pool, name, description, pif, mtu = 1500, vlan = 0 }) {
return this.getXapi(pool).createNetwork({
name,
@@ -24,6 +30,80 @@ create.permission = 'admin'
// =================================================================
export async function createBonded ({ pool, name, description, pifs, mtu = 1500, mac, bondMode }) {
return this.getXapi(pool).createBondedNetwork({
name,
description,
pifIds: mapToArray(pifs, pif =>
this.getObject(pif, 'PIF')._xapiId
),
mtu: +mtu,
mac,
bondMode
})
}
createBonded.params = {
pool: { type: 'string' },
name: { type: 'string' },
description: { type: 'string', optional: true },
pifs: {
type: 'array',
items: {
type: 'string'
}
},
mtu: { type: ['integer', 'string'], optional: true },
// RegExp since schema-inspector does not provide a param check based on an enumeration
bondMode: { type: 'string', pattern: new RegExp(`^(${getBondModes().join('|')})$`) }
}
createBonded.resolve = {
pool: ['pool', 'pool', 'administrate']
}
createBonded.permission = 'admin'
// ===================================================================
export async function set ({
network,
name_description: nameDescription,
name_label: nameLabel,
defaultIsLocked,
id
}) {
await this.getXapi(network).setNetworkProperties(network._xapiId, {
nameDescription,
nameLabel,
defaultIsLocked
})
}
set.params = {
id: {
type: 'string'
},
name_label: {
type: 'string',
optional: true
},
name_description: {
type: 'string',
optional: true
},
defaultIsLocked: {
type: 'boolean',
optional: true
}
}
set.resolve = {
network: ['id', 'network', 'administrate']
}
// =================================================================
export async function delete_ ({ network }) {
return this.getXapi(network).deleteNetwork(network._xapiId)
}

View File

@@ -1,5 +1,15 @@
// TODO: too low level, move into host.
import { IPV4_CONFIG_MODES, IPV6_CONFIG_MODES } from '../xapi'
export function getIpv4ConfigurationModes () {
return IPV4_CONFIG_MODES
}
export function getIpv6ConfigurationModes () {
return IPV6_CONFIG_MODES
}
// ===================================================================
// Delete
@@ -66,3 +76,18 @@ reconfigureIp.params = {
reconfigureIp.resolve = {
pif: ['id', 'PIF', 'administrate']
}
// ===================================================================
export async function editPif ({ pif, vlan }) {
await this.getXapi(pif).editPif(pif._xapiId, { vlan })
}
editPif.params = {
id: { type: 'string' },
vlan: { type: ['integer', 'string'] }
}
editPif.resolve = {
pif: ['id', 'PIF', 'administrate']
}

View File

@@ -102,3 +102,24 @@ purgeConfiguration.params = {
}
purgeConfiguration.permission = 'admin'
// ---------------------------------------------------------------------
export async function test ({ id, data }) {
await this.testPlugin(id, data)
}
test.description = 'Test a plugin with its current configuration'
test.params = {
id: {
type: 'string'
},
data: {
optional: true
}
}
test.permission = 'admin'
// ---------------------------------------------------------------------

View File

@@ -1,5 +1,3 @@
import {GenericError} from '../api-errors'
// ===================================================================
export async function set ({
@@ -35,21 +33,21 @@ set.resolve = {
// -------------------------------------------------------------------
export async function setDefaultSr ({pool, sr}) {
await this.getXapi(pool).setDefaultSr(sr._xapiId)
export async function setDefaultSr ({ sr }) {
await this.hasPermissions(this.user.id, [ [ sr.$pool, 'administrate' ] ])
await this.getXapi(sr).setDefaultSr(sr._xapiId)
}
setDefaultSr.permission = '' // signed in
setDefaultSr.params = {
pool: {
type: 'string'
},
sr: {
type: 'string'
}
}
setDefaultSr.resolve = {
pool: ['pool', 'pool', 'administrate'],
sr: ['sr', 'SR']
}
// -------------------------------------------------------------------
@@ -67,6 +65,21 @@ installPatch.params = {
}
}
installPatch.resolve = {
pool: ['pool', 'pool', 'administrate']
}
// -------------------------------------------------------------------
export async function installAllPatches ({ pool }) {
await this.getXapi(pool).installAllPoolPatchesOnAllHosts()
}
installPatch.params = {
pool: {
type: 'string'
}
}
installPatch.resolve = {
pool: ['pool', 'pool', 'administrate']
}
@@ -106,12 +119,7 @@ export {uploadPatch as patch}
// -------------------------------------------------------------------
export async function mergeInto ({ source, target, force }) {
try {
await this.mergeXenPools(source._xapiId, target._xapiId, force)
} catch (e) {
// FIXME: should we expose plain XAPI error messages?
throw new GenericError(e.message)
}
await this.mergeXenPools(source._xapiId, target._xapiId, force)
}
mergeInto.params = {

View File

@@ -1,6 +1,6 @@
import {
Unauthorized
} from '../api-errors'
unauthorized
} from 'xo-common/api-errors'
// ===================================================================
@@ -51,11 +51,12 @@ delete_.params = {
// -------------------------------------------------------------------
export function set ({ id, name, subjects, objects, limits }) {
export function set ({ id, name, subjects, objects, ipPools, limits }) {
return this.updateResourceSet(id, {
limits,
name,
objects,
ipPools,
subjects
})
}
@@ -84,6 +85,13 @@ set.params = {
},
optional: true
},
ipPools: {
type: 'array',
items: {
type: 'string'
},
optional: true
},
limits: {
type: 'object',
optional: true
@@ -109,7 +117,7 @@ get.params = {
export async function getAll () {
const { user } = this
if (!user) {
throw new Unauthorized()
throw unauthorized()
}
return this.getAllResourceSets(user.id)

View File

@@ -1,21 +1,18 @@
import {deprecate} from 'util'
import {InvalidCredential, AlreadyAuthenticated} from '../api-errors'
import { getUserPublicProperties } from '../utils'
import {invalidCredentials} from 'xo-common/api-errors'
// ===================================================================
export async function signIn (credentials) {
if (this.session.has('user_id')) {
throw new AlreadyAuthenticated()
}
const user = await this.authenticateUser(credentials)
if (!user) {
throw new InvalidCredential()
throw invalidCredentials()
}
this.session.set('user_id', user.id)
return this.getUserPublicProperties(user)
return getUserPublicProperties(user)
}
signIn.description = 'sign in'
@@ -55,7 +52,7 @@ export async function getUser () {
return userId === undefined
? null
: this.getUserPublicProperties(await this.getUser(userId))
: getUserPublicProperties(await this.getUser(userId))
}
getUser.description = 'return the currently connected user'

View File

@@ -121,6 +121,7 @@ export async function createIso ({
deviceConfig.legacy_mode = 'true'
} else if (type === 'smb') {
path = path.replace(/\\/g, '/')
deviceConfig.type = 'cifs'
deviceConfig.username = user
deviceConfig.cifspassword = password
}
@@ -136,7 +137,7 @@ export async function createIso ({
nameDescription,
'iso', // SR type ISO
'iso', // SR content type ISO
true,
type !== 'local',
{}
)

67
src/api/system.js Normal file
View File

@@ -0,0 +1,67 @@
import forEach from 'lodash/forEach'
import getKeys from 'lodash/keys'
import moment from 'moment-timezone'
import { noSuchObject } from 'xo-common/api-errors'
import { version as xoServerVersion } from '../../package.json'
// ===================================================================
export function getMethodsInfo () {
const methods = {}
forEach(this.apiMethods, (method, name) => {
methods[name] = {
description: method.description,
params: method.params || {},
permission: method.permission
}
})
return methods
}
getMethodsInfo.description = 'returns the signatures of all available API methods'
// -------------------------------------------------------------------
export const getServerTimezone = (tz => () => tz)(moment.tz.guess())
getServerTimezone.description = 'return the timezone server'
// -------------------------------------------------------------------
export const getServerVersion = () => xoServerVersion
getServerVersion.description = 'return the version of xo-server'
// -------------------------------------------------------------------
export const getVersion = () => '0.1'
getVersion.description = 'API version (unstable)'
// -------------------------------------------------------------------
export function listMethods () {
return getKeys(this.apiMethods)
}
listMethods.description = 'returns the name of all available API methods'
// -------------------------------------------------------------------
export function methodSignature ({method: name}) {
const method = this.apiMethods[name]
if (!method) {
throw noSuchObject()
}
// Return an array for compatibility with XML-RPC.
return [
// XML-RPC require the name of the method.
{
name,
description: method.description,
params: method.params || {},
permission: method.permission
}
]
}
methodSignature.description = 'returns the signature of an API method'

View File

@@ -36,9 +36,9 @@ hasPermission.params = {
export function wait ({duration, returnValue}) {
return new Promise(resolve => {
setTimeout(+duration, () => {
setTimeout(() => {
resolve(returnValue)
})
}, +duration)
})
}

View File

@@ -1,10 +1,10 @@
import {InvalidParameters} from '../api-errors'
import { mapToArray } from '../utils'
import {invalidParameters} from 'xo-common/api-errors'
import { getUserPublicProperties, mapToArray } from '../utils'
// ===================================================================
export async function create ({email, password, permission}) {
return (await this.createUser(email, {password, permission})).id
return (await this.createUser({email, password, permission})).id
}
create.description = 'creates a new user'
@@ -22,7 +22,7 @@ create.params = {
// Deletes an existing user.
async function delete_ ({id}) {
if (id === this.session.get('user_id')) {
throw new InvalidParameters('a user cannot delete itself')
throw invalidParameters('a user cannot delete itself')
}
await this.deleteUser(id)
@@ -48,7 +48,7 @@ export async function getAll () {
const users = await this.getAllUsers()
// Filters out private properties.
return mapToArray(users, this.getUserPublicProperties)
return mapToArray(users, getUserPublicProperties)
}
getAll.description = 'returns all the existing users'
@@ -58,15 +58,21 @@ getAll.permission = 'admin'
// -------------------------------------------------------------------
export async function set ({id, email, password, permission, preferences}) {
if (permission && id === this.session.get('user_id')) {
throw new InvalidParameters('a user cannot change its own permission')
const isAdmin = this.user && this.user.permission === 'admin'
if (isAdmin) {
if (permission && id === this.session.get('user_id')) {
throw invalidParameters('a user cannot change its own permission')
}
} else if (email || password || permission) {
throw invalidParameters('this properties can only changed by an administrator')
}
await this.updateUser(id, {email, password, permission, preferences})
}
set.description = 'changes the properties of an existing user'
set.permission = 'admin'
set.permission = ''
set.params = {
id: { type: 'string' },

View File

@@ -3,9 +3,9 @@
{coroutine: $coroutine} = require 'bluebird'
{format} = require 'json-rpc-peer'
{InvalidParameters} = require '../api-errors'
{invalidParameters} = require 'xo-common/api-errors'
{isArray: $isArray, parseSize} = require '../utils'
{JsonRpcError} = require '../api-errors'
{JsonRpcError} = require 'json-rpc-peer'
#=====================================================================
@@ -38,7 +38,7 @@ set = $coroutine (params) ->
size = parseSize(params.size)
if size < vdi.size
throw new InvalidParameters(
throw invalidParameters(
"cannot set new size (#{size}) below the current size (#{vdi.size})"
)
yield xapi.resizeVdi(ref, size)

View File

@@ -1,5 +1,19 @@
import {
diffItems,
noop,
pCatch
} from '../utils'
// ===================================================================
// TODO: move into vm and rename to removeInterface
async function delete_ ({vif}) {
this.allocIpAddresses(
vif.id,
null,
vif.allowedIpv4Addresses.concat(vif.allowedIpv6Addresses)
)::pCatch(noop)
await this.getXapi(vif).deleteVif(vif._xapiId)
}
export {delete_ as delete}
@@ -13,10 +27,11 @@ delete_.resolve = {
}
// -------------------------------------------------------------------
// TODO: move into vm and rename to disconnectInterface
export async function disconnect ({vif}) {
// TODO: check if VIF is attached before
await this.getXapi(vif).call('VIF.unplug_force', vif._xapiRef)
await this.getXapi(vif).disconnectVif(vif._xapiId)
}
disconnect.params = {
@@ -31,7 +46,7 @@ disconnect.resolve = {
// TODO: move into vm and rename to connectInterface
export async function connect ({vif}) {
// TODO: check if VIF is attached before
await this.getXapi(vif).call('VIF.plug', vif._xapiRef)
await this.getXapi(vif).connectVif(vif._xapiId)
}
connect.params = {
@@ -44,28 +59,83 @@ connect.resolve = {
// -------------------------------------------------------------------
export const set = ({ vif, allowedIpv4Addresses, allowedIpv6Addresses }) => (
this.getXapi(vif._xapiId).editVif({
export async function set ({
vif,
network,
mac,
allowedIpv4Addresses,
allowedIpv6Addresses,
attached
}) {
const oldIpAddresses = vif.allowedIpv4Addresses.concat(vif.allowedIpv6Addresses)
const newIpAddresses = []
{
const { push } = newIpAddresses
push.apply(newIpAddresses, allowedIpv4Addresses || vif.allowedIpv4Addresses)
push.apply(newIpAddresses, allowedIpv6Addresses || vif.allowedIpv6Addresses)
}
if (network || mac) {
const xapi = this.getXapi(vif)
const vm = xapi.getObject(vif.$VM)
mac == null && (mac = vif.MAC)
network = xapi.getObject(network && network.id || vif.$network)
attached == null && (attached = vif.attached)
await this.allocIpAddresses(vif.id, null, oldIpAddresses)
await xapi.deleteVif(vif._xapiId)
// create new VIF with new parameters
const newVif = await xapi.createVif(vm.$id, network.$id, {
mac,
currently_attached: attached,
ipv4_allowed: newIpAddresses
})
await this.allocIpAddresses(newVif.$id, newIpAddresses)
return
}
const [ addAddresses, removeAddresses ] = diffItems(
newIpAddresses,
oldIpAddresses
)
await this.allocIpAddresses(
vif.id,
addAddresses,
removeAddresses
)
return this.getXapi(vif).editVif(vif._xapiId, {
ipv4Allowed: allowedIpv4Addresses,
ipv6Allowed: allowedIpv6Addresses
})
)
}
set.params = {
id: { type: 'string' },
network: { type: 'string', optional: true },
mac: { type: 'string', optional: true },
allowedIpv4Addresses: {
type: 'array',
items: {
type: 'string'
}
},
optional: true
},
allowedIpv6Addresses: {
type: 'array',
items: {
type: 'string'
}
}
},
optional: true
},
attached: { type: 'boolean', optional: true }
}
set.resolve = {
vif: ['id', 'VIF', 'operate']
vif: ['id', 'VIF', 'operate'],
network: ['network', 'network', 'operate']
}

View File

@@ -3,6 +3,7 @@ $debug = (require 'debug') 'xo:api:vm'
$filter = require 'lodash/filter'
$findIndex = require 'lodash/findIndex'
$findWhere = require 'lodash/find'
concat = require 'lodash/concat'
endsWith = require 'lodash/endsWith'
escapeStringRegexp = require 'escape-string-regexp'
eventToPromise = require 'event-to-promise'
@@ -12,9 +13,10 @@ startsWith = require 'lodash/startsWith'
{format} = require 'json-rpc-peer'
{
GenericError,
Unauthorized
} = require('../api-errors')
forbiddenOperation,
invalidParameters,
unauthorized
} = require('xo-common/api-errors')
{
forEach,
formatXml: $js2xml,
@@ -47,7 +49,7 @@ checkPermissionOnSrs = (vm, permission = 'operate') -> (
)
return @hasPermissions(@session.get('user_id'), permissions).then((success) => (
throw new Unauthorized() unless success
throw unauthorized() unless success
))
)
@@ -60,6 +62,11 @@ extract = (obj, prop) ->
# TODO: Implement ACLs
create = $coroutine (params) ->
{ user } = this
resourceSet = extract(params, 'resourceSet')
if not resourceSet and user.permission isnt 'admin'
throw unauthorized()
template = extract(params, 'template')
params.template = template._xapiId
@@ -80,7 +87,7 @@ create = $coroutine (params) ->
vbd.type is 'Disk' and
(vdi = vbd.$VDI)
)
vdiSizesByDevice[vbd.device] = +vdi.virtual_size
vdiSizesByDevice[vbd.userdevice] = +vdi.virtual_size
return
)
@@ -94,7 +101,7 @@ create = $coroutine (params) ->
limits.disk += size
return $assign({}, vdi, {
device: vdi.device ? vdi.position,
device: vdi.userdevice ? vdi.device ? vdi.position,
size,
SR: sr._xapiId,
type: vdi.type
@@ -102,10 +109,10 @@ create = $coroutine (params) ->
)
existingVdis = extract(params, 'existingDisks')
params.existingVdis = existingVdis and map(existingVdis, (vdi, device) =>
params.existingVdis = existingVdis and map(existingVdis, (vdi, userdevice) =>
if vdi.size?
size = parseSize(vdi.size)
vdiSizesByDevice[device] = size
vdiSizesByDevice[userdevice] = size
if vdi.$SR
sr = @getObject(vdi.$SR)
@@ -128,23 +135,24 @@ create = $coroutine (params) ->
return {
mac: vif.mac
network: network._xapiId
ipv4_allowed: vif.allowedIpv4Addresses
ipv6_allowed: vif.allowedIpv6Addresses
}
)
installation = extract(params, 'installation')
params.installRepository = installation && installation.repository
resourceSet = extract(params, 'resourceSet')
checkLimits = null
xapiVm = yield xapi.createVm(template._xapiId, params)
vm = xapi.xo.addObject(xapiVm)
{ user } = this
if resourceSet
yield this.checkResourceSetConstraints(resourceSet, user.id, objectIds)
yield this.allocateLimitsInResourceSet(limits, resourceSet)
else unless user.permission is 'admin'
throw new Unauthorized()
checkLimits = $coroutine (limits2) =>
yield this.allocateLimitsInResourceSet(limits, resourceSet)
yield this.allocateLimitsInResourceSet(limits2, resourceSet)
xapiVm = yield xapi.createVm(template._xapiId, params, checkLimits)
vm = xapi.xo.addObject(xapiVm)
if resourceSet
yield Promise.all([
@@ -152,9 +160,23 @@ create = $coroutine (params) ->
xapi.xo.setData(xapiVm.$id, 'resourceSet', resourceSet)
])
for vifId in vm.VIFs
vif = @getObject(vifId, 'VIF')
yield this.allocIpAddresses(vifId, concat(vif.allowedIpv4Addresses, vif.allowedIpv6Addresses)).catch(() =>
xapi.deleteVif(vif._xapiId)
)
if params.bootAfterCreate
pCatch.call(xapi.startVm(vm._xapiId), noop)
return vm.id
create.params = {
bootAfterCreate: {
type: 'boolean'
optional: true
}
cloudConfig: {
type: 'string'
optional: true
@@ -211,6 +233,18 @@ create.params = {
optional: true # Auto-generated per default.
type: 'string'
}
allowedIpv4Addresses: {
optional: true
type: 'array'
items: { type: 'string' }
}
allowedIpv6Addresses: {
optional: true
type: 'array'
items: { type: 'string' }
}
}
}
}
@@ -253,7 +287,7 @@ create.params = {
}
create.resolve = {
template: ['template', 'VM-template', 'administrate'],
template: ['template', 'VM-template', ''],
}
exports.create = create
@@ -368,7 +402,7 @@ migrate = $coroutine ({
])
unless yield @hasPermissions(@session.get('user_id'), permissions)
throw new Unauthorized()
throw unauthorized()
yield @getXapi(vm).migrateVm(vm._xapiId, @getXapi(host), host._xapiId, {
migrationNetworkId: migrationNetwork?._xapiId
@@ -416,7 +450,7 @@ set = (params) ->
return @allocateLimitsInResourceSet(limits, resourceSet)
if (limits.cpuWeight && this.user.permission != 'admin')
throw new Unauthorized()
throw unauthorized()
)
set.params = {
@@ -461,7 +495,7 @@ set.params = {
}
set.resolve = {
VM: ['id', ['VM', 'VM-snapshot'], 'administrate']
VM: ['id', ['VM', 'VM-snapshot', 'VM-template'], 'administrate']
}
exports.set = set
@@ -549,7 +583,7 @@ copy.params = {
}
copy.resolve = {
vm: [ 'vm', 'VM', 'administrate' ]
vm: [ 'vm', ['VM', 'VM-snapshot'], 'administrate' ]
sr: [ 'sr', 'SR', 'operate' ]
}
@@ -562,7 +596,7 @@ convertToTemplate = $coroutine ({vm}) ->
unless yield @hasPermissions(@session.get('user_id'), [
[ vm.$pool, 'administrate' ]
])
throw new Unauthorized()
throw unauthorized()
yield @getXapi(vm).call 'VM.set_is_a_template', vm._xapiRef, true
@@ -757,10 +791,10 @@ exports.rollingBackup = rollingBackup
rollingDrCopy = ({vm, pool, sr, tag, depth}) ->
unless sr
unless pool
throw new InvalidParameters('either pool or sr param should be specified')
throw invalidParameters('either pool or sr param should be specified')
if vm.$pool is pool.id
throw new GenericError('Disaster Recovery attempts to copy on the same pool')
throw forbiddenOperation('Disaster Recovery attempts to copy on the same pool')
sr = @getObject(pool.default_SR, 'SR')
@@ -818,8 +852,7 @@ stop = $coroutine ({vm, force}) ->
yield xapi.call 'VM.clean_shutdown', vm._xapiRef
catch error
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'
throw invalidParameters('clean shutdown requires PV drivers')
else
throw error
@@ -875,15 +908,12 @@ exports.resume = resume
#---------------------------------------------------------------------
# revert a snapshot to its parent VM
revert = $coroutine ({snapshot}) ->
# Attempts a revert from this snapshot to its parent VM
yield @getXapi(snapshot).call 'VM.revert', snapshot._xapiRef
return true
revert = ({snapshot, snapshotBefore}) ->
return @getXapi(snapshot).revertVm(snapshot._xapiId, snapshotBefore)
revert.params = {
id: { type: 'string' }
id: { type: 'string' },
snapshotBefore: { type: 'boolean', optional: true }
}
revert.resolve = {
@@ -943,30 +973,30 @@ exports.export = export_;
#---------------------------------------------------------------------
handleVmImport = $coroutine (req, res, { xapi, srId }) ->
handleVmImport = $coroutine (req, res, { data, srId, type, xapi }) ->
# Timeout seems to be broken in Node 4.
# See https://github.com/nodejs/node/issues/3319
req.setTimeout(43200000) # 12 hours
try
vm = yield xapi.importVm(req, { srId })
vm = yield xapi.importVm(req, { data, srId, type })
res.end(format.response(0, vm.$id))
catch e
res.writeHead(500)
res.end(format.error(0, new GenericError(e.message)))
res.end(format.error(0, new Error(e.message)))
return
# TODO: "sr_id" can be passed in URL to target a specific SR
import_ = $coroutine ({host, sr}) ->
import_ = $coroutine ({ data, host, sr, type }) ->
if not sr
if not host
throw new InvalidParameters('you must provide either host or SR')
throw invalidParameters('you must provide either host or SR')
xapi = @getXapi(host)
sr = xapi.pool.$default_SR
if not sr
throw new InvalidParameters('there is not default SR in this pool')
throw invalidParameters('there is not default SR in this pool')
# FIXME: must have administrate permission on default SR.
else
@@ -974,13 +1004,45 @@ import_ = $coroutine ({host, sr}) ->
return {
$sendTo: yield @registerHttpRequest(handleVmImport, {
data,
srId: sr._xapiId,
type,
xapi
})
}
import_.params = {
data: {
type: 'object',
optional: true,
properties: {
descriptionLabel: { type: 'string' },
disks: {
type: 'array',
items: {
type: 'object',
properties: {
capacity: { type: 'integer' },
descriptionLabel: { type: 'string' },
nameLabel: { type: 'string' },
path: { type: 'string' },
position: { type: 'integer' }
}
},
optional: true
},
memory: { type: 'integer' },
nameLabel: { type: 'string' },
nCpus: { type: 'integer' },
networks: {
type: 'array',
items: { type: 'string' },
optional: true
},
}
},
host: { type: 'string', optional: true },
type: { type: 'string', optional: true },
sr: { type: 'string', optional: true }
}
@@ -1022,21 +1084,47 @@ exports.attachDisk = attachDisk
#---------------------------------------------------------------------
# TODO: implement resource sets
createInterface = $coroutine ({vm, network, position, mtu, mac}) ->
createInterface = $coroutine ({
vm,
network,
position,
mac,
allowedIpv4Addresses,
allowedIpv6Addresses
}) ->
vif = yield @getXapi(vm).createVif(vm._xapiId, network._xapiId, {
mac,
mtu,
position
position,
ipv4_allowed: allowedIpv4Addresses,
ipv6_allowed: allowedIpv6Addresses
})
{ push } = ipAddresses = []
push.apply(ipAddresses, allowedIpv4Addresses) if allowedIpv4Addresses
push.apply(ipAddresses, allowedIpv6Addresses) if allowedIpv6Addresses
pCatch.call(@allocIpAddresses(vif.$id, allo), noop) if ipAddresses.length
return vif.$id
createInterface.params = {
vm: { type: 'string' }
network: { type: 'string' }
position: { type: 'string', optional: true }
mtu: { type: 'string', optional: true }
position: { type: ['integer', 'string'], optional: true }
mac: { type: 'string', optional: true }
allowedIpv4Addresses: {
type: 'array',
items: {
type: 'string'
},
optional: true
},
allowedIpv6Addresses: {
type: 'array',
items: {
type: 'string'
},
optional: true
}
}
createInterface.resolve = {
@@ -1115,10 +1203,7 @@ setBootOrder = $coroutine ({vm, order}) ->
yield xapi.call 'VM.set_HVM_boot_params', vm._xapiRef, order
return true
@throw(
'INVALID_PARAMS'
'You can only set the boot order on a HVM guest'
)
throw invalidParameters('You can only set the boot order on a HVM guest')
setBootOrder.params = {
vm: { type: 'string' },

View File

@@ -1,5 +1,49 @@
import { streamToBuffer } from '../utils'
// ===================================================================
export function clean () {
return this.clean()
}
clean.permission = 'admin'
// -------------------------------------------------------------------
export async function exportConfig () {
return {
$getFrom: await this.registerHttpRequest((req, res) => {
res.writeHead(200, 'OK', {
'content-disposition': 'attachment'
})
return this.exportConfig()
},
undefined,
{ suffix: '/config.json' })
}
}
exportConfig.permission = 'admin'
// -------------------------------------------------------------------
export function getAllObjects () {
return this.getObjects()
}
getAllObjects.permission = ''
// -------------------------------------------------------------------
export async function importConfig () {
return {
$sendTo: await this.registerHttpRequest(async (req, res) => {
await this.importConfig(JSON.parse(await streamToBuffer(req)))
res.end('config successfully imported')
})
}
}
importConfig.permission = 'admin'

View File

@@ -3,6 +3,7 @@ import difference from 'lodash/difference'
import filter from 'lodash/filter'
import getKey from 'lodash/keys'
import {createClient as createRedisClient} from 'redis'
import {v4 as generateUuid} from 'uuid'
import {
forEach,
@@ -35,13 +36,13 @@ export default class Redis extends Collection {
connection,
indexes = [],
prefix,
uri = 'tcp://localhost:6379'
uri
}) {
super()
this.indexes = indexes
this.prefix = prefix
this.redis = promisifyAll.call(connection || createRedisClient(uri))
this.redis = promisifyAll(connection || createRedisClient(uri))
}
_extract (ids) {
@@ -68,12 +69,12 @@ export default class Redis extends Collection {
// TODO: remove “replace” which is a temporary measure, implement
// “set()” instead.
const {indexes, prefix, redis, idPrefix = ''} = this
const {indexes, prefix, redis} = this
return Promise.all(mapToArray(models, async model => {
// Generate a new identifier if necessary.
if (model.id === undefined) {
model.id = idPrefix + String(await redis.incr(prefix + '_id'))
model.id = generateUuid()
}
const success = await redis.sadd(prefix + '_ids', model.id)
@@ -149,6 +150,10 @@ export default class Redis extends Collection {
}
_remove (ids) {
if (isEmpty(ids)) {
return
}
const {prefix, redis} = this
// TODO: handle indexes.

View File

@@ -2,10 +2,7 @@ import bind from 'lodash/bind'
import {
isArray,
isPromise,
isFunction,
noop,
pFinally
isFunction
} from './utils'
// ===================================================================
@@ -98,117 +95,6 @@ export const debounce = duration => (target, name, descriptor) => {
// -------------------------------------------------------------------
const _push = Array.prototype.push
export const deferrable = (target, name, descriptor) => {
let fn
function newFn () {
const deferreds = []
const defer = fn => {
deferreds.push(fn)
}
defer.clear = () => {
deferreds.length = 0
}
const args = [ defer ]
_push.apply(args, arguments)
let executeDeferreds = () => {
let i = deferreds.length
while (i) {
deferreds[--i]()
}
}
try {
const result = fn.apply(this, args)
if (isPromise(result)) {
result::pFinally(executeDeferreds)
// Do not execute the deferreds in the finally block.
executeDeferreds = noop
}
return result
} finally {
executeDeferreds()
}
}
if (descriptor) {
fn = descriptor.value
descriptor.value = newFn
return descriptor
}
fn = target
return newFn
}
// Deferred functions are only executed on failures.
//
// i.e.: defer.clear() is automatically called in case of success.
deferrable.onFailure = (target, name, descriptor) => {
let fn
function newFn (defer) {
const result = fn.apply(this, arguments)
return isPromise(result)
? result.then(result => {
defer.clear()
return result
})
: (defer.clear(), result)
}
if (descriptor) {
fn = descriptor.value
descriptor.value = newFn
} else {
fn = target
target = newFn
}
return deferrable(target, name, descriptor)
}
// Deferred functions are only executed on success.
//
// i.e.: defer.clear() is automatically called in case of failure.
deferrable.onSuccess = (target, name, descriptor) => {
let fn
function newFn (defer) {
try {
const result = fn.apply(this, arguments)
return isPromise(result)
? result.then(null, error => {
defer.clear()
throw error
})
: result
} catch (error) {
defer.clear()
throw error
}
}
if (descriptor) {
fn = descriptor.value
descriptor.value = newFn
} else {
fn = target
target = newFn
}
return deferrable(target, name, descriptor)
}
// -------------------------------------------------------------------
const _ownKeys = (
typeof Reflect !== 'undefined' && Reflect.ownKeys ||
(({

View File

@@ -4,7 +4,7 @@ import expect from 'must'
// ===================================================================
import {autobind, debounce, deferrable} from './decorators'
import {autobind, debounce} from './decorators'
// ===================================================================
@@ -76,98 +76,3 @@ describe('debounce()', () => {
}, 2e1)
})
})
// -------------------------------------------------------------------
describe('deferrable()', () => {
it('works with normal termination', () => {
let i = 0
const fn = deferrable(defer => {
i += 2
defer(() => { i -= 2 })
i *= 2
defer(() => { i /= 2 })
return i
})
expect(fn()).to.equal(4)
expect(i).to.equal(0)
})
it('defer.clear() removes previous deferreds', () => {
let i = 0
const fn = deferrable(defer => {
i += 2
defer(() => { i -= 2 })
defer.clear()
i *= 2
defer(() => { i /= 2 })
return i
})
expect(fn()).to.equal(4)
expect(i).to.equal(2)
})
it('works with exception', () => {
let i = 0
const fn = deferrable(defer => {
i += 2
defer(() => { i -= 2 })
i *= 2
defer(() => { i /= 2 })
throw i
})
expect(() => fn()).to.throw(4)
expect(i).to.equal(0)
})
it('works with promise resolution', async () => {
let i = 0
const fn = deferrable(async defer => {
i += 2
defer(() => { i -= 2 })
i *= 2
defer(() => { i /= 2 })
// Wait a turn of the events loop.
await Promise.resolve()
return i
})
await expect(fn()).to.eventually.equal(4)
expect(i).to.equal(0)
})
it('works with promise rejection', async () => {
let i = 0
const fn = deferrable(async defer => {
// Wait a turn of the events loop.
await Promise.resolve()
i += 2
defer(() => { i -= 2 })
i *= 2
defer(() => { i /= 2 })
// Wait a turn of the events loop.
await Promise.resolve()
throw i
})
await expect(fn()).to.reject.to.equal(4)
expect(i).to.equal(0)
})
})

View File

@@ -1,27 +1,22 @@
import assign from 'lodash/assign'
import startsWith from 'lodash/startsWith'
import { parse as parseUrl } from 'url'
import isRedirect from 'is-redirect'
import { assign, isString, startsWith } from 'lodash'
import { request as httpRequest } from 'http'
import { request as httpsRequest } from 'https'
import { stringify as formatQueryString } from 'querystring'
import {
isString,
streamToBuffer
} from './utils'
format as formatUrl,
parse as parseUrl,
resolve as resolveUrl
} from 'url'
import { streamToBuffer } from './utils'
// -------------------------------------------------------------------
export default (...args) => {
const raw = opts => {
let req
const pResponse = new Promise((resolve, reject) => {
const opts = {}
for (let i = 0, length = args.length; i < length; ++i) {
const arg = args[i]
assign(opts, isString(arg) ? parseUrl(arg) : arg)
}
const {
body,
headers: { ...headers } = {},
@@ -62,11 +57,16 @@ export default (...args) => {
}
}
req = (
protocol && startsWith(protocol.toLowerCase(), 'https')
? httpsRequest
: httpRequest
)({
const secure = protocol && startsWith(protocol.toLowerCase(), 'https')
let requestFn
if (secure) {
requestFn = httpsRequest
} else {
requestFn = httpRequest
delete rest.rejectUnauthorized
}
req = requestFn({
...rest,
headers
})
@@ -98,6 +98,11 @@ export default (...args) => {
}
const code = response.statusCode
const { location } = response.headers
if (isRedirect(code) && location) {
assign(opts, parseUrl(resolveUrl(formatUrl(opts), location)))
return raw(opts)
}
if (code < 200 || code >= 300) {
const error = new Error(response.statusMessage)
error.code = code
@@ -112,13 +117,27 @@ export default (...args) => {
return response
})
pResponse.cancel = () => {
req.emit('error', new Error('HTTP request canceled!'))
req.abort()
}
pResponse.readAll = () => pResponse.then(response => response.readAll())
pResponse.request = req
return pResponse
}
const httpRequestPlus = (...args) => {
const opts = {}
for (let i = 0, length = args.length; i < length; ++i) {
const arg = args[i]
assign(opts, isString(arg) ? parseUrl(arg) : arg)
}
const pResponse = raw(opts)
pResponse.cancel = () => {
const { request } = pResponse
request.emit('error', new Error('HTTP request canceled!'))
request.abort()
}
pResponse.readAll = () => pResponse.then(response => response.readAll())
return pResponse
}
export { httpRequestPlus as default }

View File

@@ -9,7 +9,6 @@ import eventToPromise from 'event-to-promise'
import has from 'lodash/has'
import helmet from 'helmet'
import includes from 'lodash/includes'
import pick from 'lodash/pick'
import proxyConsole from './proxy-console'
import serveStatic from 'serve-static'
import startsWith from 'lodash/startsWith'
@@ -18,21 +17,13 @@ import { compile as compilePug } from 'pug'
import { createServer as createProxyServer } from 'http-proxy'
import { join as joinPath } from 'path'
import {
AlreadyAuthenticated,
InvalidCredential,
InvalidParameters,
NoSuchObject,
NotImplemented
} from './api-errors'
import JsonRpcPeer from 'json-rpc-peer'
import { invalidCredentials } from 'xo-common/api-errors'
import {
readFile,
readdir
} from 'fs-promise'
import * as apiMethods from './api/index'
import Api from './api'
import WebServer from 'http-server-plus'
import Xo from './xo'
import {
@@ -188,7 +179,7 @@ async function setUpPassport (express, xo) {
next()
} else if (req.cookies.token) {
next()
} else if (/favicon|fontawesome|images|styles/.test(url)) {
} else if (/favicon|fontawesome|images|styles|\.(?:css|jpg|png)$/.test(url)) {
next()
} else {
req.flash('return-url', url)
@@ -225,7 +216,8 @@ async function registerPlugin (pluginPath, pluginName) {
const {
default: factory = plugin,
configurationSchema,
configurationPresets
configurationPresets,
testSchema
} = plugin
// The default export can be either a factory or directly a plugin
@@ -239,6 +231,7 @@ async function registerPlugin (pluginPath, pluginName) {
instance,
configurationSchema,
configurationPresets,
testSchema,
version
)
}
@@ -407,27 +400,6 @@ const setUpStaticFiles = (express, opts) => {
// ===================================================================
const errorClasses = {
ALREADY_AUTHENTICATED: AlreadyAuthenticated,
INVALID_CREDENTIAL: InvalidCredential,
INVALID_PARAMS: InvalidParameters,
NO_SUCH_OBJECT: NoSuchObject,
NOT_IMPLEMENTED: NotImplemented
}
const apiHelpers = {
getUserPublicProperties (user) {
// Handles both properties and wrapped models.
const properties = user.properties || user
return pick(properties, 'id', 'email', 'groups', 'permission', 'preferences', 'provider')
},
throw (errorId, data) {
throw new (errorClasses[errorId])(data)
}
}
const setUpApi = (webServer, xo, verboseLogsOnErrors) => {
const webSocketServer = new WebSocket.Server({
server: webServer,
@@ -435,18 +407,6 @@ const setUpApi = (webServer, xo, verboseLogsOnErrors) => {
})
xo.on('stop', () => pFromCallback(cb => webSocketServer.close(cb)))
// FIXME: it can cause issues if there any property assignments in
// XO methods called from the API.
const context = { __proto__: xo, ...apiHelpers }
const api = new Api({
context,
verboseLogsOnErrors
})
xo.defineProperty('api', api)
api.addMethods(apiMethods)
webSocketServer.on('connection', socket => {
const { remoteAddress } = socket.upgradeReq.socket
@@ -461,7 +421,7 @@ const setUpApi = (webServer, xo, verboseLogsOnErrors) => {
// Create the JSON-RPC server for this connection.
const jsonRpc = new JsonRpcPeer(message => {
if (message.type === 'request') {
return api.call(connection, message.method, message.params)
return xo.callApiMethod(connection, message.method, message.params)
}
})
connection.notify = bind(jsonRpc.notify, jsonRpc)
@@ -517,7 +477,7 @@ const setUpConsoleProxy = (webServer, xo) => {
const user = await xo.authenticateUser({ token })
if (!await xo.hasPermissions(user.id, [ [ id, 'operate' ] ])) {
throw new InvalidCredential()
throw invalidCredentials()
}
const { remoteAddress } = socket
@@ -650,16 +610,24 @@ export default async function main (args) {
await registerPlugins(xo)
}
// Gracefully shutdown on signals.
//
// TODO: implements a timeout? (or maybe it is the services launcher
// responsibility?)
const shutdown = signal => {
debug('%s caught, closing…', signal)
xo.stop()
}
forEach([ 'SIGINT', 'SIGTERM' ], signal => {
let alreadyCalled = false
// Gracefully shutdown on signals.
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on(signal, () => {
if (alreadyCalled) {
warn('forced exit')
process.exit(1)
}
alreadyCalled = true
debug('%s caught, closing…', signal)
xo.stop()
})
})
await eventToPromise(xo, 'stopped')

View File

@@ -1,9 +1,19 @@
import assign from 'lodash/assign'
import {BaseError} from 'make-error'
import Bluebird from 'bluebird'
import every from 'lodash/every'
import filter from 'lodash/filter'
import isArray from 'lodash/isArray'
import isPlainObject from 'lodash/isPlainObject'
import map from 'lodash/map'
import mapValues from 'lodash/mapValues'
import size from 'lodash/size'
import some from 'lodash/some'
import { BaseError } from 'make-error'
import { crossProduct } from './math'
import {
createRawObject,
forEach
serializeError,
thunkToArray
} from './utils'
export class JobExecutorError extends BaseError {}
@@ -18,30 +28,67 @@ export class UnsupportedVectorType extends JobExecutorError {
}
}
export const productParams = (...args) => {
let product = createRawObject()
assign(product, ...args)
return product
// ===================================================================
const match = (pattern, value) => {
if (isPlainObject(pattern)) {
if (size(pattern) === 1) {
if (pattern.__or) {
return some(pattern.__or, subpattern => match(subpattern, value))
}
if (pattern.__not) {
return !match(pattern.__not, value)
}
}
return isPlainObject(value) && every(pattern, (subpattern, key) => (
value[key] !== undefined && match(subpattern, value[key])
))
}
if (isArray(pattern)) {
return isArray(value) && every(pattern, subpattern =>
some(value, subvalue => match(subpattern, subvalue))
)
}
return pattern === value
}
export function _computeCrossProduct (items, productCb, extractValueMap = {}) {
const upstreamValues = []
const itemsCopy = items.slice()
const item = itemsCopy.pop()
const values = extractValueMap[item.type] && extractValueMap[item.type](item) || item
forEach(values, value => {
if (itemsCopy.length) {
let downstreamValues = _computeCrossProduct(itemsCopy, productCb, extractValueMap)
forEach(downstreamValues, downstreamValue => {
upstreamValues.push(productCb(value, downstreamValue))
const paramsVectorActionsMap = {
extractProperties ({ mapping, value }) {
return mapValues(mapping, key => value[key])
},
crossProduct ({ items }) {
return thunkToArray(crossProduct(
map(items, value => resolveParamsVector.call(this, value))
))
},
fetchObjects ({ pattern }) {
return filter(this.xo.getObjects(), object => match(pattern, object))
},
map ({ collection, iteratee, paramName = 'value' }) {
return map(resolveParamsVector.call(this, collection), value => {
return resolveParamsVector.call(this, {
...iteratee,
[paramName]: value
})
} else {
upstreamValues.push(value)
}
})
return upstreamValues
})
},
set: ({ values }) => values
}
export function resolveParamsVector (paramsVector) {
const visitor = paramsVectorActionsMap[paramsVector.type]
if (!visitor) {
throw new Error(`Unsupported function '${paramsVector.type}'.`)
}
return visitor.call(this, paramsVector)
}
// ===================================================================
export default class JobExecutor {
constructor (xo) {
this.xo = xo
@@ -76,30 +123,24 @@ export default class JobExecutor {
event: 'job.end',
runJobId
})
} catch (e) {
} catch (error) {
this._logger.error(`The execution of ${job.id} has failed.`, {
event: 'job.end',
runJobId,
error: e
error: serializeError(error)
})
throw error
}
}
async _execCall (job, runJobId) {
let paramsFlatVector
if (job.paramsVector) {
if (job.paramsVector.type === 'crossProduct') {
paramsFlatVector = _computeCrossProduct(job.paramsVector.items, productParams, this._extractValueCb)
} else {
throw new UnsupportedVectorType(job.paramsVector)
}
} else {
paramsFlatVector = [{}] // One call with no parameters
}
const { paramsVector } = job
const paramsFlatVector = paramsVector
? resolveParamsVector.call(this, paramsVector)
: [{}] // One call with no parameters
const connection = this.xo.createUserConnection()
const promises = []
connection.set('user_id', job.userId)
@@ -109,7 +150,7 @@ export default class JobExecutor {
calls: {}
}
forEach(paramsFlatVector, params => {
await Bluebird.map(paramsFlatVector, params => {
const runCallId = this._logger.notice(`Starting ${job.method} call. (${job.id})`, {
event: 'jobCall.start',
runJobId,
@@ -123,36 +164,35 @@ export default class JobExecutor {
start: Date.now()
}
promises.push(
this.xo.api.call(connection, job.method, assign({}, params)).then(
value => {
this._logger.notice(`Call ${job.method} (${runCallId}) is a success. (${job.id})`, {
event: 'jobCall.end',
runJobId,
runCallId,
returnedValue: value
})
return this.xo.callApiMethod(connection, job.method, assign({}, params)).then(
value => {
this._logger.notice(`Call ${job.method} (${runCallId}) is a success. (${job.id})`, {
event: 'jobCall.end',
runJobId,
runCallId,
returnedValue: value
})
call.returnedValue = value
call.end = Date.now()
},
reason => {
this._logger.notice(`Call ${job.method} (${runCallId}) has failed. (${job.id})`, {
event: 'jobCall.end',
runJobId,
runCallId,
error: {...reason, message: reason.message}
})
call.returnedValue = value
call.end = Date.now()
},
reason => {
this._logger.notice(`Call ${job.method} (${runCallId}) has failed. (${job.id})`, {
event: 'jobCall.end',
runJobId,
runCallId,
error: serializeError(reason)
})
call.error = reason
call.end = Date.now()
}
)
call.error = reason
call.end = Date.now()
}
)
}, {
concurrency: 2
})
connection.close()
await Promise.all(promises)
execStatus.end = Date.now()
return execStatus

View File

@@ -1,71 +1,100 @@
/* eslint-env mocha */
import {expect} from 'chai'
import leche from 'leche'
import { expect } from 'chai'
import {
_computeCrossProduct,
productParams
} from './job-executor'
import { resolveParamsVector } from './job-executor'
describe('productParams', function () {
describe('resolveParamsVector', function () {
leche.withData({
'Two sets of one': [
{a: 1, b: 2}, {a: 1}, {b: 2}
'cross product with three sets': [
// Expected result.
[ { id: 3, value: 'foo', remote: 'local' },
{ id: 7, value: 'foo', remote: 'local' },
{ id: 10, value: 'foo', remote: 'local' },
{ id: 3, value: 'bar', remote: 'local' },
{ id: 7, value: 'bar', remote: 'local' },
{ id: 10, value: 'bar', remote: 'local' } ],
// Entry.
{
type: 'crossProduct',
items: [{
type: 'set',
values: [ { id: 3 }, { id: 7 }, { id: 10 } ]
}, {
type: 'set',
values: [ { value: 'foo' }, { value: 'bar' } ]
}, {
type: 'set',
values: [ { remote: 'local' } ]
}]
}
],
'Two sets of two': [
{a: 1, b: 2, c: 3, d: 4}, {a: 1, b: 2}, {c: 3, d: 4}
],
'Three sets': [
{a: 1, b: 2, c: 3, d: 4, e: 5, f: 6}, {a: 1}, {b: 2, c: 3}, {d: 4, e: 5, f: 6}
],
'One set': [
{a: 1, b: 2}, {a: 1, b: 2}
],
'Empty set': [
{a: 1}, {a: 1}, {}
],
'All empty': [
{}, {}, {}
],
'No set': [
{}
'cross product with `set` and `map`': [
// Expected result.
[
{ remote: 'local', id: 'vm:2' },
{ remote: 'smb', id: 'vm:2' }
],
// Entry.
{
type: 'crossProduct',
items: [{
type: 'set',
values: [ { remote: 'local' }, { remote: 'smb' } ]
}, {
type: 'map',
collection: {
type: 'fetchObjects',
pattern: {
$pool: { __or: [ 'pool:1', 'pool:8', 'pool:12' ] },
power_state: 'Running',
tags: [ 'foo' ],
type: 'VM'
}
},
iteratee: {
type: 'extractProperties',
mapping: { id: 'id' }
}
}]
},
// Context.
{
xo: {
getObjects: function () {
return [{
id: 'vm:1',
$pool: 'pool:1',
tags: [],
type: 'VM',
power_state: 'Halted'
}, {
id: 'vm:2',
$pool: 'pool:1',
tags: [ 'foo' ],
type: 'VM',
power_state: 'Running'
}, {
id: 'host:1',
type: 'host',
power_state: 'Running'
}, {
id: 'vm:3',
$pool: 'pool:8',
tags: [ 'foo' ],
type: 'VM',
power_state: 'Halted'
}]
}
}
}
]
}, function (resultSet, ...sets) {
it('Assembles all given param sets in on set', function () {
expect(productParams(...sets)).to.eql(resultSet)
})
})
})
describe('_computeCrossProduct', function () {
// Gives the sum of all args
const addTest = (...args) => args.reduce((prev, curr) => prev + curr, 0)
// Gives the product of all args
const multiplyTest = (...args) => args.reduce((prev, curr) => prev * curr, 1)
leche.withData({
'2 sets of 2 items to multiply': [
[10, 14, 15, 21], [[2, 3], [5, 7]], multiplyTest
],
'3 sets of 2 items to multiply': [
[110, 130, 154, 182, 165, 195, 231, 273], [[2, 3], [5, 7], [11, 13]], multiplyTest
],
'2 sets of 3 items to multiply': [
[14, 22, 26, 21, 33, 39, 35, 55, 65], [[2, 3, 5], [7, 11, 13]], multiplyTest
],
'2 sets of 2 items to add': [
[7, 9, 8, 10], [[2, 3], [5, 7]], addTest
],
'3 sets of 2 items to add': [
[18, 20, 20, 22, 19, 21, 21, 23], [[2, 3], [5, 7], [11, 13]], addTest
],
'2 sets of 3 items to add': [
[9, 13, 15, 10, 14, 16, 12, 16, 18], [[2, 3, 5], [7, 11, 13]], addTest
]
}, function (product, items, cb) {
it('Crosses sets of values with a crossProduct callback', function () {
expect(_computeCrossProduct(items, cb)).to.have.members(product)
}, function (expectedResult, entry, context) {
it('Resolves params vector', function () {
expect(resolveParamsVector.call(context, entry)).to.deep.have.members(expectedResult)
})
})
})

48
src/math.js Normal file
View File

@@ -0,0 +1,48 @@
import assign from 'lodash/assign'
const _combine = (vectors, n, cb) => {
if (!n) {
return
}
const nLast = n - 1
const vector = vectors[nLast]
const m = vector.length
if (n === 1) {
for (let i = 0; i < m; ++i) {
cb([ vector[i] ])
}
return
}
for (let i = 0; i < m; ++i) {
const value = vector[i]
_combine(vectors, nLast, (vector) => {
vector.push(value)
cb(vector)
})
}
}
// Compute all combinations from vectors.
//
// Ex: combine([[2, 3], [5, 7]])
// => [ [ 2, 5 ], [ 3, 5 ], [ 2, 7 ], [ 3, 7 ] ]
export const combine = vectors => cb => _combine(vectors, vectors.length, cb)
// Merge the properties of an objects set in one object.
//
// Ex: mergeObjects([ { a: 1 }, { b: 2 } ]) => { a: 1, b: 2 }
export const mergeObjects = objects => assign({}, ...objects)
// Compute a cross product between vectors.
//
// Ex: crossProduct([ [ { a: 2 }, { b: 3 } ], [ { c: 5 }, { d: 7 } ] ] )
// => [ { a: 2, c: 5 }, { b: 3, c: 5 }, { a: 2, d: 7 }, { b: 3, d: 7 } ]
export const crossProduct = (vectors, mergeFn = mergeObjects) => cb => (
combine(vectors)(vector => {
cb(mergeFn(vector))
})
)

72
src/math.spec.js Normal file
View File

@@ -0,0 +1,72 @@
/* eslint-env mocha */
import { expect } from 'chai'
import leche from 'leche'
import { thunkToArray } from './utils'
import {
crossProduct,
mergeObjects
} from './math'
describe('mergeObjects', function () {
leche.withData({
'Two sets of one': [
{a: 1, b: 2}, {a: 1}, {b: 2}
],
'Two sets of two': [
{a: 1, b: 2, c: 3, d: 4}, {a: 1, b: 2}, {c: 3, d: 4}
],
'Three sets': [
{a: 1, b: 2, c: 3, d: 4, e: 5, f: 6}, {a: 1}, {b: 2, c: 3}, {d: 4, e: 5, f: 6}
],
'One set': [
{a: 1, b: 2}, {a: 1, b: 2}
],
'Empty set': [
{a: 1}, {a: 1}, {}
],
'All empty': [
{}, {}, {}
],
'No set': [
{}
]
}, function (resultSet, ...sets) {
it('Assembles all given param sets in on set', function () {
expect(mergeObjects(sets)).to.eql(resultSet)
})
})
})
describe('crossProduct', function () {
// Gives the sum of all args
const addTest = args => args.reduce((prev, curr) => prev + curr, 0)
// Gives the product of all args
const multiplyTest = args => args.reduce((prev, curr) => prev * curr, 1)
leche.withData({
'2 sets of 2 items to multiply': [
[10, 14, 15, 21], [[2, 3], [5, 7]], multiplyTest
],
'3 sets of 2 items to multiply': [
[110, 130, 154, 182, 165, 195, 231, 273], [[2, 3], [5, 7], [11, 13]], multiplyTest
],
'2 sets of 3 items to multiply': [
[14, 22, 26, 21, 33, 39, 35, 55, 65], [[2, 3, 5], [7, 11, 13]], multiplyTest
],
'2 sets of 2 items to add': [
[7, 9, 8, 10], [[2, 3], [5, 7]], addTest
],
'3 sets of 2 items to add': [
[18, 20, 20, 22, 19, 21, 21, 23], [[2, 3], [5, 7], [11, 13]], addTest
],
'2 sets of 3 items to add': [
[9, 13, 15, 10, 14, 16, 12, 16, 18], [[2, 3, 5], [7, 11, 13]], addTest
]
}, function (product, items, cb) {
it('Crosses sets of values with a crossProduct callback', function () {
expect(thunkToArray(crossProduct(items, cb))).to.have.members(product)
})
})
})

View File

@@ -14,10 +14,6 @@ export class Groups extends Collection {
return Group
}
get idPrefix () {
return 'group:'
}
create (name) {
return this.add(new Group({
name,

View File

@@ -11,12 +11,7 @@ export class Jobs extends Collection {
return Job
}
get idPrefix () {
return 'job:'
}
async create (userId, job) {
job.userId = userId
async create (job) {
// Serializes.
job.paramsVector = JSON.stringify(job.paramsVector)
return /* await */ this.add(new Job(job))

View File

@@ -13,10 +13,6 @@ export class PluginsMetadata extends Collection {
return PluginMetadata
}
get idPrefix () {
return 'plugin-metadata:'
}
async save ({ id, autoload, configuration }) {
return /* await */ this.update({
id,

View File

@@ -13,10 +13,6 @@ export class Remotes extends Collection {
return Remote
}
get idPrefix () {
return 'remote-'
}
create (name, url) {
return this.add(new Remote({
name,

View File

@@ -11,10 +11,6 @@ export class Schedules extends Collection {
return Schedule
}
get idPrefix () {
return 'schedule:'
}
create (userId, job, cron, enabled, name = undefined, timezone = undefined) {
return this.add(new Schedule({
userId,

View File

@@ -31,15 +31,14 @@ export class Users extends Collection {
return User
}
async create (email, properties = {}) {
async create (properties) {
const { email } = properties
// Avoid duplicates.
if (await this.exists({email})) {
throw new Error(`the user ${email} already exists`)
}
// Adds the email to the user's properties.
properties.email = email
// Create the user object.
const user = new User(properties)

View File

@@ -154,6 +154,13 @@ export default class RemoteHandlerAbstract {
throw new Error('Not implemented')
}
async refreshChecksum (path) {
const stream = addChecksumToReadStream(await this.createReadStream(path))
stream.resume() // start reading the whole file
const checksum = await stream.checksum
await this.outputFile(`${path}.checksum`, checksum)
}
async createOutputStream (file, {
checksum = false,
...options

View File

@@ -12,7 +12,7 @@ export default class NfsHandler extends LocalHandler {
}
_getRealPath () {
return `/tmp/xo-server/mounts/${this._remote.id}`
return `/run/xo-server/mounts/${this._remote.id}`
}
async _loadRealMounts () {
@@ -79,6 +79,6 @@ export default class NfsHandler extends LocalHandler {
}
async _umount (remote) {
await execa('umount', [remote.path])
await execa('umount', [this._getRealPath()])
}
}

View File

@@ -1,5 +1,3 @@
import paramsVector from 'job/params-vector'
export default {
$schema: 'http://json-schema.org/draft-04/schema#',
type: 'object',
@@ -27,7 +25,9 @@ export default {
type: 'string',
description: 'called method'
},
paramsVector
paramsVector: {
type: 'object'
}
},
required: [
'type',

View File

@@ -1,59 +0,0 @@
export default {
$schema: 'http://json-schema.org/draft-04/schema#',
type: 'object',
properties: {
type: {
enum: ['crossProduct']
},
items: {
type: 'array',
description: 'vector of values to multiply with others vectors',
items: {
type: 'object',
properties: {
type: {
enum: ['set']
},
values: {
type: 'array',
items: {
type: 'object'
},
minItems: 1
}
},
required: [
'type',
'values'
]
},
minItems: 1
}
},
required: [
'type',
'items'
]
}
/* Example:
{
"type": "cross product",
"items": [
{
"type": "set",
"values": [
{"id": 0, "name": "snapshost de 0"},
{"id": 1, "name": "snapshost de 1"}
],
},
{
"type": "set",
"values": [
{"force": true}
]
}
]
}
*/

View File

@@ -20,7 +20,7 @@ export default {
},
unloadable: {
type: 'boolean',
default: 'true',
default: true,
description: 'whether or not this plugin can be unloaded'
},
configuration: {
@@ -30,6 +30,14 @@ export default {
configurationSchema: {
$ref: 'http://json-schema.org/draft-04/schema#',
description: 'configuration schema for this plugin (not present if not configurable)'
},
testable: {
type: 'boolean',
description: 'whether or not this plugin can be tested'
},
testSchema: {
$ref: 'http://json-schema.org/draft-04/schema#',
description: 'test schema for this plugin'
}
},
required: [

View File

@@ -11,6 +11,7 @@ import isString from 'lodash/isString'
import keys from 'lodash/keys'
import kindOf from 'kindof'
import multiKeyHashInt from 'multikey-hash'
import pick from 'lodash/pick'
import xml2js from 'xml2js'
// Moment timezone can be loaded only one time, it's a workaround to load
@@ -18,7 +19,10 @@ import xml2js from 'xml2js'
// does not implement `guess` function for example.
import 'moment-timezone'
import through2 from 'through2'
import { CronJob } from 'cron'
import { Readable } from 'stream'
import { utcFormat, utcParse } from 'd3-time-format'
import {
all as pAll,
defer,
@@ -29,9 +33,6 @@ import {
createHash,
randomBytes
} from 'crypto'
import { Readable } from 'stream'
import through2 from 'through2'
import {utcFormat as d3TimeFormat} from 'd3-time-format'
// ===================================================================
@@ -59,7 +60,7 @@ export const streamToBuffer = getStream.buffer
export function camelToSnakeCase (string) {
return string.replace(
/([a-z])([A-Z])/g,
/([a-z0-9])([A-Z])/g,
(_, prevChar, currChar) => `${prevChar}_${currChar.toLowerCase()}`
)
}
@@ -73,6 +74,27 @@ export const createRawObject = Object.create
// -------------------------------------------------------------------
// Only works with string items!
export const diffItems = (coll1, coll2) => {
const removed = createRawObject()
forEach(coll2, value => {
removed[value] = true
})
const added = []
forEach(coll1, value => {
if (value in removed) {
delete removed[value]
} else {
added.push(value)
}
})
return [ added, keys(removed) ]
}
// -------------------------------------------------------------------
const ALGORITHM_TO_ID = {
md5: '1',
sha256: '5',
@@ -177,6 +199,13 @@ export function extractProperty (obj, prop) {
// -------------------------------------------------------------------
export const getUserPublicProperties = user => pick(
user.properties || user,
'id', 'email', 'groups', 'permission', 'preferences', 'provider'
)
// -------------------------------------------------------------------
export const getPseudoRandomBytes = n => {
const bytes = new Buffer(n)
@@ -197,7 +226,7 @@ export const generateUnsecureToken = (n = 32) => base64url(getPseudoRandomBytes(
// Generate a secure random Base64 string.
export const generateToken = (randomBytes => {
return (n = 32) => randomBytes(n).then(base64url)
})(randomBytes::promisify())
})(promisify(randomBytes))
// -------------------------------------------------------------------
@@ -362,7 +391,9 @@ export const popProperty = obj => {
// Format a date in ISO 8601 in a safe way to be used in filenames
// (even on Windows).
export const safeDateFormat = d3TimeFormat('%Y%m%dT%H%M%SZ')
export const safeDateFormat = utcFormat('%Y%m%dT%H%M%SZ')
export const safeDateParse = utcParse('%Y%m%dT%H%M%SZ')
// -------------------------------------------------------------------
@@ -476,5 +507,41 @@ export const scheduleFn = (cronTime, fn, timeZone) => {
// -------------------------------------------------------------------
// Create a serializable object from an error.
export const serializeError = error => ({
message: error.message,
stack: error.stack,
...error // Copy enumerable properties.
})
// -------------------------------------------------------------------
// Create an array which contains the results of one thunk function.
// Only works with synchronous thunks.
export const thunkToArray = thunk => {
const values = []
thunk(::values.push)
return values
}
// -------------------------------------------------------------------
// Creates a new function which throws an error.
//
// ```js
// promise.catch(throwFn('an error has occured'))
//
// function foo (param = throwFn('param is required')) {}
// ```
export const throwFn = error => () => {
throw (
isString(error)
? new Error(error)
: error
)
}
// -------------------------------------------------------------------
// Wrap a value in a function.
export const wrap = value => () => value

View File

@@ -7,6 +7,7 @@ import expect from 'must'
import {
camelToSnakeCase,
createRawObject,
diffItems,
ensureArray,
extractProperty,
formatXml,
@@ -20,10 +21,12 @@ import {
describe('camelToSnakeCase()', function () {
it('converts a string from camelCase to snake_case', function () {
expect(camelToSnakeCase('fooBar')).to.equal('foo_bar')
expect(camelToSnakeCase('ipv4Allowed')).to.equal('ipv4_allowed')
})
it('does not alter snake_case strings', function () {
expect(camelToSnakeCase('foo_bar')).to.equal('foo_bar')
expect(camelToSnakeCase('ipv4_allowed')).to.equal('ipv4_allowed')
})
it('does not alter upper case letters expect those from the camelCase', function () {
@@ -55,6 +58,20 @@ describe('createRawObject()', () => {
// -------------------------------------------------------------------
describe('diffItems', () => {
it('computes the added/removed items between 2 iterables', () => {
expect(diffItems(
['foo', 'bar'],
['baz', 'foo']
)).to.eql([
['bar'],
['baz']
])
})
})
// -------------------------------------------------------------------
describe('ensureArray()', function () {
it('wrap the value in an array', function () {
const value = 'foo'

View File

@@ -1,4 +1,5 @@
import fu from 'struct-fu'
import fu from '@nraynaud/struct-fu'
import isEqual from 'lodash/isEqual'
import {
noop,
@@ -91,7 +92,7 @@ const fuHeader = fu.struct([
fu.uint8('parentUuid', 16),
fu.uint32('parentTimestamp'),
fu.uint32('reserved1'),
fu.char('parentUnicodeName', 512),
fu.char16be('parentUnicodeName', 512),
fu.struct('parentLocatorEntry', [
fu.uint32('platformCode'),
fu.uint32('platformDataSpace'),
@@ -144,24 +145,24 @@ const unpackField = (field, buf) => {
}
// ===================================================================
// Returns the checksum of a raw footer.
// The raw footer is altered with the new sum.
function checksumFooter (rawFooter) {
const checksumField = fuFooter.fields.checksum
// Returns the checksum of a raw struct.
// The raw struct (footer or header) is altered with the new sum.
function checksumStruct (rawStruct, struct) {
const checksumField = struct.fields.checksum
let sum = 0
// Reset current sum.
packField(checksumField, 0, rawFooter)
packField(checksumField, 0, rawStruct)
for (let i = 0; i < VHD_FOOTER_SIZE; i++) {
sum = (sum + rawFooter[i]) & 0xFFFFFFFF
for (let i = 0, n = struct.size; i < n; i++) {
sum = (sum + rawStruct[i]) & 0xFFFFFFFF
}
sum = 0xFFFFFFFF - sum
// Write new sum.
packField(checksumField, sum, rawFooter)
packField(checksumField, sum, rawStruct)
return sum
}
@@ -257,7 +258,7 @@ class Vhd {
)
const sum = unpackField(fuFooter.fields.checksum, buf)
const sumToTest = checksumFooter(buf)
const sumToTest = checksumStruct(buf, fuFooter)
// Checksum child & parent.
if (sumToTest !== sum) {
@@ -494,25 +495,36 @@ class Vhd {
}
}
// Write a context footer. (At the end and beggining of a vhd file.)
// Write a context footer. (At the end and beginning of a vhd file.)
async writeFooter () {
const { footer } = this
const offset = this.getEndOfData()
const rawFooter = fuFooter.pack(footer)
footer.checksum = checksumFooter(rawFooter)
footer.checksum = checksumStruct(rawFooter, fuFooter)
debug(`Write footer at: ${offset} (checksum=${footer.checksum}). (data=${rawFooter.toString('hex')})`)
await this._write(rawFooter, 0)
await this._write(rawFooter, offset)
}
async writeHeader () {
const { header } = this
const rawHeader = fuHeader.pack(header)
header.checksum = checksumStruct(rawHeader, fuHeader)
const offset = VHD_FOOTER_SIZE
debug(`Write header at: ${offset} (checksum=${header.checksum}). (data=${rawHeader.toString('hex')})`)
await this._write(rawHeader, offset)
}
}
// Merge vhd child into vhd parent.
//
// Child must be a delta backup !
// Parent must be a full backup !
//
// TODO: update the identifier of the parent VHD.
export default async function vhdMerge (
parentHandler, parentPath,
childHandler, childPath
@@ -564,3 +576,46 @@ export default async function vhdMerge (
await parentVhd.writeFooter()
}
// returns true if the child was actually modified
export async function chainVhd (
parentHandler, parentPath,
childHandler, childPath
) {
const parentVhd = new Vhd(parentHandler, parentPath)
const childVhd = new Vhd(childHandler, childPath)
await Promise.all([
parentVhd.readHeaderAndFooter(),
childVhd.readHeaderAndFooter()
])
const { header } = childVhd
const parentName = parentPath.split('/').pop()
const parentUuid = parentVhd.footer.uuid
if (
header.parentUnicodeName !== parentName ||
!isEqual(header.parentUuid, parentUuid)
) {
header.parentUuid = parentUuid
header.parentUnicodeName = parentName
await childVhd.writeHeader()
return true
}
// The checksum was broken between xo-server v5.2.4 and v5.2.5
//
// Replace by a correct checksum if necessary.
//
// TODO: remove when enough time as passed (6 months).
{
const rawHeader = fuHeader.pack(header)
const checksum = checksumStruct(rawHeader, fuHeader)
if (checksum !== header.checksum) {
await childVhd._write(rawHeader, VHD_FOOTER_SIZE)
return true
}
}
return false
}

View File

@@ -3,6 +3,7 @@ import {
extractProperty,
forEach,
isArray,
isEmpty,
mapToArray,
parseXml
} from './utils'
@@ -194,6 +195,15 @@ const TRANSFORMS = {
: 'out of date'
})()
let resourceSet = otherConfig['xo:resource_set']
if (resourceSet) {
try {
resourceSet = JSON.parse(resourceSet)
} catch (_) {
resourceSet = undefined
}
}
const vm = {
// type is redefined after for controllers/, templates &
// snapshots.
@@ -232,7 +242,8 @@ const TRANSFORMS = {
return {
enabled: true,
info: info && parseXml(info).docker_info,
process: process && parseXml(process).docker_ps,
containers: ensureArray(process && parseXml(process).docker_ps.item),
process: process && parseXml(process).docker_ps, // deprecated (only used in v4)
version: version && parseXml(version).docker_version
}
})(),
@@ -271,6 +282,7 @@ const TRANSFORMS = {
other: otherConfig,
os_version: guestMetrics && guestMetrics.os_version || null,
power_state: obj.power_state,
resourceSet,
snapshots: link(obj, 'snapshots'),
startTime: metrics && toTimestamp(metrics.start_time),
tags: obj.tags,
@@ -362,6 +374,7 @@ const TRANSFORMS = {
name_description: obj.name_description,
name_label: obj.name_label,
size: +obj.physical_size,
shared: Boolean(obj.shared),
SR_type: obj.type,
tags: obj.tags,
usage: +obj.virtual_allocation,
@@ -382,7 +395,7 @@ const TRANSFORMS = {
return {
type: 'PBD',
attached: obj.currently_attached,
attached: Boolean(obj.currently_attached),
host: link(obj, 'host'),
SR: link(obj, 'SR')
}
@@ -391,10 +404,13 @@ const TRANSFORMS = {
// -----------------------------------------------------------------
pif (obj) {
const metrics = obj.$metrics
return {
type: 'PIF',
attached: Boolean(obj.currently_attached),
isBondMaster: !isEmpty(obj.bond_master_of),
device: obj.device,
dns: obj.DNS,
disallowUnplug: Boolean(obj.disallow_unplug),
@@ -402,6 +418,7 @@ const TRANSFORMS = {
ip: obj.IP,
mac: obj.MAC,
management: Boolean(obj.management), // TODO: find a better name.
carrier: Boolean(metrics && metrics.carrier),
mode: obj.ip_configuration_mode,
mtu: +obj.MTU,
netmask: obj.netmask,
@@ -483,6 +500,7 @@ const TRANSFORMS = {
network (obj) {
return {
bridge: obj.bridge,
defaultIsLocked: obj.default_locking_mode === 'disabled',
MTU: +obj.MTU,
name_description: obj.name_description,
name_label: obj.name_label,

View File

@@ -1,12 +1,16 @@
/* eslint-disable camelcase */
import createDebug from 'debug'
import deferrable from 'golike-defer'
import every from 'lodash/every'
import fatfs from 'fatfs'
import find from 'lodash/find'
import flatten from 'lodash/flatten'
import includes from 'lodash/includes'
import sortBy from 'lodash/sortBy'
import unzip from 'julien-f-unzip'
import isEmpty from 'lodash/isEmpty'
import omit from 'lodash/omit'
import tarStream from 'tar-stream'
import uniq from 'lodash/uniq'
import vmdkToVhd from 'xo-vmdk-to-vhd'
import { defer } from 'promise-toolbox'
import {
wrapError as wrapXapiError,
@@ -18,12 +22,7 @@ import {
import httpRequest from '../http-request'
import fatfsBuffer, { init as fatfsBufferInit } from '../fatfs-buffer'
import {
debounce,
deferrable,
mixin
} from '../decorators'
import httpProxy from '../http-proxy'
import { mixin } from '../decorators'
import {
bufferToStream,
camelToSnakeCase,
@@ -35,33 +34,31 @@ import {
mapToArray,
noop,
pAll,
parseXml,
pCatch,
pDelay,
pFinally,
promisifyAll,
pSettle
} from '../utils'
import {
GenericError,
ForbiddenOperation
} from '../api-errors'
import { forbiddenOperation } from 'xo-common/api-errors'
import mixins from './mixins'
import OTHER_CONFIG_TEMPLATE from './other-config-template'
import {
asBoolean,
asInteger,
debug,
extractOpaqueRef,
filterUndefineds,
getNamespaceForType,
isVmHvm,
isVmRunning,
NULL_REF,
optional,
prepareXapiParam
prepareXapiParam,
put
} from './utils'
const debug = createDebug('xo:xapi')
// ===================================================================
const TAG_BASE_DELTA = 'xo:base_delta'
@@ -69,45 +66,6 @@ const TAG_COPY_SRC = 'xo:copy_of'
// ===================================================================
// HTTP put, use an ugly hack if the length is not known because XAPI
// does not support chunk encoding.
const put = (stream, {
headers: { ...headers } = {},
...opts
}, task) => {
const makeRequest = () => httpRequest({
...opts,
body: stream,
headers,
method: 'put'
})
// Xen API does not support chunk encoding.
if (stream.length == null) {
headers['transfer-encoding'] = null
const promise = makeRequest()
if (task) {
// Some connections need the task to resolve (VDI import).
task::pFinally(() => {
promise.cancel()
})
} else {
// Some tasks need the connection to close (VM import).
promise.request.once('finish', () => {
promise.cancel()
})
}
return promise.readAll()
}
return makeRequest().readAll()
}
// ===================================================================
// FIXME: remove this work around when fixed, https://phabricator.babeljs.io/T2877
// export * from './utils'
require('lodash/assign')(module.exports, require('./utils'))
@@ -116,6 +74,9 @@ require('lodash/assign')(module.exports, require('./utils'))
export const VDI_FORMAT_VHD = 'vhd'
export const VDI_FORMAT_RAW = 'raw'
export const IPV4_CONFIG_MODES = ['None', 'DHCP', 'Static']
export const IPV6_CONFIG_MODES = ['None', 'DHCP', 'Static', 'Autoconf']
// ===================================================================
@mixin(mapToArray(mixins))
@@ -379,6 +340,22 @@ export default class Xapi extends XapiBase {
})
}
async setNetworkProperties (id, {
nameLabel,
nameDescription,
defaultIsLocked
}) {
let defaultLockingMode
if (defaultIsLocked != null) {
defaultLockingMode = defaultIsLocked ? 'disabled' : 'unlocked'
}
await this._setObjectProperties(this.getObject(id), {
nameLabel,
nameDescription,
defaultLockingMode
})
}
// =================================================================
async addTag (id, tag) {
@@ -411,82 +388,6 @@ export default class Xapi extends XapiBase {
// =================================================================
// FIXME: should be static
@debounce(24 * 60 * 60 * 1000)
async _getXenUpdates () {
const { readAll, statusCode } = await httpRequest(
'http://updates.xensource.com/XenServer/updates.xml',
{ agent: httpProxy }
)
if (statusCode !== 200) {
throw new GenericError('cannot fetch patches list from Citrix')
}
const data = parseXml(await readAll()).patchdata
const patches = createRawObject()
forEach(data.patches.patch, patch => {
patches[patch.uuid] = {
date: patch.timestamp,
description: patch['name-description'],
documentationUrl: patch.url,
guidance: patch['after-apply-guidance'],
name: patch['name-label'],
url: patch['patch-url'],
uuid: patch.uuid,
conflicts: mapToArray(ensureArray(patch.conflictingpatches), patch => {
return patch.conflictingpatch.uuid
}),
requirements: mapToArray(ensureArray(patch.requiredpatches), patch => {
return patch.requiredpatch.uuid
})
// TODO: what does it mean, should we handle it?
// version: patch.version,
}
if (patches[patch.uuid].conflicts[0] === undefined) {
patches[patch.uuid].conflicts.length = 0
}
if (patches[patch.uuid].requirements[0] === undefined) {
patches[patch.uuid].requirements.length = 0
}
})
const resolveVersionPatches = function (uuids) {
const versionPatches = createRawObject()
forEach(ensureArray(uuids), ({uuid}) => {
versionPatches[uuid] = patches[uuid]
})
return versionPatches
}
const versions = createRawObject()
let latestVersion
forEach(data.serverversions.version, version => {
versions[version.value] = {
date: version.timestamp,
name: version.name,
id: version.value,
documentationUrl: version.url,
patches: resolveVersionPatches(version.patch)
}
if (version.latest) {
latestVersion = versions[version.value]
}
})
return {
patches,
latestVersion,
versions
}
}
// =================================================================
async joinPool (masterAddress, masterUsername, masterPassword, force = false) {
await this.call(
force ? 'pool.join_force' : 'pool.join',
@@ -498,194 +399,6 @@ export default class Xapi extends XapiBase {
// =================================================================
// Returns installed and not installed patches for a given host.
async _getPoolPatchesForHost (host) {
const versions = (await this._getXenUpdates()).versions
const hostVersions = host.software_version
const version =
versions[hostVersions.product_version] ||
versions[hostVersions.product_version_text]
return version
? version.patches
: []
}
_getInstalledPoolPatchesOnHost (host) {
const installed = createRawObject()
forEach(host.$patches, hostPatch => {
installed[hostPatch.$pool_patch.uuid] = true
})
return installed
}
async _listMissingPoolPatchesOnHost (host) {
const all = await this._getPoolPatchesForHost(host)
const installed = this._getInstalledPoolPatchesOnHost(host)
const installable = createRawObject()
forEach(all, (patch, uuid) => {
if (installed[uuid]) {
return
}
for (const uuid of patch.conflicts) {
if (uuid in installed) {
return
}
}
installable[uuid] = patch
})
return installable
}
async listMissingPoolPatchesOnHost (hostId) {
// Returns an array to not break compatibility.
return mapToArray(
await this._listMissingPoolPatchesOnHost(this.getObject(hostId))
)
}
// -----------------------------------------------------------------
_isPoolPatchInstallableOnHost (patchUuid, host) {
const installed = this._getInstalledPoolPatchesOnHost(host)
if (installed[patchUuid]) {
return false
}
let installable = true
forEach(installed, patch => {
if (includes(patch.conflicts, patchUuid)) {
installable = false
return false
}
})
return installable
}
// -----------------------------------------------------------------
async uploadPoolPatch (stream, patchName = 'unknown') {
const taskRef = await this._createTask('Patch upload', patchName)
const task = this._watchTask(taskRef)
const [ patchRef ] = await Promise.all([
task,
put(stream, {
hostname: this.pool.$master.address,
path: '/pool_patch_upload',
query: {
session_id: this.sessionId,
task_id: taskRef
}
}, task)
])
return this._getOrWaitObject(patchRef)
}
async _getOrUploadPoolPatch (uuid) {
try {
return this.getObjectByUuid(uuid)
} catch (error) {}
debug('downloading patch %s', uuid)
const patchInfo = (await this._getXenUpdates()).patches[uuid]
if (!patchInfo) {
throw new Error('no such patch ' + uuid)
}
let stream = await httpRequest(patchInfo.url, { agent: httpProxy })
stream = await new Promise((resolve, reject) => {
const PATCH_RE = /\.xsupdate$/
stream.pipe(unzip.Parse()).on('entry', entry => {
if (PATCH_RE.test(entry.path)) {
entry.length = entry.size
resolve(entry)
} else {
entry.autodrain()
}
}).on('error', reject)
})
return this.uploadPoolPatch(stream, patchInfo.name)
}
// -----------------------------------------------------------------
async _installPoolPatchOnHost (patchUuid, host) {
debug('installing patch %s', patchUuid)
const patch = await this._getOrUploadPoolPatch(patchUuid)
await this.call('pool_patch.apply', patch.$ref, host.$ref)
}
async installPoolPatchOnHost (patchUuid, hostId) {
return /* await */ this._installPoolPatchOnHost(
patchUuid,
this.getObject(hostId)
)
}
// -----------------------------------------------------------------
async installPoolPatchOnAllHosts (patchUuid) {
const patch = await this._getOrUploadPoolPatch(patchUuid)
await this.call('pool_patch.pool_apply', patch.$ref)
}
// -----------------------------------------------------------------
async _installPoolPatchOnHostAndRequirements (patch, host, patchesByUuid) {
const { requirements } = patch
if (requirements.length) {
for (const requirementUuid of requirements) {
if (this._isPoolPatchInstallableOnHost(requirementUuid, host)) {
const requirement = patchesByUuid[requirementUuid]
await this._installPoolPatchOnHostAndRequirements(requirement, host, patchesByUuid)
host = this.getObject(host.$id)
}
}
}
await this._installPoolPatchOnHost(patch.uuid, host)
}
async installAllPoolPatchesOnHost (hostId) {
let host = this.getObject(hostId)
const installableByUuid = await this._listMissingPoolPatchesOnHost(host)
// List of all installable patches sorted from the newest to the
// oldest.
const installable = sortBy(
installableByUuid,
patch => -Date.parse(patch.date)
)
for (let i = 0, n = installable.length; i < n; ++i) {
const patch = installable[i]
if (this._isPoolPatchInstallableOnHost(patch.uuid, host)) {
await this._installPoolPatchOnHostAndRequirements(patch, host, installableByUuid)
host = this.getObject(host.$id)
}
}
}
async emergencyShutdownHost (hostId) {
const host = this.getObject(hostId)
const vms = host.$resident_VMs
@@ -714,7 +427,7 @@ export default class Xapi extends XapiBase {
await this.call('host.evacuate', ref)
} catch (error) {
if (!force) {
await this.call('host.enabled', ref)
await this.call('host.enable', ref)
throw error
}
@@ -915,7 +628,7 @@ export default class Xapi extends XapiBase {
actions_after_crash,
actions_after_reboot,
actions_after_shutdown,
affinity: affinity == null ? 'OpaqueRef:NULL' : affinity,
affinity: affinity == null ? NULL_REF : affinity,
HVM_boot_params,
HVM_boot_policy,
is_a_template: asBoolean(is_a_template),
@@ -1052,7 +765,8 @@ export default class Xapi extends XapiBase {
session_id: this.sessionId,
task_id: taskRef,
use_compression: compress ? 'true' : 'false'
}
},
rejectUnauthorized: false
})
}
@@ -1147,7 +861,10 @@ export default class Xapi extends XapiBase {
[TAG_BASE_DELTA]: baseVm.uuid
}
}
: vm
: {
...vm,
other_config: omit(vm.other_config, TAG_BASE_DELTA)
}
}, 'streams', {
value: await streams::pAll()
})
@@ -1196,7 +913,7 @@ export default class Xapi extends XapiBase {
is_a_template: false
})
)
$onFailure(() => this._deleteVm(vm))
$onFailure(() => this._deleteVm(vm, true))
await Promise.all([
this._setObjectProperties(vm, {
@@ -1286,7 +1003,7 @@ export default class Xapi extends XapiBase {
// Create VIFs.
Promise.all(mapToArray(delta.vifs, vif => {
const network =
this.getObject(vif.$network$uuid, null) ||
vif.$network$uuid && this.getObject(vif.$network$uuid, null) ||
networksOnPoolMasterByDevice[vif.device] ||
defaultNetwork
@@ -1326,13 +1043,13 @@ export default class Xapi extends XapiBase {
}) {
// VDIs/SRs mapping
const vdis = {}
const defaultSrRef = host.$pool.$default_SR.$ref
const defaultSr = host.$pool.$default_SR
for (const vbd of vm.$VBDs) {
const vdi = vbd.$VDI
if (vbd.type === 'Disk') {
vdis[vdi.$ref] = mapVdisSrs && mapVdisSrs[vdi.$id]
? hostXapi.getObject(mapVdisSrs[vdi.$id]).$ref
: defaultSrRef
: defaultSr.$ref // Will error if there are no default SR.
}
}
@@ -1418,16 +1135,112 @@ export default class Xapi extends XapiBase {
return vmRef
}
@deferrable.onFailure
async _importOvaVm ($onFailure, stream, {
descriptionLabel,
disks,
memory,
nameLabel,
networks,
nCpus
}, sr) {
// 1. Create VM.
const vm = await this._getOrWaitObject(
await this._createVmRecord({
...OTHER_CONFIG_TEMPLATE,
memory_dynamic_max: memory,
memory_dynamic_min: memory,
memory_static_max: memory,
name_description: descriptionLabel,
name_label: nameLabel,
VCPUs_at_startup: nCpus,
VCPUs_max: nCpus
})
)
$onFailure(() => this._deleteVm(vm, true))
// Disable start and change the VM name label during import.
await Promise.all([
this.addForbiddenOperationToVm(vm.$id, 'start', 'OVA import in progress...'),
this._setObjectProperties(vm, { name_label: `[Importing...] ${nameLabel}` })
])
// 2. Create VDIs & Vifs.
const vdis = {}
const vifDevices = await this.call('VM.get_allowed_VIF_devices', vm.$ref)
await Promise.all(
map(disks, async disk => {
const vdi = vdis[disk.path] = await this.createVdi(disk.capacity, {
name_description: disk.descriptionLabel,
name_label: disk.nameLabel,
sr: sr.$ref
})
$onFailure(() => this._deleteVdi(vdi)::pCatch(noop))
return this._createVbd(vm, vdi, { position: disk.position })
}).concat(map(networks, (networkId, i) => (
this._createVif(vm, this.getObject(networkId), {
device: vifDevices[i]
})
)))
)
// 3. Import VDIs contents.
await new Promise((resolve, reject) => {
const extract = tarStream.extract()
stream.on('error', reject)
extract.on('finish', resolve)
extract.on('error', reject)
extract.on('entry', async (entry, stream, cb) => {
// Not a disk to import.
const vdi = vdis[entry.name]
if (!vdi) {
stream.on('end', cb)
stream.resume()
return
}
const vhdStream = await vmdkToVhd(stream)
await this._importVdiContent(vdi, vhdStream, VDI_FORMAT_RAW)
// See: https://github.com/mafintosh/tar-stream#extracting
// No import parallelization.
cb()
})
stream.pipe(extract)
})
// Enable start and restore the VM name label after import.
await Promise.all([
this.removeForbiddenOperationFromVm(vm.$id, 'start'),
this._setObjectProperties(vm, { name_label: nameLabel })
])
return vm
}
// TODO: an XVA can contain multiple VMs
async importVm (stream, {
data,
onlyMetadata = false,
srId
srId,
type = 'xva'
} = {}) {
return /* await */ this._getOrWaitObject(await this._importVm(
stream,
srId && this.getObject(srId),
onlyMetadata
))
const sr = srId && this.getObject(srId)
if (type === 'xva') {
return /* await */ this._getOrWaitObject(await this._importVm(
stream,
sr,
onlyMetadata
))
}
if (type === 'ova') {
return this._getOrWaitObject(await this._importOvaVm(stream, data, sr))
}
throw new Error(`unsupported type: '${type}'`)
}
async migrateVm (vmId, hostXapi, hostId, {
@@ -1534,7 +1347,7 @@ export default class Xapi extends XapiBase {
await this._startVm(this.getObject(vmId))
} catch (e) {
if (e.code === 'OPERATION_BLOCKED') {
throw new ForbiddenOperation('Start', e.params[1])
throw forbiddenOperation('Start', e.params[1])
}
throw e
@@ -1829,9 +1642,16 @@ export default class Xapi extends XapiBase {
await this.call('VBD.plug', vbdId)
}
_disconnectVbd (vbd) {
async _disconnectVbd (vbd) {
// TODO: check if VBD is attached before
return this.call('VBD.unplug_force', vbd.$ref)
try {
await this.call('VBD.unplug_force', vbd.$ref)
} catch (error) {
if (error.code === 'VBD_NOT_UNPLUGGABLE') {
await this.call('VBD.set_unpluggable', vbd.$ref, true)
return this.call('VBD.unplug_force', vbd.$ref)
}
}
}
async disconnectVbd (vbdId) {
@@ -1987,15 +1807,14 @@ export default class Xapi extends XapiBase {
async _createVif (vm, network, {
mac = '',
mtu = 1500,
position = undefined,
currently_attached = true,
device = position != null ? String(position) : undefined,
ipv4_allowed = undefined,
ipv6_allowed = undefined,
locking_mode = undefined,
MAC = mac,
MTU = mtu,
other_config = {},
qos_algorithm_params = {},
qos_algorithm_type = ''
@@ -2012,7 +1831,7 @@ export default class Xapi extends XapiBase {
ipv6_allowed,
locking_mode,
MAC,
MTU: asInteger(MTU),
MTU: asInteger(network.MTU),
network: network.$ref,
other_config,
qos_algorithm_params,
@@ -2020,18 +1839,13 @@ export default class Xapi extends XapiBase {
VM: vm.$ref
}))
if (isVmRunning(vm)) {
if (currently_attached && isVmRunning(vm)) {
await this.call('VIF.plug', vifRef)
}
return vifRef
}
// TODO: check whether the VIF was unplugged before.
async _deleteVif (vif) {
await this.call('VIF.destroy', vif.$ref)
}
async createVif (vmId, networkId, opts = undefined) {
return /* await */ this._getOrWaitObject(
await this._createVif(
@@ -2042,10 +1856,6 @@ export default class Xapi extends XapiBase {
)
}
async deleteVif (vifId) {
await this._deleteVif(this.getObject(vifId))
}
async createNetwork ({
name,
description = 'Created with Xen Orchestra',
@@ -2066,10 +1876,69 @@ export default class Xapi extends XapiBase {
return this._getOrWaitObject(networkRef)
}
async editPif (
pifId,
{ vlan }
) {
const pif = this.getObject(pifId)
const physPif = find(this.objects.all, obj => (
obj.$type === 'pif' &&
(obj.physical || !isEmpty(obj.bond_master_of)) &&
obj.$pool === pif.$pool &&
obj.device === pif.device
))
if (!physPif) {
throw new Error('PIF not found')
}
const pifs = this.getObject(pif.network).$PIFs
const wasAttached = {}
forEach(pifs, pif => {
wasAttached[pif.host] = pif.currently_attached
})
const vlans = uniq(mapToArray(pifs, pif => pif.VLAN_master_of))
await Promise.all(
mapToArray(vlans, vlan => vlan !== NULL_REF && this.call('VLAN.destroy', vlan))
)
const newPifs = await this.call('pool.create_VLAN_from_PIF', physPif.$ref, pif.network, asInteger(vlan))
await Promise.all(
mapToArray(newPifs, pifRef =>
!wasAttached[this.getObject(pifRef).host] && this.call('PIF.unplug', pifRef)::pCatch(noop)
)
)
}
async createBondedNetwork ({
bondMode,
mac,
pifIds,
...params
}) {
const network = await this.createNetwork(params)
// TODO: test and confirm:
// Bond.create is called here with PIFs from one host but XAPI should then replicate the
// bond on each host in the same pool with the corresponding PIFs (ie same interface names?).
await this.call('Bond.create', network.$ref, map(pifIds, pifId => this.getObject(pifId).$ref), mac, bondMode)
return network
}
async deleteNetwork (networkId) {
const network = this.getObject(networkId)
const pifs = network.$PIFs
const vlans = uniq(mapToArray(pifs, pif => pif.VLAN_master_of))
await Promise.all(
mapToArray(network.$PIFs, (pif) => this.call('VLAN.destroy', pif.$VLAN_master_of.$ref))
mapToArray(vlans, vlan => vlan !== NULL_REF && this.call('VLAN.destroy', vlan))
)
const bonds = uniq(flatten(mapToArray(pifs, pif => pif.bond_master_of)))
await Promise.all(
mapToArray(bonds, bond => this.call('Bond.destroy', bond))
)
await this.call('network.destroy', network.$ref)
@@ -2136,6 +2005,7 @@ export default class Xapi extends XapiBase {
sruuid: sr.uuid,
configuration: config
})
await this.registerDockerContainer(vmId)
}
// Generic Config Drive
@@ -2147,7 +2017,7 @@ export default class Xapi extends XapiBase {
const buffer = fatfsBufferInit()
const vdi = await this.createVdi(buffer.length, { name_label: 'XO CloudConfigDrive', name_description: undefined, sr: sr.$ref })
// Then, generate a FAT fs
const fs = fatfs.createFileSystem(fatfsBuffer(buffer))::promisifyAll()
const fs = promisifyAll(fatfs.createFileSystem(fatfsBuffer(buffer)))
// Create Cloud config folders
await fs.mkdir('openstack')
await fs.mkdir('openstack/latest')

View File

View File

@@ -1,10 +1,60 @@
import {
makeEditObject
} from '../utils'
import { isEmpty } from '../../utils'
import { makeEditObject } from '../utils'
export default {
async _connectVif (vif) {
await this.call('VIF.plug', vif.$ref)
},
async connectVif (vifId) {
await this._connectVif(this.getObject(vifId))
},
async _deleteVif (vif) {
await this.call('VIF.destroy', vif.$ref)
},
async deleteVif (vifId) {
const vif = this.getObject(vifId)
if (vif.currently_attached) {
await this._disconnectVif(vif)
}
await this._deleteVif(vif)
},
async _disconnectVif (vif) {
await this.call('VIF.unplug_force', vif.$ref)
},
async disconnectVif (vifId) {
await this._disconnectVif(this.getObject(vifId))
},
editVif: makeEditObject({
ipv4Allowed: true,
ipv6Allowed: true
ipv4Allowed: {
get: true,
set: [
'ipv4Allowed',
function (value, vif) {
const lockingMode = isEmpty(value) && isEmpty(vif.ipv6_allowed)
? 'network_default'
: 'locked'
if (lockingMode !== vif.locking_mode) {
return this._set('locking_mode', lockingMode)
}
}
]
},
ipv6Allowed: {
get: true,
set: [
'ipv6Allowed',
function (value, vif) {
const lockingMode = isEmpty(value) && isEmpty(vif.ipv4_allowed)
? 'network_default'
: 'locked'
if (lockingMode !== vif.locking_mode) {
return this._set('locking_mode', lockingMode)
}
}
]
}
})
}

294
src/xapi/mixins/patching.js Normal file
View File

@@ -0,0 +1,294 @@
import filter from 'lodash/filter'
import includes from 'lodash/includes'
import sortBy from 'lodash/sortBy'
import unzip from 'julien-f-unzip'
import httpProxy from '../../http-proxy'
import httpRequest from '../../http-request'
import { debounce } from '../../decorators'
import {
createRawObject,
ensureArray,
forEach,
mapToArray,
parseXml
} from '../../utils'
import {
debug,
put
} from '../utils'
export default {
// FIXME: should be static
@debounce(24 * 60 * 60 * 1000)
async _getXenUpdates () {
const { readAll, statusCode } = await httpRequest(
'http://updates.xensource.com/XenServer/updates.xml',
{ agent: httpProxy }
)
if (statusCode !== 200) {
throw new Error('cannot fetch patches list from Citrix')
}
const data = parseXml(await readAll()).patchdata
const patches = createRawObject()
forEach(data.patches.patch, patch => {
patches[patch.uuid] = {
date: patch.timestamp,
description: patch['name-description'],
documentationUrl: patch.url,
guidance: patch['after-apply-guidance'],
name: patch['name-label'],
url: patch['patch-url'],
uuid: patch.uuid,
conflicts: mapToArray(ensureArray(patch.conflictingpatches), patch => {
return patch.conflictingpatch.uuid
}),
requirements: mapToArray(ensureArray(patch.requiredpatches), patch => {
return patch.requiredpatch.uuid
})
// TODO: what does it mean, should we handle it?
// version: patch.version,
}
if (patches[patch.uuid].conflicts[0] === undefined) {
patches[patch.uuid].conflicts.length = 0
}
if (patches[patch.uuid].requirements[0] === undefined) {
patches[patch.uuid].requirements.length = 0
}
})
const resolveVersionPatches = function (uuids) {
const versionPatches = createRawObject()
forEach(ensureArray(uuids), ({uuid}) => {
versionPatches[uuid] = patches[uuid]
})
return versionPatches
}
const versions = createRawObject()
let latestVersion
forEach(data.serverversions.version, version => {
versions[version.value] = {
date: version.timestamp,
name: version.name,
id: version.value,
documentationUrl: version.url,
patches: resolveVersionPatches(version.patch)
}
if (version.latest) {
latestVersion = versions[version.value]
}
})
return {
patches,
latestVersion,
versions
}
},
// =================================================================
// Returns installed and not installed patches for a given host.
async _getPoolPatchesForHost (host) {
const versions = (await this._getXenUpdates()).versions
const hostVersions = host.software_version
const version =
versions[hostVersions.product_version] ||
versions[hostVersions.product_version_text]
return version
? version.patches
: []
},
_getInstalledPoolPatchesOnHost (host) {
const installed = createRawObject()
forEach(host.$patches, hostPatch => {
installed[hostPatch.$pool_patch.uuid] = true
})
return installed
},
async _listMissingPoolPatchesOnHost (host) {
const all = await this._getPoolPatchesForHost(host)
const installed = this._getInstalledPoolPatchesOnHost(host)
const installable = createRawObject()
forEach(all, (patch, uuid) => {
if (installed[uuid]) {
return
}
for (const uuid of patch.conflicts) {
if (uuid in installed) {
return
}
}
installable[uuid] = patch
})
return installable
},
async listMissingPoolPatchesOnHost (hostId) {
// Returns an array to not break compatibility.
return mapToArray(
await this._listMissingPoolPatchesOnHost(this.getObject(hostId))
)
},
// -----------------------------------------------------------------
_isPoolPatchInstallableOnHost (patchUuid, host) {
const installed = this._getInstalledPoolPatchesOnHost(host)
if (installed[patchUuid]) {
return false
}
let installable = true
forEach(installed, patch => {
if (includes(patch.conflicts, patchUuid)) {
installable = false
return false
}
})
return installable
},
// -----------------------------------------------------------------
async uploadPoolPatch (stream, patchName = 'unknown') {
const taskRef = await this._createTask('Patch upload', patchName)
const task = this._watchTask(taskRef)
const [ patchRef ] = await Promise.all([
task,
put(stream, {
hostname: this.pool.$master.address,
path: '/pool_patch_upload',
query: {
session_id: this.sessionId,
task_id: taskRef
}
}, task)
])
return this._getOrWaitObject(patchRef)
},
async _getOrUploadPoolPatch (uuid) {
try {
return this.getObjectByUuid(uuid)
} catch (error) {}
debug('downloading patch %s', uuid)
const patchInfo = (await this._getXenUpdates()).patches[uuid]
if (!patchInfo) {
throw new Error('no such patch ' + uuid)
}
let stream = await httpRequest(patchInfo.url, { agent: httpProxy })
stream = await new Promise((resolve, reject) => {
const PATCH_RE = /\.xsupdate$/
stream.pipe(unzip.Parse()).on('entry', entry => {
if (PATCH_RE.test(entry.path)) {
entry.length = entry.size
resolve(entry)
} else {
entry.autodrain()
}
}).on('error', reject)
})
return this.uploadPoolPatch(stream, patchInfo.name)
},
// -----------------------------------------------------------------
async _installPoolPatchOnHost (patchUuid, host) {
debug('installing patch %s', patchUuid)
const patch = await this._getOrUploadPoolPatch(patchUuid)
await this.call('pool_patch.apply', patch.$ref, host.$ref)
},
async installPoolPatchOnHost (patchUuid, hostId) {
return /* await */ this._installPoolPatchOnHost(
patchUuid,
this.getObject(hostId)
)
},
// -----------------------------------------------------------------
async installPoolPatchOnAllHosts (patchUuid) {
const patch = await this._getOrUploadPoolPatch(patchUuid)
await this.call('pool_patch.pool_apply', patch.$ref)
},
// -----------------------------------------------------------------
async _installPoolPatchOnHostAndRequirements (patch, host, patchesByUuid) {
const { requirements } = patch
if (requirements.length) {
for (const requirementUuid of requirements) {
if (this._isPoolPatchInstallableOnHost(requirementUuid, host)) {
const requirement = patchesByUuid[requirementUuid]
await this._installPoolPatchOnHostAndRequirements(requirement, host, patchesByUuid)
host = this.getObject(host.$id)
}
}
}
await this._installPoolPatchOnHost(patch.uuid, host)
},
async installAllPoolPatchesOnHost (hostId) {
let host = this.getObject(hostId)
const installableByUuid = await this._listMissingPoolPatchesOnHost(host)
// List of all installable patches sorted from the newest to the
// oldest.
const installable = sortBy(
installableByUuid,
patch => -Date.parse(patch.date)
)
for (let i = 0, n = installable.length; i < n; ++i) {
const patch = installable[i]
if (this._isPoolPatchInstallableOnHost(patch.uuid, host)) {
await this._installPoolPatchOnHostAndRequirements(patch, host, installableByUuid)
host = this.getObject(host.$id)
}
}
},
async installAllPoolPatchesOnAllHosts () {
await this.installAllPoolPatchesOnHost(this.pool.master)
await Promise.all(mapToArray(
filter(this.objects.all, { $type: 'host' }),
host => this.installAllPoolPatchesOnHost(host.$id)
))
}
}

View File

@@ -1,3 +1,4 @@
import deferrable from 'golike-defer'
import find from 'lodash/find'
import gte from 'lodash/gte'
import lte from 'lodash/lte'
@@ -18,12 +19,11 @@ import {
export default {
// TODO: clean up on error.
async createVm (templateId, {
@deferrable.onFailure
async createVm ($onFailure, templateId, {
name_label, // deprecated
nameLabel = name_label, // eslint-disable-line camelcase
bootAfterCreate = false,
clone = true,
installRepository = undefined,
vdis = undefined,
@@ -34,7 +34,7 @@ export default {
cloudConfig = undefined,
...props
} = {}) {
} = {}, checkLimits) {
const installMethod = (() => {
if (installRepository == null) {
return 'none'
@@ -50,23 +50,23 @@ export default {
const template = this.getObject(templateId)
// Clones the template.
let vm = await this._getOrWaitObject(
await this[clone ? '_cloneVm' : '_copyVm'](template, nameLabel)
)
const vmRef = await this[clone ? '_cloneVm' : '_copyVm'](template, nameLabel)
$onFailure(() => this.deleteVm(vmRef, true)::pCatch(noop))
// TODO: copy BIOS strings?
// Removes disks from the provision XML, we will create them by
// ourselves.
await this.call('VM.remove_from_other_config', vm.$ref, 'disks')::pCatch(noop)
await this.call('VM.remove_from_other_config', vmRef, 'disks')::pCatch(noop)
// Creates the VDIs and executes the initial steps of the
// installation.
await this.call('VM.provision', vm.$ref)
await this.call('VM.provision', vmRef)
let vm = await this._getOrWaitObject(vmRef)
// Set VMs params.
// TODO: checkLimits
this._editVm(vm, props)
await this._editVm(vm, props, checkLimits)
// Sets boot parameters.
{
@@ -112,8 +112,12 @@ export default {
})
}
let nDisks = 0
// Modify existing (previous template) disks if necessary
existingVdis && await Promise.all(mapToArray(existingVdis, async ({ size, $SR: srId, ...properties }, userdevice) => {
++nDisks
const vbd = find(vm.$VBDs, { userdevice })
if (!vbd) {
return
@@ -140,6 +144,8 @@ export default {
if (vdis) {
const devices = await this.call('VM.get_allowed_VBD_devices', vm.$ref)
await Promise.all(mapToArray(vdis, (vdiDescription, i) => {
++nDisks
return this._createVdi(
vdiDescription.size, // FIXME: Should not be done in Xapi.
{
@@ -168,6 +174,8 @@ export default {
vm,
this.getObject(vif.network),
{
ipv4_allowed: vif.ipv4_allowed,
ipv6_allowed: vif.ipv6_allowed,
device: devices[index],
mac: vif.mac,
mtu: vif.mtu
@@ -179,13 +187,16 @@ export default {
if (cloudConfig != null) {
// Refresh the record.
vm = this.getObject(vm.$id)
vm = await this._waitObject(vm.$id, vm => vm.VBDs.length === nDisks)
// Find the SR of the first VDI.
let srRef
forEach(vm.$VBDs, vbd => {
const vdi = vbd.$VDI
if (vdi) {
let vdi
if (
vbd.type === 'Disk' &&
(vdi = vbd.$VDI)
) {
srRef = vdi.SR
return false
}
@@ -197,10 +208,6 @@ export default {
await this[method](vm.$id, srRef, cloudConfig)
}
if (bootAfterCreate) {
this._startVm(vm)::pCatch(noop)
}
return this._waitObject(vm.$id)
},
@@ -243,7 +250,6 @@ export default {
},
cpuCap: {
addToLimits: true,
get: vm => vm.VCPUs_params.cap && +vm.VCPUs_params.cap,
set (cap, vm) {
return this._updateObjectMapProperty(vm, 'VCPUs_params', { cap })
@@ -260,7 +266,6 @@ export default {
},
cpuWeight: {
addToLimits: true,
get: vm => vm.VCPUs_params.weight && +vm.VCPUs_params.weight,
set (weight, vm) {
return this._updateObjectMapProperty(vm, 'VCPUs_params', { weight })
@@ -285,6 +290,7 @@ export default {
memory: 'memoryMax',
memoryMax: {
addToLimits: true,
limitName: 'memory',
constraints: {
memoryMin: lte,
memoryStaticMax: gte
@@ -307,10 +313,20 @@ export default {
nameLabel: true,
PV_args: true
PV_args: true,
tags: true
}),
async editVm (id, props) {
return /* await */ this._editVm(this.getObject(id), props)
},
async revertVm (snapshotId, snapshotBefore = true) {
const snapshot = this.getObject(snapshotId)
if (snapshotBefore) {
await this._snapshotVm(snapshot.$snapshot_of)
}
return this.call('VM.revert', snapshot.$ref)
}
}

View File

@@ -0,0 +1,53 @@
import { NULL_REF } from './utils'
const OTHER_CONFIG_TEMPLATE = {
actions_after_crash: 'restart',
actions_after_reboot: 'restart',
actions_after_shutdown: 'destroy',
affinity: null,
blocked_operations: {},
ha_always_run: false,
HVM_boot_params: {
order: 'cdn'
},
HVM_boot_policy: 'BIOS order',
HVM_shadow_multiplier: 1,
is_a_template: false,
memory_dynamic_max: 4294967296,
memory_dynamic_min: 4294967296,
memory_static_max: 4294967296,
memory_static_min: 134217728,
order: 0,
other_config: {
vgpu_pci: '',
base_template_name: 'Other install media',
mac_seed: '5e88eb6a-d680-c47f-a94a-028886971ba4',
'install-methods': 'cdrom'
},
PCI_bus: '',
platform: {
timeoffset: '0',
nx: 'true',
acpi: '1',
apic: 'true',
pae: 'true',
hpet: 'true',
viridian: 'true'
},
protection_policy: NULL_REF,
PV_args: '',
PV_bootloader: '',
PV_bootloader_args: '',
PV_kernel: '',
PV_legacy_args: '',
PV_ramdisk: '',
recommendations: '<restrictions><restriction field="memory-static-max" max="137438953472" /><restriction field="vcpus-max" max="32" /><restriction property="number-of-vbds" max="255" /><restriction property="number-of-vifs" max="7" /><restriction field="has-vendor-device" value="false" /></restrictions>',
shutdown_delay: 0,
start_delay: 0,
user_version: 1,
VCPUs_at_startup: 1,
VCPUs_max: 1,
VCPUs_params: {},
version: 0
}
export { OTHER_CONFIG_TEMPLATE as default }

View File

@@ -1,9 +1,12 @@
// import isFinite from 'lodash/isFinite'
import camelCase from 'lodash/camelCase'
import createDebug from 'debug'
import isEqual from 'lodash/isEqual'
import isPlainObject from 'lodash/isPlainObject'
import pickBy from 'lodash/pickBy'
import { utcFormat, utcParse } from 'd3-time-format'
import httpRequest from '../http-request'
import {
camelToSnakeCase,
createRawObject,
@@ -12,11 +15,11 @@ import {
isBoolean,
isFunction,
isInteger,
isObject,
isString,
map,
mapToArray,
noop
noop,
pFinally
} from '../utils'
// ===================================================================
@@ -48,7 +51,10 @@ export const prepareXapiParam = param => {
if (isBoolean(param)) {
return asBoolean(param)
}
if (isObject(param)) {
if (isArray(param)) {
return map(param, prepareXapiParam)
}
if (isPlainObject(param)) {
return map(filterUndefineds(param), prepareXapiParam)
}
@@ -57,6 +63,10 @@ export const prepareXapiParam = param => {
// -------------------------------------------------------------------
export const debug = createDebug('xo:xapi')
// -------------------------------------------------------------------
const OPAQUE_REF_RE = /OpaqueRef:[0-9a-z-]+/
export const extractOpaqueRef = str => {
const matches = OPAQUE_REF_RE.exec(str)
@@ -85,6 +95,7 @@ forEach([
'VDI',
'VGPU',
'VGPU_type',
'VIF',
'VLAN',
'VM',
'VM_appliance',
@@ -169,8 +180,9 @@ export const makeEditObject = specs => {
if (isString(set)) {
const index = set.indexOf('.')
if (index === -1) {
const prop = camelToSnakeCase(set)
return function (value) {
return this._set(set, value)
return this._set(prop, value)
}
}
@@ -213,6 +225,9 @@ export const makeEditObject = specs => {
if (spec.addToLimits === true) {
spec.addToLimits = _DEFAULT_ADD_TO_LIMITS
}
if (!spec.limitName) {
spec.limitName = name
}
forEach(spec.constraints, (constraint, constraintName) => {
if (!isFunction(constraint)) {
@@ -291,7 +306,7 @@ export const makeEditObject = specs => {
let addToLimits
if (limits && (addToLimits = spec.addToLimits)) {
limits[name] = addToLimits(value, current)
limits[spec.limitName] = addToLimits(value, current)
}
}
@@ -302,14 +317,20 @@ export const makeEditObject = specs => {
const cbs = []
forEach(constraints, (constraint, constraintName) => {
// This constraint value is already defined: bypass the constraint.
if (values[constraintName] != null) {
return
}
// Before setting a property to a new value, if the constraint check fails (e.g. memoryMin > memoryMax):
// - if the user wants to set the constraint (ie constraintNewValue is defined):
// constraint <-- constraintNewValue THEN property <-- value (e.g. memoryMax <-- 2048 THEN memoryMin <-- 1024)
// - if the user DOES NOT want to set the constraint (ie constraintNewValue is NOT defined):
// constraint <-- value THEN property <-- value (e.g. memoryMax <-- 1024 THEN memoryMin <-- 1024)
// FIXME: Some values combinations will lead to setting the same property twice, which is not perfect but works for now.
const constraintCurrentValue = specs[constraintName].get(object)
const constraintNewValue = values[constraintName]
if (!constraint(specs[constraintName].get(object), value)) {
const cb = set(value, constraintName)
cbs.push(cb)
if (!constraint(constraintCurrentValue, value)) {
const cb = set(constraintNewValue == null ? value : constraintNewValue, constraintName)
if (cb) {
cbs.push(cb)
}
}
})
@@ -330,3 +351,46 @@ export const makeEditObject = specs => {
return Promise.all(mapToArray(cbs, cb => cb())).then(noop)
}
}
// ===================================================================
export const NULL_REF = 'OpaqueRef:NULL'
// ===================================================================
// HTTP put, use an ugly hack if the length is not known because XAPI
// does not support chunk encoding.
export const put = (stream, {
headers: { ...headers } = {},
...opts
}, task) => {
const makeRequest = () => httpRequest({
...opts,
body: stream,
headers,
method: 'put'
})
// Xen API does not support chunk encoding.
if (stream.length == null) {
headers['transfer-encoding'] = null
const promise = makeRequest()
if (task) {
// Some connections need the task to resolve (VDI import).
task::pFinally(() => {
promise.cancel()
})
} else {
// Some tasks need the connection to close (VM import).
promise.request.once('finish', () => {
promise.cancel()
})
}
return promise.readAll()
}
return makeRequest().readAll()
}

View File

View File

@@ -19,11 +19,29 @@ export default class {
constructor (xo) {
this._xo = xo
this._acls = new Acls({
const aclsDb = this._acls = new Acls({
connection: xo._redis,
prefix: 'xo:acl',
indexes: ['subject', 'object']
})
xo.on('start', () => {
xo.addConfigManager('acls',
() => aclsDb.get(),
acls => aclsDb.update(acls)
)
})
xo.on('clean', async () => {
const acls = await aclsDb.get()
const toRemove = []
forEach(acls, ({ subject, object, action, id }) => {
if (!subject || !object || !action) {
toRemove.push(id)
}
})
await aclsDb.remove(toRemove)
})
}
async _getAclsForUser (userId) {
@@ -39,10 +57,9 @@ export default class {
push.apply(acls, entries)
})(acls.push)
const collection = this._acls
await Promise.all(mapToArray(
subjects,
subject => collection.get({subject}).then(pushAcls)
subject => this.getAclsForSubject(subject).then(pushAcls)
))
return acls
@@ -67,6 +84,10 @@ export default class {
return this._acls.get()
}
async getAclsForSubject (subjectId) {
return this._acls.get({ subject: subjectId })
}
async getPermissionsForUser (userId) {
const [
acls,

View File

@@ -1,27 +1,23 @@
import createDebug from 'debug'
const debug = createDebug('xo:api')
import getKeys from 'lodash/keys'
import kindOf from 'kindof'
import moment from 'moment-timezone'
import ms from 'ms'
import schemaInspector from 'schema-inspector'
import * as methods from '../api'
import {
InvalidParameters,
MethodNotFound,
NoSuchObject,
Unauthorized
} from './api-errors'
import {
version as xoServerVersion
} from '../package.json'
MethodNotFound
} from 'json-rpc-peer'
import {
createRawObject,
forEach,
isFunction,
noop
} from './utils'
noop,
serializeError
} from '../utils'
import * as errors from 'xo-common/api-errors'
// ===================================================================
@@ -32,39 +28,30 @@ const PERMISSIONS = {
admin: 3
}
// TODO:
// - error when adding a server to a pool with incompatible version
// - error when halted VM migration failure is due to XS < 7
const XAPI_ERROR_TO_XO_ERROR = {
EHOSTUNREACH: errors.serverUnreachable,
HOST_OFFLINE: ([ host ], getId) => errors.hostOffline({ host: getId(host) }),
NO_HOSTS_AVAILABLE: errors.noHostsAvailable,
NOT_SUPPORTED_DURING_UPGRADE: errors.notSupportedDuringUpgrade,
OPERATION_BLOCKED: ([ ref, code ], getId) => errors.operationBlocked({ objectId: getId(ref), code }),
PATCH_PRECHECK_FAILED_ISO_MOUNTED: ([ patch ]) => errors.patchPrecheck({ errorType: 'isoMounted', patch }),
PIF_VLAN_EXISTS: ([ pif ], getId) => errors.objectAlreadyExists({ objectId: getId(pif), objectType: 'PIF' }),
SESSION_AUTHENTICATION_FAILED: errors.authenticationFailed,
VDI_IN_USE: ([ vdi, operation ], getId) => errors.vdiInUse({ vdi: getId(vdi), operation }),
VM_BAD_POWER_STATE: ([ vm, expected, actual ], getId) => errors.vmBadPowerState({ vm: getId(vm), expected, actual }),
VM_IS_TEMPLATE: errors.vmIsTemplate,
VM_LACKS_FEATURE: ([ vm ], getId) => errors.vmLacksFeature({ vm: getId(vm) }),
VM_LACKS_FEATURE_SHUTDOWN: ([ vm ], getId) => errors.vmLacksFeature({ vm: getId(vm), feature: 'shutdown' }),
VM_MISSING_PV_DRIVERS: ([ vm ], getId) => errors.vmMissingPvDrivers({ vm: getId(vm) })
}
const hasPermission = (user, permission) => (
PERMISSIONS[user.permission] >= PERMISSIONS[permission]
)
// FIXME: this function is specific to XO and should not be defined in
// this file.
function checkPermission (method) {
/* jshint validthis: true */
const {permission} = method
// No requirement.
if (permission === undefined) {
return
}
const {user} = this
if (!user) {
throw new Unauthorized()
}
// The only requirement is login.
if (!permission) {
return
}
if (!hasPermission(user, permission)) {
throw new Unauthorized()
}
}
// -------------------------------------------------------------------
function checkParams (method, params) {
const schema = method.params
if (!schema) {
@@ -77,11 +64,34 @@ function checkParams (method, params) {
}, params)
if (!result.valid) {
throw new InvalidParameters(result.error)
throw errors.invalidParameters(result.error)
}
}
// -------------------------------------------------------------------
function checkPermission (method) {
/* jshint validthis: true */
const {permission} = method
// No requirement.
if (permission === undefined) {
return
}
const {user} = this
if (!user) {
throw errors.unauthorized()
}
// The only requirement is login.
if (!permission) {
return
}
if (!hasPermission(user, permission)) {
throw errors.unauthorized()
}
}
function resolveParams (method, params) {
const resolve = method.resolve
@@ -91,7 +101,7 @@ function resolveParams (method, params) {
const {user} = this
if (!user) {
throw new Unauthorized()
throw errors.unauthorized()
}
const userId = user.id
@@ -127,95 +137,29 @@ function resolveParams (method, params) {
return params
}
throw new Unauthorized()
throw errors.unauthorized()
})
}
// ===================================================================
function getMethodsInfo () {
const methods = {}
forEach(this.api._methods, (method, name) => {
methods[name] = {
description: method.description,
params: method.params || {},
permission: method.permission
}
})
return methods
}
getMethodsInfo.description = 'returns the signatures of all available API methods'
// -------------------------------------------------------------------
const getServerVersion = () => xoServerVersion
getServerVersion.description = 'return the version of xo-server'
// -------------------------------------------------------------------
const getVersion = () => '0.1'
getVersion.description = 'API version (unstable)'
// -------------------------------------------------------------------
function listMethods () {
return getKeys(this.api._methods)
}
listMethods.description = 'returns the name of all available API methods'
// -------------------------------------------------------------------
function methodSignature ({method: name}) {
const method = this.api.getMethod(name)
if (!method) {
throw new NoSuchObject()
}
// Return an array for compatibility with XML-RPC.
return [
// XML-RPC require the name of the method.
{
name,
description: method.description,
params: method.params || {},
permission: method.permission
}
]
}
methodSignature.description = 'returns the signature of an API method'
// ===================================================================
const getServerTimezone = (tz => () => tz)(moment.tz.guess())
getServerTimezone.description = 'return the timezone server'
// ===================================================================
export default class Api {
constructor ({
context,
verboseLogsOnErrors
} = {}) {
constructor (xo) {
this._logger = null
this._methods = createRawObject()
this._verboseLogsOnErrors = verboseLogsOnErrors
this.context = context
this._xo = xo
this.addMethods({
system: {
getMethodsInfo,
getServerVersion,
getServerTimezone,
getVersion,
listMethods,
methodSignature
}
this.addApiMethods(methods)
xo.on('start', async () => {
this._logger = await xo.getLogger('api')
})
}
addMethod (name, method) {
get apiMethods () {
return this._methods
}
addApiMethod (name, method) {
const methods = this._methods
if (name in methods) {
@@ -224,21 +168,22 @@ export default class Api {
methods[name] = method
let unset = () => {
let remove = () => {
delete methods[name]
unset = noop
remove = noop
}
return () => unset()
return () => remove()
}
addMethods (methods) {
addApiMethods (methods) {
let base = ''
const removes = []
const addMethod = (method, name) => {
name = base + name
if (isFunction(method)) {
this.addMethod(name, method)
removes.push(this.addApiMethod(name, method))
return
}
@@ -247,20 +192,35 @@ export default class Api {
forEach(method, addMethod)
base = oldBase
}
forEach(methods, addMethod)
try {
forEach(methods, addMethod)
} catch (error) {
// Remove all added methods.
forEach(removes, remove => remove())
// Forward the error
throw error
}
let remove = () => {
forEach(removes, remove => remove())
remove = noop
}
return remove
}
async call (session, name, params) {
async callApiMethod (session, name, params) {
const startTime = Date.now()
const method = this.getMethod(name)
const method = this._methods[name]
if (!method) {
throw new MethodNotFound(name)
}
// FIXME: it can cause issues if there any property assignments in
// XO methods called from the API.
const context = Object.create(this.context, {
const context = Object.create(this._xo, {
api: { // Used by system.*().
value: this
},
@@ -269,10 +229,9 @@ export default class Api {
}
})
// FIXME: too coupled with XO.
// Fetch and inject the current user.
const userId = session.get('user_id', undefined)
context.user = userId && await context.getUser(userId)
context.user = userId && await this._xo.getUser(userId)
const userName = context.user
? context.user.email
: '(unknown user)'
@@ -293,7 +252,7 @@ export default class Api {
params.id = params[namespace]
}
checkParams(method, params)
checkParams.call(context, method, params)
const resolvedParams = await resolveParams.call(context, method, params)
@@ -315,15 +274,19 @@ export default class Api {
return result
} catch (error) {
if (this._verboseLogsOnErrors) {
debug(
'%s | %s(%j) [%s] =!> %s',
userName,
name,
params,
ms(Date.now() - startTime),
error
)
const data = {
userId,
method: name,
params,
duration: Date.now() - startTime,
error: serializeError(error)
}
const message = `${userName} | ${name}(${JSON.stringify(params)}) [${ms(Date.now() - startTime)}] =!> ${error}`
this._logger.error(message, data)
if (this._xo._config.verboseLogsOnErrors) {
debug(message)
const stack = error && error.stack
if (stack) {
@@ -339,11 +302,18 @@ export default class Api {
)
}
const xoError = XAPI_ERROR_TO_XO_ERROR[error.code]
if (xoError) {
throw xoError(error.params, ref => {
try {
return this._xo.getObject(ref).id
} catch (e) {
return ref
}
})
}
throw error
}
}
getMethod (name) {
return this._methods[name]
}
}

View File

@@ -1,9 +1,8 @@
import Token, { Tokens } from '../models/token'
import {
NoSuchObject
} from '../api-errors'
import { noSuchObject } from 'xo-common/api-errors'
import {
createRawObject,
forEach,
generateToken,
pCatch,
noop
@@ -11,13 +10,8 @@ import {
// ===================================================================
class NoSuchAuthenticationToken extends NoSuchObject {
constructor (id) {
super(id, 'authentication token')
}
}
// ===================================================================
const noSuchAuthenticationToken = id =>
noSuchObject(id, 'authenticationToken')
export default class {
constructor (xo) {
@@ -30,7 +24,7 @@ export default class {
this._providers = new Set()
// Creates persistent collections.
this._tokens = new Tokens({
const tokensDb = this._tokens = new Tokens({
connection: xo._redis,
prefix: 'xo:token',
indexes: ['user_id']
@@ -65,6 +59,25 @@ export default class {
return
}
})
xo.on('clean', async () => {
const tokens = await tokensDb.get()
const toRemove = []
const now = Date.now()
forEach(tokens, ({ expiration, id }) => {
if (!expiration || expiration < now) {
toRemove.push(id)
}
})
await tokensDb.remove(toRemove)
})
xo.on('start', () => {
xo.addConfigManager('authTokens',
() => tokensDb.get(),
tokens => tokensDb.update(tokens)
)
})
}
registerAuthenticationProvider (provider) {
@@ -152,14 +165,14 @@ export default class {
async deleteAuthenticationToken (id) {
if (!await this._tokens.remove(id)) {
throw new NoSuchAuthenticationToken(id)
throw noSuchAuthenticationToken(id)
}
}
async getAuthenticationToken (id) {
let token = await this._tokens.first(id)
if (!token) {
throw new NoSuchAuthenticationToken(id)
throw noSuchAuthenticationToken(id)
}
token = token.properties
@@ -169,7 +182,7 @@ export default class {
)) {
this._tokens.remove(id)::pCatch(noop)
throw new NoSuchAuthenticationToken(id)
throw noSuchAuthenticationToken(id)
}
return token

View File

@@ -1,3 +1,4 @@
import deferrable from 'golike-defer'
import endsWith from 'lodash/endsWith'
import escapeStringRegexp from 'escape-string-regexp'
import eventToPromise from 'event-to-promise'
@@ -11,19 +12,18 @@ import {
dirname
} from 'path'
import { satisfies as versionSatisfies } from 'semver'
import { utcFormat } from 'd3-time-format'
import vhdMerge from '../vhd-merge'
import vhdMerge, { chainVhd } from '../vhd-merge'
import xapiObjectToXo from '../xapi-object-to-xo'
import {
deferrable
} from '../decorators'
import {
forEach,
mapToArray,
noop,
pCatch,
pSettle,
safeDateFormat
safeDateFormat,
safeDateParse
} from '../utils'
import {
VDI_FORMAT_VHD
@@ -34,6 +34,8 @@ import {
const DELTA_BACKUP_EXT = '.json'
const DELTA_BACKUP_EXT_LENGTH = DELTA_BACKUP_EXT.length
const shortDate = utcFormat('%Y-%m-%d')
// Test if a file is a vdi backup. (full or delta)
const isVdiBackup = name => /^\d+T\d+Z_(?:full|delta)\.vhd$/.test(name)
@@ -41,6 +43,37 @@ const isVdiBackup = name => /^\d+T\d+Z_(?:full|delta)\.vhd$/.test(name)
const isDeltaVdiBackup = name => /^\d+T\d+Z_delta\.vhd$/.test(name)
const isFullVdiBackup = name => /^\d+T\d+Z_full\.vhd$/.test(name)
const parseVmBackupPath = name => {
const base = basename(name)
let baseMatches
baseMatches = /^([^_]+)_([^_]+)_(.+)\.xva$/.exec(base)
if (baseMatches) {
return {
datetime: safeDateParse(baseMatches[1]),
name: baseMatches[3],
tag: baseMatches[2],
type: 'xva'
}
}
let dirMatches
if (
(baseMatches = /^([^_]+)_(.+)\.json$/.exec(base)) &&
(dirMatches = /^vm_delta_([^_]+)_(.+)$/.exec(basename(dirname(name))))
) {
return {
datetime: safeDateParse(baseMatches[1]),
name: baseMatches[2],
uuid: dirMatches[2],
tag: dirMatches[1],
type: 'delta'
}
}
throw new Error('invalid VM backup filename')
}
// Get the timestamp of a vdi backup. (full or delta)
const getVdiTimestamp = name => {
const arr = /^(\d+T\d+Z)_(?:full|delta)\.vhd$/.exec(name)
@@ -50,21 +83,28 @@ const getVdiTimestamp = name => {
const getDeltaBackupNameWithoutExt = name => name.slice(0, -DELTA_BACKUP_EXT_LENGTH)
const isDeltaBackup = name => endsWith(name, DELTA_BACKUP_EXT)
// Checksums have been corrupted between 5.2.6 and 5.2.7.
//
// For a short period of time, bad checksums will be regenerated
// instead of rejected.
//
// TODO: restore when enough time has passed (a week/a month).
async function checkFileIntegrity (handler, name) {
let stream
try {
stream = await handler.createReadStream(name, { checksum: true })
} catch (error) {
if (error.code === 'ENOENT') {
return
}
throw error
}
stream.resume()
await eventToPromise(stream, 'finish')
await handler.refreshChecksum(name)
// let stream
//
// try {
// stream = await handler.createReadStream(name, { checksum: true })
// } catch (error) {
// if (error.code === 'ENOENT') {
// return
// }
//
// throw error
// }
//
// stream.resume()
// await eventToPromise(stream, 'finish')
}
// ===================================================================
@@ -107,6 +147,15 @@ export default class {
const xapi = this._xo.getXapi(sr)
const vm = await xapi.importVm(stream, { srId: sr._xapiId })
const { datetime } = parseVmBackupPath(file)
await Promise.all([
xapi.addTag(vm.$id, 'restored from backup'),
xapi.editVm(vm.$id, {
name_label: `${vm.name_label} (${shortDate(datetime)})`
})
])
return xapiObjectToXo(vm).id
}
@@ -291,6 +340,18 @@ export default class {
return backups.slice(i)
}
// fix the parent UUID and filename in delta files after download from xapi or backup compression
async _chainDeltaVdiBackups ({handler, dir}) {
const backups = await this._listVdiBackups(handler, dir)
for (let i = 1; i < backups.length; i++) {
const childPath = dir + '/' + backups[i]
const modified = await chainVhd(handler, dir + '/' + backups[i - 1], handler, childPath)
if (modified) {
await handler.refreshChecksum(childPath)
}
}
}
async _mergeDeltaVdiBackups ({handler, dir, depth}) {
const backups = await this._listVdiBackups(handler, dir)
let i = backups.length - depth
@@ -553,7 +614,9 @@ export default class {
mapToArray(vdiBackups, vdiBackup => {
const backupName = vdiBackup.value()
const backupDirectory = backupName.slice(0, backupName.lastIndexOf('/'))
return this._mergeDeltaVdiBackups({ handler, dir: `${dir}/${backupDirectory}`, depth })
const backupDir = `${dir}/${backupDirectory}`
return this._mergeDeltaVdiBackups({ handler, dir: backupDir, depth })
.then(() => { this._chainDeltaVdiBackups({ handler, dir: backupDir }) })
})
)
@@ -569,10 +632,13 @@ export default class {
}
async importDeltaVmBackup ({sr, remoteId, filePath}) {
filePath = `${filePath}${DELTA_BACKUP_EXT}`
const { datetime } = parseVmBackupPath(filePath)
const handler = await this._xo.getRemoteHandler(remoteId)
const xapi = this._xo.getXapi(sr)
const delta = JSON.parse(await handler.readFile(`${filePath}${DELTA_BACKUP_EXT}`))
const delta = JSON.parse(await handler.readFile(filePath))
let vm
const { version } = delta
@@ -599,9 +665,12 @@ export default class {
)
)
delta.vm.name_label += ` (${shortDate(datetime)})`
delta.vm.tags.push('restored from backup')
vm = await xapi.importDeltaVm(delta, {
srId: sr._xapiId,
disableStartAfterImport: false
disableStartAfterImport: false,
srId: sr._xapiId
})
} else {
throw new Error(`Unsupported delta backup version: ${version}`)
@@ -690,12 +759,12 @@ export default class {
const sourceXapi = this._xo.getXapi(vm)
vm = sourceXapi.getObject(vm._xapiId)
const vms = []
const vms = {}
forEach(sr.$VDIs, vdi => {
const vbds = vdi.$VBDs
const vm = vbds && vbds[0] && vbds[0].$VM
if (vm && reg.test(vm.name_label)) {
vms.push(vm)
vms[vm.$id] = vm
}
})
const olderCopies = sortBy(vms, 'name_label')
@@ -706,11 +775,10 @@ export default class {
})
await targetXapi.addTag(drCopy.$id, 'Disaster Recovery')
const promises = []
for (let surplus = olderCopies.length - (depth - 1); surplus > 0; surplus--) {
const oldDRVm = olderCopies.shift()
promises.push(targetXapi.deleteVm(oldDRVm.$id, true))
}
await Promise.all(promises)
const n = 1 - depth
await Promise.all(mapToArray(n ? olderCopies.slice(0, n) : olderCopies, vm =>
// Do not consider a failure to delete an old copy as a fatal error.
targetXapi.deleteVm(vm.$id, true)::pCatch(noop)
))
}
}

View File

@@ -0,0 +1,33 @@
import { map, noop } from '../utils'
import { all as pAll } from 'promise-toolbox'
export default class ConfigManagement {
constructor () {
this._managers = { __proto__: null }
}
addConfigManager (id, exporter, importer) {
const managers = this._managers
if (id in managers) {
throw new Error(`${id} is already taken`)
}
this._managers[id] = { exporter, importer }
}
exportConfig () {
return map(this._managers, ({ exporter }, key) => exporter())::pAll()
}
importConfig (config) {
const managers = this._managers
return map(config, (entry, key) => {
const manager = managers[key]
if (manager) {
return manager.importer(entry)
}
})::pAll().then(noop)
}
}

265
src/xo-mixins/ip-pools.js Normal file
View File

@@ -0,0 +1,265 @@
import concat from 'lodash/concat'
import diff from 'lodash/difference'
import findIndex from 'lodash/findIndex'
import flatten from 'lodash/flatten'
import highland from 'highland'
import includes from 'lodash/includes'
import keys from 'lodash/keys'
import mapValues from 'lodash/mapValues'
import pick from 'lodash/pick'
import remove from 'lodash/remove'
import { noSuchObject } from 'xo-common/api-errors'
import { fromCallback } from 'promise-toolbox'
import {
forEach,
generateUnsecureToken,
isEmpty,
lightSet,
mapToArray,
streamToArray,
throwFn
} from '../utils'
// ===================================================================
const normalize = ({
addresses,
id = throwFn('id is a required field'),
name = '',
networks,
resourceSets
}) => ({
addresses,
id,
name,
networks,
resourceSets
})
// ===================================================================
// Note: an address cannot be in two different pools sharing a
// network.
export default class IpPools {
constructor (xo) {
this._store = null
this._xo = xo
xo.on('start', async () => {
this._store = await xo.getStore('ipPools')
xo.addConfigManager('ipPools',
() => this.getAllIpPools(),
ipPools => Promise.all(mapToArray(ipPools, ipPool => this._save(ipPool)))
)
})
}
async createIpPool ({ addresses, name, networks }) {
const id = await this._generateId()
await this._save({
addresses,
id,
name,
networks
})
return id
}
async deleteIpPool (id) {
const store = this._store
if (await store.has(id)) {
await Promise.all(mapToArray(await this._xo.getAllResourceSets(), async set => {
await this._xo.removeLimitFromResourceSet(`ipPool:${id}`, set.id)
return this._xo.removeIpPoolFromResourceSet(id, set.id)
}))
await this._removeIpAddressesFromVifs(
mapValues((await this.getIpPool(id)).addresses, 'vifs')
)
return store.del(id)
}
throw noSuchObject(id, 'ipPool')
}
async getAllIpPools (userId = undefined) {
let filter
if (userId != null) {
const user = await this._xo.getUser(userId)
if (user.permission !== 'admin') {
const resourceSets = await this._xo.getAllResourceSets(userId)
const ipPools = lightSet(flatten(mapToArray(resourceSets, 'ipPools')))
filter = ({ id }) => ipPools.has(id)
}
}
return streamToArray(this._store.createValueStream(), {
filter,
mapper: normalize
})
}
getIpPool (id) {
return this._store.get(id).then(normalize, error => {
throw error.notFound ? noSuchObject(id, 'ipPool') : error
})
}
allocIpAddresses (vifId, addAddresses, removeAddresses) {
const updatedIpPools = {}
const limits = {}
const xoVif = this._xo.getObject(vifId)
const xapi = this._xo.getXapi(xoVif)
const vif = xapi.getObject(xoVif._xapiId)
const allocAndSave = (() => {
const resourseSetId = xapi.xo.getData(vif.VM, 'resourceSet')
return () => {
const saveIpPools = () => Promise.all(mapToArray(updatedIpPools, ipPool => this._save(ipPool)))
return resourseSetId
? this._xo.allocateLimitsInResourceSet(limits, resourseSetId).then(
saveIpPools
)
: saveIpPools()
}
})()
return fromCallback(cb => {
const network = vif.$network
const networkId = network.$id
const isVif = id => id === vifId
highland(this._store.createValueStream()).each(ipPool => {
const { addresses, networks } = updatedIpPools[ipPool.id] || ipPool
if (!(addresses && networks && includes(networks, networkId))) {
return false
}
let allocations = 0
let changed = false
forEach(removeAddresses, address => {
let vifs, i
if (
(vifs = addresses[address]) &&
(vifs = vifs.vifs) &&
(i = findIndex(vifs, isVif)) !== -1
) {
vifs.splice(i, 1)
--allocations
changed = true
}
})
forEach(addAddresses, address => {
const data = addresses[address]
if (!data) {
return
}
const vifs = data.vifs || (data.vifs = [])
if (!includes(vifs, vifId)) {
vifs.push(vifId)
++allocations
changed = true
}
})
if (changed) {
const { id } = ipPool
updatedIpPools[id] = ipPool
limits[`ipPool:${id}`] = (limits[`ipPool:${id}`] || 0) + allocations
}
}).toCallback(cb)
}).then(allocAndSave)
}
async _removeIpAddressesFromVifs (mapAddressVifs) {
const mapVifAddresses = {}
forEach(mapAddressVifs, (vifs, address) => {
forEach(vifs, vifId => {
if (mapVifAddresses[vifId]) {
mapVifAddresses[vifId].push(address)
} else {
mapVifAddresses[vifId] = [ address ]
}
})
})
const { getXapi } = this._xo
return Promise.all(mapToArray(mapVifAddresses, (addresses, vifId) => {
const vif = this._xo.getObject(vifId)
const { allowedIpv4Addresses, allowedIpv6Addresses } = vif
remove(allowedIpv4Addresses, address => includes(addresses, address))
remove(allowedIpv6Addresses, address => includes(addresses, address))
this.allocIpAddresses(vifId, undefined, concat(allowedIpv4Addresses, allowedIpv6Addresses))
return getXapi(vif).editVif(vif._xapiId, {
ipv4Allowed: allowedIpv4Addresses,
ipv6Allowed: allowedIpv6Addresses
})
}))
}
async updateIpPool (id, {
addresses,
name,
networks,
resourceSets
}) {
const ipPool = await this.getIpPool(id)
const previousAddresses = { ...ipPool.addresses }
name != null && (ipPool.name = name)
if (addresses) {
const addresses_ = ipPool.addresses || {}
forEach(addresses, (props, address) => {
if (props === null) {
delete addresses_[address]
} else {
addresses_[address] = props
}
})
// Remove the addresses that are no longer in the IP pool from the concerned VIFs
const deletedAddresses = diff(keys(previousAddresses), keys(addresses_))
await this._removeIpAddressesFromVifs(pick(previousAddresses, deletedAddresses))
if (isEmpty(addresses_)) {
delete ipPool.addresses
} else {
ipPool.addresses = addresses_
}
}
// TODO: Implement patching like for addresses.
if (networks) {
ipPool.networks = networks
}
// TODO: Implement patching like for addresses.
if (resourceSets) {
ipPool.resourceSets = resourceSets
}
await this._save(ipPool)
}
async _generateId () {
let id
do {
id = generateUnsecureToken(8)
} while (await this._store.has(id))
return id
}
_save (ipPool) {
ipPool = normalize(ipPool)
return this._store.put(ipPool.id, ipPool)
}
}

View File

@@ -1,29 +1,29 @@
import assign from 'lodash/assign'
import JobExecutor from '../job-executor'
import { Jobs } from '../models/job'
import {
GenericError,
NoSuchObject
} from '../api-errors'
// ===================================================================
class NoSuchJob extends NoSuchObject {
constructor (id) {
super(id, 'job')
}
}
import { mapToArray } from '../utils'
import { noSuchObject } from 'xo-common/api-errors'
// ===================================================================
export default class {
constructor (xo) {
this._executor = new JobExecutor(xo)
this._jobs = new Jobs({
const jobsDb = this._jobs = new Jobs({
connection: xo._redis,
prefix: 'xo:job',
indexes: ['user_id', 'key']
})
xo.on('start', () => {
xo.addConfigManager('jobs',
() => jobsDb.get(),
jobs => Promise.all(mapToArray(jobs, job =>
jobsDb.save(job)
))
)
})
}
async getAllJobs () {
@@ -33,21 +33,21 @@ export default class {
async getJob (id) {
const job = await this._jobs.first(id)
if (!job) {
throw new NoSuchJob(id)
throw noSuchObject(id, 'job')
}
return job.properties
}
async createJob (userId, job) {
async createJob (job) {
// TODO: use plain objects
const job_ = await this._jobs.create(userId, job)
const job_ = await this._jobs.create(job)
return job_.properties
}
async updateJob ({id, type, name, key, method, paramsVector}) {
async updateJob ({id, userId, type, name, key, method, paramsVector}) {
const oldJob = await this.getJob(id)
assign(oldJob, {type, name, key, method, paramsVector})
assign(oldJob, {userId, type, name, key, method, paramsVector})
return /* await */ this._jobs.save(oldJob)
}
@@ -56,24 +56,10 @@ export default class {
}
async runJobSequence (idSequence) {
const notFound = []
for (const id of idSequence) {
let job
try {
job = await this.getJob(id)
} catch (error) {
if (error instanceof NoSuchJob) {
notFound.push(id)
} else {
throw error
}
}
if (job) {
await this._executor.exec(job)
}
}
if (notFound.length > 0) {
throw new GenericError(`The following jobs were not found: ${notFound.join()}`)
const jobs = await Promise.all(mapToArray(idSequence, id => this.getJob(id)))
for (const job of jobs) {
await this._executor.exec(job)
}
}
}

View File

@@ -2,9 +2,9 @@ import createJsonSchemaValidator from 'is-my-json-valid'
import { PluginsMetadata } from '../models/plugin-metadata'
import {
InvalidParameters,
NoSuchObject
} from '../api-errors'
invalidParameters,
noSuchObject
} from 'xo-common/api-errors'
import {
createRawObject,
isFunction,
@@ -13,14 +13,6 @@ import {
// ===================================================================
class NoSuchPlugin extends NoSuchObject {
constructor (id) {
super(id, 'plugin')
}
}
// ===================================================================
export default class {
constructor (xo) {
this._plugins = createRawObject()
@@ -29,12 +21,21 @@ export default class {
connection: xo._redis,
prefix: 'xo:plugin-metadata'
})
xo.on('start', () => {
xo.addConfigManager('plugins',
() => this._pluginsMetadata.get(),
plugins => Promise.all(mapToArray(plugins, plugin =>
this._pluginsMetadata.save(plugin)
))
)
})
}
_getRawPlugin (id) {
const plugin = this._plugins[id]
if (!plugin) {
throw new NoSuchPlugin(id)
throw noSuchObject(id, 'plugin')
}
return plugin
}
@@ -51,16 +52,19 @@ export default class {
instance,
configurationSchema,
configurationPresets,
testSchema,
version
) {
const id = name
const plugin = this._plugins[id] = {
configured: !configurationSchema,
configurationPresets,
configurationSchema,
configured: !configurationSchema,
id,
instance,
name,
testable: isFunction(instance.test),
testSchema,
unloadable: isFunction(instance.unload),
version
}
@@ -68,7 +72,6 @@ export default class {
const metadata = await this._getPluginMetadata(id)
let autoload = true
let configuration
if (metadata) {
({
autoload,
@@ -107,6 +110,8 @@ export default class {
configurationSchema,
loaded,
name,
testable,
testSchema,
unloadable,
version
} = this._getRawPlugin(id)
@@ -124,7 +129,9 @@ export default class {
version,
configuration,
configurationPresets,
configurationSchema
configurationSchema,
testable,
testSchema
}
}
@@ -139,12 +146,12 @@ export default class {
const { configurationSchema } = plugin
if (!configurationSchema) {
throw new InvalidParameters('plugin not configurable')
throw invalidParameters('plugin not configurable')
}
// See: https://github.com/mafintosh/is-my-json-valid/issues/116
if (configuration == null) {
throw new InvalidParameters([{
throw invalidParameters([{
field: 'data',
message: 'is the wrong type'
}])
@@ -152,7 +159,7 @@ export default class {
const validate = createJsonSchemaValidator(configurationSchema)
if (!validate(configuration)) {
throw new InvalidParameters(validate.errors)
throw invalidParameters(validate.errors)
}
// Sets the plugin configuration.
@@ -191,11 +198,11 @@ export default class {
async loadPlugin (id) {
const plugin = this._getRawPlugin(id)
if (plugin.loaded) {
throw new InvalidParameters('plugin already loaded')
throw invalidParameters('plugin already loaded')
}
if (!plugin.configured) {
throw new InvalidParameters('plugin not configured')
throw invalidParameters('plugin not configured')
}
await plugin.instance.load()
@@ -205,11 +212,11 @@ export default class {
async unloadPlugin (id) {
const plugin = this._getRawPlugin(id)
if (!plugin.loaded) {
throw new InvalidParameters('plugin already unloaded')
throw invalidParameters('plugin already unloaded')
}
if (plugin.unloadable === false) {
throw new InvalidParameters('plugin cannot be unloaded')
throw invalidParameters('plugin cannot be unloaded')
}
await plugin.instance.unload()
@@ -219,4 +226,31 @@ export default class {
async purgePluginConfiguration (id) {
await this._pluginsMetadata.merge(id, { configuration: undefined })
}
async testPlugin (id, data) {
const plugin = this._getRawPlugin(id)
if (!plugin.testable) {
throw invalidParameters('plugin not testable')
}
if (!plugin.loaded) {
throw invalidParameters('plugin not loaded')
}
const { testSchema } = plugin
if (testSchema) {
if (data == null) {
throw invalidParameters([{
field: 'data',
message: 'is the wrong type'
}])
}
const validate = createJsonSchemaValidator(testSchema)
if (!validate(data)) {
throw invalidParameters(validate.errors)
}
}
await plugin.instance.test(data)
}
}

View File

@@ -1,26 +1,18 @@
import { noSuchObject } from 'xo-common/api-errors'
import RemoteHandlerLocal from '../remote-handlers/local'
import RemoteHandlerNfs from '../remote-handlers/nfs'
import RemoteHandlerSmb from '../remote-handlers/smb'
import {
forEach
forEach,
mapToArray
} from '../utils'
import {
NoSuchObject
} from '../api-errors'
import {
Remotes
} from '../models/remote'
// ===================================================================
class NoSuchRemote extends NoSuchObject {
constructor (id) {
super(id, 'remote')
}
}
// ===================================================================
export default class {
constructor (xo) {
this._remotes = new Remotes({
@@ -30,6 +22,13 @@ export default class {
})
xo.on('start', async () => {
xo.addConfigManager('remotes',
() => this._remotes.get(),
remotes => Promise.all(mapToArray(remotes, remote =>
this._remotes.save(remote)
))
)
await this.initRemotes()
await this.syncAllRemotes()
})
@@ -66,7 +65,7 @@ export default class {
async _getRemote (id) {
const remote = await this._remotes.first(id)
if (!remote) {
throw new NoSuchRemote(id)
throw noSuchObject(id, 'remote')
}
return remote

View File

@@ -2,11 +2,11 @@ import every from 'lodash/every'
import keyBy from 'lodash/keyBy'
import remove from 'lodash/remove'
import some from 'lodash/some'
import {
NoSuchObject,
Unauthorized
} from '../api-errors'
noSuchObject,
unauthorized
} from 'xo-common/api-errors'
import {
forEach,
generateUnsecureToken,
@@ -19,10 +19,12 @@ import {
// ===================================================================
class NoSuchResourceSet extends NoSuchObject {
constructor (id) {
super(id, 'resource set')
}
const VM_RESOURCES = {
cpus: true,
disk: true,
disks: true,
memory: true,
vms: true
}
const computeVmResourcesUsage = vm => {
@@ -54,6 +56,7 @@ const computeVmResourcesUsage = vm => {
const normalize = set => ({
id: set.id,
ipPools: set.ipPools || [],
limits: set.limits
? map(set.limits, limit => isObject(limit)
? limit
@@ -76,6 +79,13 @@ export default class {
this._store = null
xo.on('start', async () => {
xo.addConfigManager('resourceSets',
() => this.getAllResourceSets(),
resourceSets => Promise.all(mapToArray(resourceSets, resourceSet =>
this._save(resourceSet)
))
)
this._store = await xo.getStore('resourceSets')
})
}
@@ -108,7 +118,7 @@ export default class {
// The set does not contains ALL objects.
!every(objectIds, lightSet(set.objects).has)
)) {
throw new Unauthorized()
throw unauthorized()
}
}
@@ -140,14 +150,15 @@ export default class {
return store.del(id)
}
throw new NoSuchResourceSet(id)
throw noSuchObject(id, 'resourceSet')
}
async updateResourceSet (id, {
name = undefined,
subjects = undefined,
objects = undefined,
limits = undefined
limits = undefined,
ipPools = undefined
}) {
const set = await this.getResourceSet(id)
if (name) {
@@ -178,6 +189,9 @@ export default class {
}
})
}
if (ipPools) {
set.ipPools = ipPools
}
await this._save(set)
}
@@ -203,7 +217,7 @@ export default class {
getResourceSet (id) {
return this._store.get(id).then(normalize, error => {
if (error.notFound) {
throw new NoSuchResourceSet(id)
throw noSuchObject(id, 'resourceSet')
}
throw error
@@ -218,7 +232,19 @@ export default class {
async removeObjectFromResourceSet (objectId, setId) {
const set = await this.getResourceSet(setId)
remove(set.objects)
remove(set.objects, id => id === objectId)
await this._save(set)
}
async addIpPoolToResourceSet (ipPoolId, setId) {
const set = await this.getResourceSet(setId)
set.ipPools.push(ipPoolId)
await this._save(set)
}
async removeIpPoolFromResourceSet (ipPoolId, setId) {
const set = await this.getResourceSet(setId)
remove(set.ipPools, id => id === ipPoolId)
await this._save(set)
}
@@ -230,7 +256,7 @@ export default class {
async removeSubjectToResourceSet (subjectId, setId) {
const set = await this.getResourceSet(setId)
remove(set.subjects, subjectId)
remove(set.subjects, id => id === subjectId)
await this._save(set)
}
@@ -280,7 +306,9 @@ export default class {
const sets = keyBy(await this.getAllResourceSets(), 'id')
forEach(sets, ({ limits }) => {
forEach(limits, (limit, id) => {
limit.available = limit.total
if (VM_RESOURCES[limit]) { // only reset VMs related limits
limit.available = limit.total
}
})
})

View File

@@ -1,9 +1,10 @@
import { BaseError } from 'make-error'
import { NoSuchObject } from '../api-errors.js'
import { Schedules } from '../models/schedule'
import { noSuchObject } from 'xo-common/api-errors.js'
import { Schedules } from '../models/schedule'
import {
forEach,
mapToArray,
scheduleFn
} from '../utils'
@@ -19,12 +20,6 @@ export class ScheduleOverride extends SchedulerError {
}
}
export class NoSuchSchedule extends NoSuchObject {
constructor (scheduleOrId) {
super(scheduleOrId, 'schedule')
}
}
export class ScheduleNotEnabled extends SchedulerError {
constructor (scheduleOrId) {
super('Schedule ' + _resolveId(scheduleOrId)) + ' is not enabled'
@@ -42,14 +37,23 @@ export class ScheduleAlreadyEnabled extends SchedulerError {
export default class {
constructor (xo) {
this.xo = xo
this._redisSchedules = new Schedules({
const schedules = this._redisSchedules = new Schedules({
connection: xo._redis,
prefix: 'xo:schedule',
indexes: ['user_id', 'job']
})
this._scheduleTable = undefined
xo.on('start', () => this._loadSchedules())
xo.on('start', () => {
xo.addConfigManager('schedules',
() => schedules.get(),
schedules_ => Promise.all(mapToArray(schedules_, schedule =>
schedules.save(schedule)
))
)
return this._loadSchedules()
})
xo.on('stop', () => this._disableAll())
}
@@ -86,7 +90,7 @@ export default class {
_disable (scheduleOrId) {
if (!this._exists(scheduleOrId)) {
throw new NoSuchSchedule(scheduleOrId)
throw noSuchObject(scheduleOrId, 'schedule')
}
if (!this._isEnabled(scheduleOrId)) {
throw new ScheduleNotEnabled(scheduleOrId)
@@ -125,7 +129,7 @@ export default class {
const schedule = await this._redisSchedules.first(id)
if (!schedule) {
throw new NoSuchSchedule(id)
throw noSuchObject(id, 'schedule')
}
return schedule
@@ -166,7 +170,7 @@ export default class {
const { properties } = schedule
if (!this._exists(properties)) {
throw new NoSuchSchedule(properties)
throw noSuchObject(properties, 'schedule')
}
if (this._isEnabled(properties)) {
@@ -182,7 +186,7 @@ export default class {
try {
this._disable(id)
} catch (exc) {
if (!exc instanceof SchedulerError) {
if (!(exc instanceof SchedulerError)) {
throw exc
}
} finally {

View File

@@ -54,7 +54,7 @@ const levelPromise = db => {
dbP[name] = db::value
} else {
dbP[`${name}Sync`] = db::value
dbP[name] = value::promisify(db)
dbP[name] = promisify(value, db)
}
})

View File

@@ -5,11 +5,11 @@ import {
needsRehash,
verify
} from 'hashy'
import {
InvalidCredential,
NoSuchObject
} from '../api-errors'
invalidCredentials,
noSuchObject
} from 'xo-common/api-errors'
import {
Groups
} from '../models/group'
@@ -27,18 +27,6 @@ import {
// ===================================================================
class NoSuchGroup extends NoSuchObject {
constructor (id) {
super(id, 'group')
}
}
class NoSuchUser extends NoSuchObject {
constructor (id) {
super(id, 'user')
}
}
const addToArraySet = (set, value) => set && !includes(set, value)
? set.concat(value)
: [ value ]
@@ -52,22 +40,40 @@ export default class {
const redis = xo._redis
this._groups = new Groups({
const groupsDb = this._groups = new Groups({
connection: redis,
prefix: 'xo:group'
})
const users = this._users = new Users({
const usersDb = this._users = new Users({
connection: redis,
prefix: 'xo:user',
indexes: ['email']
})
xo.on('start', async () => {
if (!await users.exists()) {
xo.addConfigManager('groups',
() => groupsDb.get(),
groups => Promise.all(mapToArray(groups, group => groupsDb.save(group)))
)
xo.addConfigManager('users',
() => usersDb.get(),
users => Promise.all(mapToArray(users, async user => {
const userId = user.id
const conflictUsers = await usersDb.get({ email: user.email })
if (!isEmpty(conflictUsers)) {
await Promise.all(mapToArray(conflictUsers, ({ id }) =>
(id !== userId) && this.deleteUser(user.id)
))
}
return usersDb.save(user)
}))
)
if (!await usersDb.exists()) {
const email = 'admin@admin.net'
const password = 'admin'
await this.createUser(email, {password, permission: 'admin'})
await this.createUser({email, password, permission: 'admin'})
console.log('[INFO] Default user created:', email, ' with password', password)
}
})
@@ -75,13 +81,17 @@ export default class {
// -----------------------------------------------------------------
async createUser (email, { password, ...properties }) {
async createUser ({ name, password, ...properties }) {
if (name) {
properties.email = name
}
if (password) {
properties.pw_hash = await hash(password)
}
// TODO: use plain objects
const user = await this._users.create(email, properties)
const user = await this._users.create(properties)
return user.properties
}
@@ -100,6 +110,13 @@ export default class {
})
::pCatch(noop) // Ignore any failures.
// Remove ACLs for this user.
this._xo.getAclsForSubject(id).then(acls => {
forEach(acls, acl => {
this._xo.removeAcl(id, acl.object, acl.action)::pCatch(noop)
})
})
// Remove the user from all its groups.
forEach(user.groups, groupId => {
this.getGroup(groupId)
@@ -152,7 +169,7 @@ export default class {
async _getUser (id) {
const user = await this._users.first(id)
if (!user) {
throw new NoSuchUser(id)
throw noSuchObject(id, 'user')
}
return user
@@ -185,7 +202,7 @@ export default class {
return null
}
throw new NoSuchUser(username)
throw noSuchObject(username, 'user')
}
// Get or create a user associated with an auth provider.
@@ -203,14 +220,15 @@ export default class {
throw new Error(`registering ${name} user is forbidden`)
}
return /* await */ this.createUser(name, {
return /* await */ this.createUser({
name,
_provider: provider
})
}
async changeUserPassword (userId, oldPassword, newPassword) {
if (!(await this.checkUserPassword(userId, oldPassword, false))) {
throw new InvalidCredential()
throw invalidCredentials()
}
await this.updateUser(userId, { password: newPassword })
@@ -247,6 +265,13 @@ export default class {
await this._groups.remove(id)
// Remove ACLs for this group.
this._xo.getAclsForSubject(id).then(acls => {
forEach(acls, acl => {
this._xo.removeAcl(id, acl.object, acl.action)::pCatch(noop)
})
})
// Remove the group from all its users.
forEach(group.users, userId => {
this.getUser(userId)
@@ -266,7 +291,7 @@ export default class {
async getGroup (id) {
const group = await this._groups.first(id)
if (!group) {
throw new NoSuchGroup(id)
throw noSuchObject(id, 'group')
}
return group.properties

View File

@@ -1,10 +1,8 @@
import { noSuchObject } from 'xo-common/api-errors'
import Xapi from '../xapi'
import xapiObjectToXo from '../xapi-object-to-xo'
import XapiStats from '../xapi-stats'
import {
GenericError,
NoSuchObject
} from '../api-errors'
import {
camelToSnakeCase,
createRawObject,
@@ -21,18 +19,10 @@ import {
// ===================================================================
class NoSuchXenServer extends NoSuchObject {
constructor (id) {
super(id, 'xen server')
}
}
// ===================================================================
export default class {
constructor (xo) {
this._objectConflicts = createRawObject() // TODO: clean when a server is disconnected.
this._servers = new Servers({
const serversDb = this._servers = new Servers({
connection: xo._redis,
prefix: 'xo:server',
indexes: ['host']
@@ -43,8 +33,13 @@ export default class {
this._xo = xo
xo.on('start', async () => {
xo.addConfigManager('xenServers',
() => serversDb.get(),
servers => serversDb.update(servers)
)
// Connects to existing servers.
const servers = await this._servers.get()
const servers = await serversDb.get()
for (let server of servers) {
if (server.enabled) {
this.connectXenServer(server.id).catch(error => {
@@ -79,7 +74,7 @@ export default class {
this.disconnectXenServer(id)::pCatch(noop)
if (!await this._servers.remove(id)) {
throw new NoSuchXenServer(id)
throw noSuchObject(id, 'xenServer')
}
}
@@ -110,7 +105,7 @@ export default class {
async _getXenServer (id) {
const server = await this._servers.first(id)
if (!server) {
throw new NoSuchXenServer(id)
throw noSuchObject(id, 'xenServer')
}
return server
@@ -283,23 +278,13 @@ export default class {
xapi.xo.install()
try {
await xapi.connect()
} catch (error) {
if (error.code === 'SESSION_AUTHENTICATION_FAILED') {
throw new GenericError('authentication failed')
}
if (error.code === 'EHOSTUNREACH') {
throw new GenericError('host unreachable')
}
throw error
}
await xapi.connect()
}
async disconnectXenServer (id) {
const xapi = this._xapis[id]
if (!xapi) {
throw new NoSuchXenServer(id)
throw noSuchObject(id, 'xenServer')
}
delete this._xapis[id]

View File

@@ -3,6 +3,7 @@ import XoCollection from 'xo-collection'
import XoUniqueIndex from 'xo-collection/unique-index'
import {createClient as createRedisClient} from 'redis'
import {EventEmitter} from 'events'
import { noSuchObject } from 'xo-common/api-errors'
import mixins from './xo-mixins'
import Connection from './connection'
@@ -20,9 +21,6 @@ import {
mapToArray,
noop
} from './utils'
import {
NoSuchObject
} from './api-errors'
// ===================================================================
@@ -43,7 +41,29 @@ export default class Xo extends EventEmitter {
this._httpRequestWatchers = createRawObject()
// Connects to Redis.
this._redis = createRedisClient(config.redis && config.redis.uri)
const redisConf = config.redis
this._redis = createRedisClient(redisConf && {
rename_commands: redisConf.renameCommands,
url: redisConf.uri
})
}
// -----------------------------------------------------------------
async clean () {
const handleCleanError = error => {
console.error(
'[WARN] clean error:',
error && error.stack || error
)
}
await Promise.all(mapToArray(
this.listeners('clean'),
listener => new Promise(resolve => {
resolve(listener.call(this))
}).catch(handleCleanError)
))
}
// -----------------------------------------------------------------
@@ -122,14 +142,14 @@ export default class Xo extends EventEmitter {
const obj = all[key] || byRef[key]
if (!obj) {
throw new NoSuchObject(key, type)
throw noSuchObject(key, type)
}
if (type != null && (
isString(type) && type !== obj.type ||
!includes(type, obj.type) // Array
)) {
throw new NoSuchObject(key, type)
throw noSuchObject(key, type)
}
return obj
@@ -330,7 +350,7 @@ export default class Xo extends EventEmitter {
forEach(connections, connection => {
// Notifies only authenticated clients.
if (connection.has('user_id')) {
if (connection.has('user_id') && connection.notify) {
if (enteredMessage) {
connection.notify('all', enteredMessage)
}

View File

@@ -1,84 +0,0 @@
#!/usr/bin/env node
var join = require('path').join
var readdir = require('fs').readdirSync
var stat = require('fs').statSync
var writeFile = require('fs').writeFileSync
// ===================================================================
function bind (fn, thisArg) {
return function () {
return fn.apply(thisArg, arguments)
}
}
function camelCase (str) {
return str.toLowerCase().replace(/[^a-z0-9]+([a-z0-9])/g, function (_, str) {
return str.toUpperCase()
})
}
function removeSuffix (str, sfx) {
var strLength = str.length
var sfxLength = sfx.length
var pos = strLength - sfxLength
if (pos < 0 || str.indexOf(sfx, pos) !== pos) {
return false
}
return str.slice(0, pos)
}
// ===================================================================
function handleEntry (entry, dir) {
var stats = stat(join(dir, entry))
var base
if (stats.isDirectory()) {
base = entry
} else if (!(
stats.isFile() && (
(base = removeSuffix(entry, '.coffee')) ||
(base = removeSuffix(entry, '.js'))
)
)) {
return
}
var identifier = camelCase(base)
this(
'import ' + identifier + " from './" + base + "'",
'defaults.' + identifier + ' = ' + identifier,
'export * as ' + identifier + " from './" + base + "'",
''
)
}
function generateIndex (dir) {
var content = [
'//',
'// This file has been generated by /tools/generate-index',
'//',
'// It is automatically re-generated each time a build is started.',
'//',
'',
'const defaults = {}',
'export default defaults',
''
]
var write = bind(content.push, content)
readdir(dir).map(function (entry) {
if (entry === 'index.js') {
return
}
handleEntry.call(write, entry, dir)
})
writeFile(dir + '/index.js', content.join('\n'))
}
process.argv.slice(2).map(generateIndex)