Compare commits

..

8 Commits

Author SHA1 Message Date
ce5ee11841 Bump version to 0.9.8 2020-11-24 12:26:57 -05:00
8f705c9cc2 Add cluster backup + restore functionality
Adds cluster backup (JSON dump) and restore functions for use in
disaster recovery.

Further, adds additional confirmation to the initialization (as well as
restore) endpoints to avoid accidental triggering, and also groups the
init, backup, and restore commands in the CLI into a new "task"
subsection.
2020-11-24 02:39:06 -05:00
3f2c7293d1 Fix inconsistent name helpmsg
In the RequestParser this is called helptext, not helpmsg; make all of
the entries consistent and return the issue as a message.
2020-11-24 02:37:28 -05:00
d4a28d7a58 Bump version to 0.9.7 2020-11-19 10:48:28 -05:00
e8914eabb7 Better handle modifying consoles in templates
Before, the default False was problematic and would reset consoles if
the template was otherwise modified. Instead switch the flags to be full
true/false flags, and on modify, adjust the default to be None so they
will not be changed.
2020-11-19 10:28:00 -05:00
e69eb93cb3 Bump version to 0.9.6 2020-11-17 13:01:54 -05:00
70dfcd434f Ensure inmigrate is cleared on failure 2020-11-17 12:57:37 -05:00
0383f31086 Fix linting error 2020-11-17 12:37:33 -05:00
9 changed files with 387 additions and 78 deletions

View File

@ -20,6 +20,20 @@ To get started with PVC, please see the [About](https://parallelvirtualcluster.r
## Changelog
#### v0.9.8
* Adds support for cluster backup/restore
* Moves location of `init` command in CLI to make room for the above
* Cleans up some invalid help messages from the API
#### v0.9.7
* Fixes bug with provisioner system template modifications
#### v0.9.6
* Fixes bug with migrations
#### v0.9.5
* Fixes bug with line count in log follow

View File

@ -333,14 +333,23 @@ api.add_resource(API_Logout, '/logout')
# /initialize
class API_Initialize(Resource):
@RequestParser([
{'name': 'yes-i-really-mean-it', 'required': True, 'helptext': "Initialization is destructive; please confirm with the argument 'yes-i-really-mean-it'."}
])
@Authenticator
def post(self):
def post(self, reqargs):
"""
Initialize a new PVC cluster
Note: Normally used only once during cluster bootstrap; checks for the existence of the "/primary_node" key before proceeding and returns 400 if found
---
tags:
- root
parameters:
- in: query
name: yes-i-really-mean-it
type: string
required: true
description: A confirmation string to ensure that the API consumer really means it
responses:
200:
description: OK
@ -363,6 +372,82 @@ class API_Initialize(Resource):
api.add_resource(API_Initialize, '/initialize')
# /backup
class API_Backup(Resource):
@Authenticator
def get(self):
"""
Back up the Zookeeper data of a cluster in JSON format
---
tags:
- root
responses:
200:
description: OK
schema:
type: object
id: Cluster Data
400:
description: Bad request
"""
return api_helper.backup_cluster()
api.add_resource(API_Backup, '/backup')
# /restore
class API_Restore(Resource):
@RequestParser([
{'name': 'yes-i-really-mean-it', 'required': True, 'helptext': "Restore is destructive; please confirm with the argument 'yes-i-really-mean-it'."},
{'name': 'cluster_data', 'required': True, 'helptext': "A cluster JSON backup must be provided."}
])
@Authenticator
def post(self, reqargs):
"""
Restore a backup over the cluster; destroys the existing data
---
tags:
- root
parameters:
- in: query
name: yes-i-really-mean-it
type: string
required: true
description: A confirmation string to ensure that the API consumer really means it
- in: query
name: cluster_data
type: string
required: true
description: The raw JSON cluster backup data
responses:
200:
description: OK
schema:
type: object
id: Message
400:
description: Bad request
schema:
type: object
id: Message
500:
description: Restore error or code failure
schema:
type: object
id: Message
"""
try:
cluster_data = reqargs.get('cluster_data')
except Exception as e:
return {"message": "Failed to load JSON backup: {}.".format(e)}, 400
return api_helper.restore_cluster(cluster_data)
api.add_resource(API_Restore, '/restore')
# /status
class API_Status(Resource):
@Authenticator
@ -443,7 +528,7 @@ class API_Status(Resource):
return api_helper.cluster_status()
@RequestParser([
{'name': 'state', 'choices': ('true', 'false'), 'required': True, 'helpmsg': "A valid state must be specified."}
{'name': 'state', 'choices': ('true', 'false'), 'required': True, 'helptext': "A valid state must be specified."}
])
@Authenticator
def post(self, reqargs):
@ -2412,7 +2497,7 @@ api.add_resource(API_Network_Lease_Element, '/network/<vni>/lease/<mac>')
class API_Network_ACL_Root(Resource):
@RequestParser([
{'name': 'limit'},
{'name': 'direction', 'choices': ('in', 'out'), 'helpmsg': "A valid direction must be specified."}
{'name': 'direction', 'choices': ('in', 'out'), 'helptext': "A valid direction must be specified."}
])
@Authenticator
def get(self, vni, reqargs):
@ -2474,9 +2559,9 @@ class API_Network_ACL_Root(Resource):
)
@RequestParser([
{'name': 'description', 'required': True, 'helpmsg': "A whitespace-free description must be specified."},
{'name': 'rule', 'required': True, 'helpmsg': "A rule must be specified."},
{'name': 'direction', 'choices': ('in', 'out'), 'helpmsg': "A valid direction must be specified."},
{'name': 'description', 'required': True, 'helptext': "A whitespace-free description must be specified."},
{'name': 'rule', 'required': True, 'helptext': "A rule must be specified."},
{'name': 'direction', 'choices': ('in', 'out'), 'helptext': "A valid direction must be specified."},
{'name': 'order'}
])
@Authenticator
@ -2566,8 +2651,8 @@ class API_Network_ACL_Element(Resource):
)
@RequestParser([
{'name': 'rule', 'required': True, 'helpmsg': "A rule must be specified."},
{'name': 'direction', 'choices': ('in', 'out'), 'helpmsg': "A valid direction must be specified."},
{'name': 'rule', 'required': True, 'helptext': "A rule must be specified."},
{'name': 'direction', 'choices': ('in', 'out'), 'helptext': "A valid direction must be specified."},
{'name': 'order'}
])
@Authenticator
@ -2858,7 +2943,7 @@ class API_Storage_Ceph_Benchmark(Resource):
return api_benchmark.list_benchmarks(reqargs.get('job', None))
@RequestParser([
{'name': 'pool', 'required': True, 'helpmsg': "A valid pool must be specified."},
{'name': 'pool', 'required': True, 'helptext': "A valid pool must be specified."},
])
@Authenticator
def post(self, reqargs):
@ -2897,8 +2982,8 @@ api.add_resource(API_Storage_Ceph_Benchmark, '/storage/ceph/benchmark')
# /storage/ceph/option
class API_Storage_Ceph_Option(Resource):
@RequestParser([
{'name': 'option', 'required': True, 'helpmsg': "A valid option must be specified."},
{'name': 'action', 'required': True, 'choices': ('set', 'unset'), 'helpmsg': "A valid action must be specified."},
{'name': 'option', 'required': True, 'helptext': "A valid option must be specified."},
{'name': 'action', 'required': True, 'choices': ('set', 'unset'), 'helptext': "A valid action must be specified."},
])
@Authenticator
def post(self, reqargs):
@ -3039,9 +3124,9 @@ class API_Storage_Ceph_OSD_Root(Resource):
)
@RequestParser([
{'name': 'node', 'required': True, 'helpmsg': "A valid node must be specified."},
{'name': 'device', 'required': True, 'helpmsg': "A valid device must be specified."},
{'name': 'weight', 'required': True, 'helpmsg': "An OSD weight must be specified."},
{'name': 'node', 'required': True, 'helptext': "A valid node must be specified."},
{'name': 'device', 'required': True, 'helptext': "A valid device must be specified."},
{'name': 'weight', 'required': True, 'helptext': "An OSD weight must be specified."},
])
@Authenticator
def post(self, reqargs):
@ -3109,7 +3194,7 @@ class API_Storage_Ceph_OSD_Element(Resource):
)
@RequestParser([
{'name': 'yes-i-really-mean-it', 'required': True, 'helpmsg': "Please confirm that 'yes-i-really-mean-it'."}
{'name': 'yes-i-really-mean-it', 'required': True, 'helptext': "Please confirm that 'yes-i-really-mean-it'."}
])
@Authenticator
def delete(self, osdid, reqargs):
@ -3175,7 +3260,7 @@ class API_Storage_Ceph_OSD_State(Resource):
)
@RequestParser([
{'name': 'state', 'choices': ('in', 'out'), 'required': True, 'helpmsg': "A valid state must be specified."},
{'name': 'state', 'choices': ('in', 'out'), 'required': True, 'helptext': "A valid state must be specified."},
])
@Authenticator
def post(self, osdid, reqargs):
@ -3295,9 +3380,9 @@ class API_Storage_Ceph_Pool_Root(Resource):
)
@RequestParser([
{'name': 'pool', 'required': True, 'helpmsg': "A pool name must be specified."},
{'name': 'pgs', 'required': True, 'helpmsg': "A placement group count must be specified."},
{'name': 'replcfg', 'required': True, 'helpmsg': "A valid replication configuration must be specified."}
{'name': 'pool', 'required': True, 'helptext': "A pool name must be specified."},
{'name': 'pgs', 'required': True, 'helptext': "A placement group count must be specified."},
{'name': 'replcfg', 'required': True, 'helptext': "A valid replication configuration must be specified."}
])
@Authenticator
def post(self, reqargs):
@ -3370,8 +3455,8 @@ class API_Storage_Ceph_Pool_Element(Resource):
)
@RequestParser([
{'name': 'pgs', 'required': True, 'helpmsg': "A placement group count must be specified."},
{'name': 'replcfg', 'required': True, 'helpmsg': "A valid replication configuration must be specified."}
{'name': 'pgs', 'required': True, 'helptext': "A placement group count must be specified."},
{'name': 'replcfg', 'required': True, 'helptext': "A valid replication configuration must be specified."}
])
@Authenticator
def post(self, pool, reqargs):
@ -3415,7 +3500,7 @@ class API_Storage_Ceph_Pool_Element(Resource):
)
@RequestParser([
{'name': 'yes-i-really-mean-it', 'required': True, 'helpmsg': "Please confirm that 'yes-i-really-mean-it'."}
{'name': 'yes-i-really-mean-it', 'required': True, 'helptext': "Please confirm that 'yes-i-really-mean-it'."}
])
@Authenticator
def delete(self, pool, reqargs):
@ -3559,9 +3644,9 @@ class API_Storage_Ceph_Volume_Root(Resource):
)
@RequestParser([
{'name': 'volume', 'required': True, 'helpmsg': "A volume name must be specified."},
{'name': 'pool', 'required': True, 'helpmsg': "A valid pool name must be specified."},
{'name': 'size', 'required': True, 'helpmsg': "A volume size in bytes (or with k/M/G/T suffix) must be specified."}
{'name': 'volume', 'required': True, 'helptext': "A volume name must be specified."},
{'name': 'pool', 'required': True, 'helptext': "A valid pool name must be specified."},
{'name': 'size', 'required': True, 'helptext': "A volume size in bytes (or with k/M/G/T suffix) must be specified."}
])
@Authenticator
def post(self, reqargs):
@ -3635,7 +3720,7 @@ class API_Storage_Ceph_Volume_Element(Resource):
)
@RequestParser([
{'name': 'size', 'required': True, 'helpmsg': "A volume size in bytes (or with k/M/G/T suffix) must be specified."}
{'name': 'size', 'required': True, 'helptext': "A volume size in bytes (or with k/M/G/T suffix) must be specified."}
])
@Authenticator
def post(self, pool, volume, reqargs):
@ -3761,7 +3846,7 @@ api.add_resource(API_Storage_Ceph_Volume_Element, '/storage/ceph/volume/<pool>/<
# /storage/ceph/volume/<pool>/<volume>/clone
class API_Storage_Ceph_Volume_Element_Clone(Resource):
@RequestParser([
{'name': 'new_volume', 'required': True, 'helpmsg': "A new volume name must be specified."}
{'name': 'new_volume', 'required': True, 'helptext': "A new volume name must be specified."}
])
@Authenticator
def post(self, pool, volume, reqargs):
@ -3806,7 +3891,7 @@ api.add_resource(API_Storage_Ceph_Volume_Element_Clone, '/storage/ceph/volume/<p
# /storage/ceph/volume/<pool>/<volume>/upload
class API_Storage_Ceph_Volume_Element_Upload(Resource):
@RequestParser([
{'name': 'image_format', 'required': True, 'location': ['args'], 'helpmsg': "A source image format must be specified."}
{'name': 'image_format', 'required': True, 'location': ['args'], 'helptext': "A source image format must be specified."}
])
@Authenticator
def post(self, pool, volume, reqargs):
@ -3916,9 +4001,9 @@ class API_Storage_Ceph_Snapshot_Root(Resource):
)
@RequestParser([
{'name': 'snapshot', 'required': True, 'helpmsg': "A snapshot name must be specified."},
{'name': 'volume', 'required': True, 'helpmsg': "A volume name must be specified."},
{'name': 'pool', 'required': True, 'helpmsg': "A pool name must be specified."}
{'name': 'snapshot', 'required': True, 'helptext': "A snapshot name must be specified."},
{'name': 'volume', 'required': True, 'helptext': "A volume name must be specified."},
{'name': 'pool', 'required': True, 'helptext': "A pool name must be specified."}
])
@Authenticator
def post(self, reqargs):
@ -4039,7 +4124,7 @@ class API_Storage_Ceph_Snapshot_Element(Resource):
)
@RequestParser([
{'name': 'new_name', 'required': True, 'helpmsg': "A new name must be specified."}
{'name': 'new_name', 'required': True, 'helptext': "A new name must be specified."}
])
@Authenticator
def put(self, pool, volume, snapshot, reqargs):
@ -4243,11 +4328,11 @@ class API_Provisioner_Template_System_Root(Resource):
)
@RequestParser([
{'name': 'name', 'required': True, 'helpmsg': "A name must be specified."},
{'name': 'vcpus', 'required': True, 'helpmsg': "A vcpus value must be specified."},
{'name': 'vram', 'required': True, 'helpmsg': "A vram value in MB must be specified."},
{'name': 'serial', 'required': True, 'helpmsg': "A serial value must be specified."},
{'name': 'vnc', 'required': True, 'helpmsg': "A vnc value must be specified."},
{'name': 'name', 'required': True, 'helptext': "A name must be specified."},
{'name': 'vcpus', 'required': True, 'helptext': "A vcpus value must be specified."},
{'name': 'vram', 'required': True, 'helptext': "A vram value in MB must be specified."},
{'name': 'serial', 'required': True, 'helptext': "A serial value must be specified."},
{'name': 'vnc', 'required': True, 'helptext': "A vnc value must be specified."},
{'name': 'vnc_bind'},
{'name': 'node_limit'},
{'name': 'node_selector'},
@ -4392,10 +4477,10 @@ class API_Provisioner_Template_System_Element(Resource):
)
@RequestParser([
{'name': 'vcpus', 'required': True, 'helpmsg': "A vcpus value must be specified."},
{'name': 'vram', 'required': True, 'helpmsg': "A vram value in MB must be specified."},
{'name': 'serial', 'required': True, 'helpmsg': "A serial value must be specified."},
{'name': 'vnc', 'required': True, 'helpmsg': "A vnc value must be specified."},
{'name': 'vcpus', 'required': True, 'helptext': "A vcpus value must be specified."},
{'name': 'vram', 'required': True, 'helptext': "A vram value in MB must be specified."},
{'name': 'serial', 'required': True, 'helptext': "A serial value must be specified."},
{'name': 'vnc', 'required': True, 'helptext': "A vnc value must be specified."},
{'name': 'vnc_bind'},
{'name': 'node_limit'},
{'name': 'node_selector'},
@ -4674,7 +4759,7 @@ class API_Provisioner_Template_Network_Root(Resource):
)
@RequestParser([
{'name': 'name', 'required': True, 'helpmsg': "A template name must be specified."},
{'name': 'name', 'required': True, 'helptext': "A template name must be specified."},
{'name': 'mac_template'}
])
@Authenticator
@ -4833,7 +4918,7 @@ class API_Provisioner_Template_Network_Net_Root(Resource):
return {'message': 'Template not found.'}, 404
@RequestParser([
{'name': 'vni', 'required': True, 'helpmsg': "A valid VNI must be specified."}
{'name': 'vni', 'required': True, 'helptext': "A valid VNI must be specified."}
])
@Authenticator
def post(self, template, reqargs):
@ -5026,7 +5111,7 @@ class API_Provisioner_Template_Storage_Root(Resource):
)
@RequestParser([
{'name': 'name', 'required': True, 'helpmsg': "A template name must be specified."}
{'name': 'name', 'required': True, 'helptext': "A template name must be specified."}
])
@Authenticator
def post(self, reqargs):
@ -5171,8 +5256,8 @@ class API_Provisioner_Template_Storage_Disk_Root(Resource):
return {'message': 'Template not found.'}, 404
@RequestParser([
{'name': 'disk_id', 'required': True, 'helpmsg': "A disk identifier in sdX or vdX format must be specified."},
{'name': 'pool', 'required': True, 'helpmsg': "A storage pool must be specified."},
{'name': 'disk_id', 'required': True, 'helptext': "A disk identifier in sdX or vdX format must be specified."},
{'name': 'pool', 'required': True, 'helptext': "A storage pool must be specified."},
{'name': 'source_volume'},
{'name': 'disk_size'},
{'name': 'filesystem'},
@ -5279,7 +5364,7 @@ class API_Provisioner_Template_Storage_Disk_Element(Resource):
abort(404)
@RequestParser([
{'name': 'pool', 'required': True, 'helpmsg': "A storage pool must be specified."},
{'name': 'pool', 'required': True, 'helptext': "A storage pool must be specified."},
{'name': 'source_volume'},
{'name': 'disk_size'},
{'name': 'filesystem'},
@ -5421,8 +5506,8 @@ class API_Provisioner_Userdata_Root(Resource):
)
@RequestParser([
{'name': 'name', 'required': True, 'helpmsg': "A name must be specified."},
{'name': 'data', 'required': True, 'helpmsg': "A userdata document must be specified."}
{'name': 'name', 'required': True, 'helptext': "A name must be specified."},
{'name': 'data', 'required': True, 'helptext': "A userdata document must be specified."}
])
@Authenticator
def post(self, reqargs):
@ -5489,7 +5574,7 @@ class API_Provisioner_Userdata_Element(Resource):
)
@RequestParser([
{'name': 'data', 'required': True, 'helpmsg': "A userdata document must be specified."}
{'name': 'data', 'required': True, 'helptext': "A userdata document must be specified."}
])
@Authenticator
def post(self, userdata, reqargs):
@ -5522,7 +5607,7 @@ class API_Provisioner_Userdata_Element(Resource):
)
@RequestParser([
{'name': 'data', 'required': True, 'helpmsg': "A userdata document must be specified."}
{'name': 'data', 'required': True, 'helptext': "A userdata document must be specified."}
])
@Authenticator
def put(self, userdata, reqargs):
@ -5626,8 +5711,8 @@ class API_Provisioner_Script_Root(Resource):
)
@RequestParser([
{'name': 'name', 'required': True, 'helpmsg': "A script name must be specified."},
{'name': 'data', 'required': True, 'helpmsg': "A script document must be specified."}
{'name': 'name', 'required': True, 'helptext': "A script name must be specified."},
{'name': 'data', 'required': True, 'helptext': "A script document must be specified."}
])
@Authenticator
def post(self, reqargs):
@ -5694,7 +5779,7 @@ class API_Provisioner_Script_Element(Resource):
)
@RequestParser([
{'name': 'data', 'required': True, 'helpmsg': "A script document must be specified."}
{'name': 'data', 'required': True, 'helptext': "A script document must be specified."}
])
@Authenticator
def post(self, script, reqargs):
@ -5727,7 +5812,7 @@ class API_Provisioner_Script_Element(Resource):
)
@RequestParser([
{'name': 'data', 'required': True, 'helpmsg': "A script document must be specified."}
{'name': 'data', 'required': True, 'helptext': "A script document must be specified."}
])
@Authenticator
def put(self, script, reqargs):
@ -5849,9 +5934,9 @@ class API_Provisioner_OVA_Root(Resource):
)
@RequestParser([
{'name': 'pool', 'required': True, 'location': ['args'], 'helpmsg': "A storage pool must be specified."},
{'name': 'name', 'required': True, 'location': ['args'], 'helpmsg': "A VM name must be specified."},
{'name': 'ova_size', 'required': True, 'location': ['args'], 'helpmsg': "An OVA size must be specified."},
{'name': 'pool', 'required': True, 'location': ['args'], 'helptext': "A storage pool must be specified."},
{'name': 'name', 'required': True, 'location': ['args'], 'helptext': "A VM name must be specified."},
{'name': 'ova_size', 'required': True, 'location': ['args'], 'helptext': "An OVA size must be specified."},
])
@Authenticator
def post(self, reqargs):
@ -5926,8 +6011,8 @@ class API_Provisioner_OVA_Element(Resource):
)
@RequestParser([
{'name': 'pool', 'required': True, 'location': ['args'], 'helpmsg': "A storage pool must be specified."},
{'name': 'ova_size', 'required': True, 'location': ['args'], 'helpmsg': "An OVA size must be specified."},
{'name': 'pool', 'required': True, 'location': ['args'], 'helptext': "A storage pool must be specified."},
{'name': 'ova_size', 'required': True, 'location': ['args'], 'helptext': "An OVA size must be specified."},
])
@Authenticator
def post(self, ova, reqargs):
@ -6056,8 +6141,8 @@ class API_Provisioner_Profile_Root(Resource):
)
@RequestParser([
{'name': 'name', 'required': True, 'helpmsg': "A profile name must be specified."},
{'name': 'profile_type', 'required': True, 'helpmsg': "A profile type must be specified."},
{'name': 'name', 'required': True, 'helptext': "A profile name must be specified."},
{'name': 'profile_type', 'required': True, 'helptext': "A profile type must be specified."},
{'name': 'system_template'},
{'name': 'network_template'},
{'name': 'storage_template'},
@ -6175,7 +6260,7 @@ class API_Provisioner_Profile_Element(Resource):
)
@RequestParser([
{'name': 'profile_type', 'required': True, 'helpmsg': "A profile type must be specified."},
{'name': 'profile_type', 'required': True, 'helptext': "A profile type must be specified."},
{'name': 'system_template'},
{'name': 'network_template'},
{'name': 'storage_template'},
@ -6357,8 +6442,8 @@ api.add_resource(API_Provisioner_Profile_Element, '/provisioner/profile/<profile
# /provisioner/create
class API_Provisioner_Create_Root(Resource):
@RequestParser([
{'name': 'name', 'required': True, 'helpmsg': "A VM name must be specified."},
{'name': 'profile', 'required': True, 'helpmsg': "A profile name must be specified."},
{'name': 'name', 'required': True, 'helptext': "A VM name must be specified."},
{'name': 'profile', 'required': True, 'helptext': "A profile name must be specified."},
{'name': 'define_vm'},
{'name': 'start_vm'},
{'name': 'arg', 'action': 'append'}

View File

@ -21,6 +21,7 @@
###############################################################################
import flask
import json
import lxml.etree as etree
from distutils.util import strtobool as dustrtobool
@ -49,7 +50,7 @@ def strtobool(stringv):
#
# Initialization function
# Cluster base functions
#
def initialize_cluster():
# Open a Zookeeper connection
@ -86,6 +87,66 @@ def initialize_cluster():
return True
def backup_cluster():
# Open a zookeeper connection
zk_conn = pvc_common.startZKConnection(config['coordinators'])
# Dictionary of values to come
cluster_data = dict()
def get_data(path):
data_raw = zk_conn.get(path)
if data_raw:
data = data_raw[0].decode('utf8')
children = zk_conn.get_children(path)
cluster_data[path] = data
if children:
if path == '/':
child_prefix = '/'
else:
child_prefix = path + '/'
for child in children:
if child_prefix + child == '/zookeeper':
# We must skip the built-in /zookeeper tree
continue
get_data(child_prefix + child)
get_data('/')
return cluster_data, 200
def restore_cluster(cluster_data_raw):
# Open a zookeeper connection
zk_conn = pvc_common.startZKConnection(config['coordinators'])
# Open a single transaction (restore is atomic)
zk_transaction = zk_conn.transaction()
try:
cluster_data = json.loads(cluster_data_raw)
except Exception as e:
return {"message": "Failed to parse JSON data: {}.".format(e)}, 400
for key in cluster_data:
data = cluster_data[key]
if zk_conn.exists(key):
zk_transaction.set_data(key, str(data).encode('utf8'))
else:
zk_transaction.create(key, str(data).encode('utf8'))
try:
zk_transaction.commit()
return {'message': 'Restore completed successfully.'}, 200
except Exception as e:
raise
return {'message': 'Restore failed: {}.'.format(e)}, 500
#
# Cluster functions
#

View File

@ -31,10 +31,55 @@ def initialize(config):
Initialize the PVC cluster
API endpoint: GET /api/v1/initialize
API arguments: yes-i-really-mean-it
API schema: {json_data_object}
"""
params = {
'yes-i-really-mean-it': 'yes'
}
response = call_api(config, 'post', '/initialize', params=params)
if response.status_code == 200:
retstatus = True
else:
retstatus = False
return retstatus, response.json().get('message', '')
def backup(config):
"""
Get a JSON backup of the cluster
API endpoint: GET /api/v1/backup
API arguments:
API schema: {json_data_object}
"""
response = call_api(config, 'post', '/initialize')
response = call_api(config, 'get', '/backup')
if response.status_code == 200:
return True, response.json()
else:
return False, response.json().get('message', '')
def restore(config, cluster_data):
"""
Restore a JSON backup to the cluster
API endpoint: POST /api/v1/restore
API arguments: yes-i-really-mean-it
API schema: {json_data_object}
"""
cluster_data_json = json.dumps(cluster_data)
params = {
'yes-i-really-mean-it': 'yes'
}
data = {
'cluster_data': cluster_data_json
}
response = call_api(config, 'post', '/restore', params=params, data=data)
if response.status_code == 200:
retstatus = True
@ -115,7 +160,7 @@ def format_info(cluster_information, oformat):
if cluster_information['storage_health_msg']:
for line in cluster_information['storage_health_msg']:
ainformation.append(' > {}'.format(line))
return '\n'.join(ainformation)
ainformation.append('{}PVC cluster status:{}'.format(ansiprint.bold(), ansiprint.end()))

View File

@ -2721,14 +2721,14 @@ def provisioner_template_system_list(limit):
help='The amount of vRAM (in MB).'
)
@click.option(
'-s', '--serial', 'serial',
'-s/-S', '--serial/--no-serial', 'serial',
is_flag=True, default=False,
help='Enable the virtual serial console.'
)
@click.option(
'-n', '--vnc', 'vnc',
'-n/-N', '--vnc/--no-vnc', 'vnc',
is_flag=True, default=False,
help='Enable the VNC console.'
help='Enable/disable the VNC console.'
)
@click.option(
'-b', '--vnc-bind', 'vnc_bind',
@ -2801,14 +2801,14 @@ def provisioner_template_system_add(name, vcpus, vram, serial, vnc, vnc_bind, no
help='The amount of vRAM (in MB).'
)
@click.option(
'-s', '--serial', 'serial',
'-s/-S', '--serial/--no-serial', 'serial',
is_flag=True, default=None,
help='Enable the virtual serial console.'
)
@click.option(
'-n', '--vnc', 'vnc',
'-n/-N', '--vnc/--no-vnc', 'vnc',
is_flag=True, default=None,
help='Enable the VNC console.'
help='Enable/disable the VNC console.'
)
@click.option(
'-b', '--vnc-bind', 'vnc_bind',
@ -4078,6 +4078,71 @@ def status_cluster(oformat):
cleanup(retcode, retdata)
###############################################################################
# pvc task
###############################################################################
@click.group(name='task', short_help='Perform PVC cluster tasks.', context_settings=CONTEXT_SETTINGS)
def cli_task():
"""
Perform administrative tasks against the PVC cluster.
"""
pass
###############################################################################
# pvc task backup
###############################################################################
@click.command(name='backup', short_help='Create JSON backup of cluster.')
@click.option(
'-f', '--file', 'filename',
default=None, type=click.File(),
help='Write backup data to this file.'
)
@cluster_req
def task_backup(filename):
"""
Create a JSON-format backup of the cluster Zookeeper database.
"""
retcode, retdata = pvc_cluster.backup(config)
if filename:
with open(filename, 'wb') as fh:
fh.write(retdata)
retdata = 'Data written to {}'.format(filename)
cleanup(retcode, retdata)
###############################################################################
# pvc task restore
###############################################################################
@click.command(name='restore', short_help='Restore JSON backup to cluster.')
@click.option(
'-f', '--file', 'filename',
required=True, default=None, type=click.File(),
help='Read backup data from this file.'
)
@click.option(
'-y', '--yes', 'confirm_flag',
is_flag=True, default=False,
help='Confirm the restore'
)
@cluster_req
def task_restore(filename, confirm_flag):
"""
Restore the JSON backup data from a file to the cluster.
"""
if not confirm_flag:
try:
click.confirm('Replace all existing cluster data from coordinators with backup file "{}"'.format(filename.name), prompt_suffix='? ', abort=True)
except Exception:
exit(0)
cluster_data = json.loads(filename.read())
retcode, retmsg = pvc_cluster.restore(config, cluster_data)
cleanup(retcode, retmsg)
###############################################################################
# pvc init
###############################################################################
@ -4085,10 +4150,10 @@ def status_cluster(oformat):
@click.option(
'-y', '--yes', 'confirm_flag',
is_flag=True, default=False,
help='Confirm the removal'
help='Confirm the initialization'
)
@cluster_req
def init_cluster(confirm_flag):
def task_init(confirm_flag):
"""
Perform initialization of a new PVC cluster.
"""
@ -4323,6 +4388,10 @@ cli_provisioner.add_command(provisioner_status)
cli_maintenance.add_command(maintenance_on)
cli_maintenance.add_command(maintenance_off)
cli_task.add_command(task_backup)
cli_task.add_command(task_restore)
cli_task.add_command(task_init)
cli.add_command(cli_cluster)
cli.add_command(cli_node)
cli.add_command(cli_vm)
@ -4330,8 +4399,8 @@ cli.add_command(cli_network)
cli.add_command(cli_storage)
cli.add_command(cli_provisioner)
cli.add_command(cli_maintenance)
cli.add_command(cli_task)
cli.add_command(status_cluster)
cli.add_command(init_cluster)
#

20
debian/changelog vendored
View File

@ -1,3 +1,23 @@
pvc (0.9.8-0) unstable; urgency=high
* Adds support for cluster backup/restore
* Moves location of `init` command in CLI to make room for the above
* Cleans up some invalid help messages from the API
-- Joshua M. Boniface <joshua@boniface.me> Tue, 24 Nov 2020 12:26:57 -0500
pvc (0.9.7-0) unstable; urgency=high
* Fixes bug with provisioner system template modifications
-- Joshua M. Boniface <joshua@boniface.me> Thu, 19 Nov 2020 10:48:28 -0500
pvc (0.9.6-0) unstable; urgency=high
* Fixes bug with migrations
-- Joshua M. Boniface <joshua@boniface.me> Tue, 17 Nov 2020 13:01:54 -0500
pvc (0.9.5-0) unstable; urgency=high
* Fixes bug with line count in log follow

View File

@ -18,6 +18,20 @@ To get started with PVC, please see the [About](https://parallelvirtualcluster.r
## Changelog
#### v0.9.8
* Adds support for cluster backup/restore
* Moves location of `init` command in CLI to make room for the above
* Cleans up some invalid help messages from the API
#### v0.9.7
* Fixes bug with provisioner system template modifications
#### v0.9.6
* Fixes bug with migrations
#### v0.9.5
* Fixes bug with line count in log follow

View File

@ -54,7 +54,7 @@ import pvcnoded.CephInstance as CephInstance
import pvcnoded.MetadataAPIInstance as MetadataAPIInstance
# Version string for startup output
version = '0.9.5'
version = '0.9.8'
###############################################################################
# PVCD - node daemon startup program

View File

@ -381,6 +381,7 @@ class VMInstance(object):
})
migrate_lock_node.release()
migrate_lock_state.release()
self.inmigrate = False
self.logger.out('Aborted migration: {}'.format(reason), state='i', prefix='Domain {}'.format(self.domuuid))
# Acquire exclusive lock on the domain node key