diff --git a/client-api/api_lib/pvcapi_helper.py b/client-api/api_lib/pvcapi_helper.py index b6c08785..b86e8933 100755 --- a/client-api/api_lib/pvcapi_helper.py +++ b/client-api/api_lib/pvcapi_helper.py @@ -23,21 +23,57 @@ import flask import json +from distutils.util import strtobool + import client_lib.common as pvc_common import client_lib.node as pvc_node import client_lib.vm as pvc_vm import client_lib.network as pvc_network import client_lib.ceph as pvc_ceph +# +# Initialization function +# +def initialize_cluster(): + # Open a Zookeeper connection + zk_conn = pvc_common.startZKConnection(config['coordinators']) + + # Abort if we've initialized the cluster before + if zk_conn.exists('/primary_node'): + return False + + # Create the root keys + transaction = zk_conn.transaction() + transaction.create('/primary_node', 'none'.encode('ascii')) + transaction.create('/upstream_ip', 'none'.encode('ascii')) + transaction.create('/nodes', ''.encode('ascii')) + transaction.create('/domains', ''.encode('ascii')) + transaction.create('/networks', ''.encode('ascii')) + transaction.create('/ceph', ''.encode('ascii')) + transaction.create('/ceph/osds', ''.encode('ascii')) + transaction.create('/ceph/pools', ''.encode('ascii')) + transaction.create('/ceph/volumes', ''.encode('ascii')) + transaction.create('/ceph/snapshots', ''.encode('ascii')) + transaction.create('/cmd', ''.encode('ascii')) + transaction.create('/cmd/domains', ''.encode('ascii')) + transaction.create('/cmd/ceph', ''.encode('ascii')) + transaction.create('/locks', ''.encode('ascii')) + transaction.create('/locks/flush_lock', ''.encode('ascii')) + transaction.create('/locks/primary_node', ''.encode('ascii')) + transaction.commit() + + # Close the Zookeeper connection + pvc_common.stopZKConnection(zk_conn) + # # Node functions # -def node_list(limit=None): +def node_list(limit=None, is_fuzzy=True): """ Return a list of nodes with limit LIMIT. """ zk_conn = pvc_common.startZKConnection(config['coordinators']) - retflag, retdata = pvc_node.get_list(zk_conn, limit) + retflag, retdata = pvc_node.get_list(zk_conn, limit, is_fuzzy=is_fuzzy) if retflag: if retdata: retcode = 200 @@ -50,7 +86,12 @@ def node_list(limit=None): retcode = 400 pvc_common.stopZKConnection(zk_conn) - return flask.jsonify(retdata), retcode + + # If this is a single element, strip it out of the list + if isinstance(retdata, list) and len(retdata) == 1: + retdata = retdata[0] + + return retdata, retcode def node_daemon_state(node): """ @@ -74,7 +115,7 @@ def node_daemon_state(node): retcode = 400 pvc_common.stopZKConnection(zk_conn) - return flask.jsonify(retdata), retcode + return retdata, retcode def node_coordinator_state(node): """ @@ -98,7 +139,7 @@ def node_coordinator_state(node): retcode = 400 pvc_common.stopZKConnection(zk_conn) - return flask.jsonify(retdata), retcode + return retdata, retcode def node_domain_state(node): """ @@ -122,7 +163,7 @@ def node_domain_state(node): retcode = 400 pvc_common.stopZKConnection(zk_conn) - return flask.jsonify(retdata), retcode + return retdata, retcode def node_secondary(node): """ @@ -139,7 +180,7 @@ def node_secondary(node): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def node_primary(node): """ @@ -156,7 +197,7 @@ def node_primary(node): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def node_flush(node): """ @@ -173,7 +214,7 @@ def node_flush(node): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def node_ready(node): """ @@ -190,7 +231,7 @@ def node_ready(node): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode # # VM functions @@ -209,12 +250,17 @@ def vm_state(vm): """ zk_conn = pvc_common.startZKConnection(config['coordinators']) retflag, retdata = pvc_vm.get_list(zk_conn, None, None, vm, is_fuzzy=False) + + # If this is a single element, strip it out of the list + if isinstance(retdata, list) and len(retdata) == 1: + retdata = retdata[0] + if retflag: if retdata: retcode = 200 retdata = { 'name': vm, - 'state': retdata[0]['state'] + 'state': retdata['state'] } else: retcode = 404 @@ -225,7 +271,7 @@ def vm_state(vm): retcode = 400 pvc_common.stopZKConnection(zk_conn) - return flask.jsonify(retdata), retcode + return retdata, retcode def vm_node(vm): """ @@ -233,13 +279,18 @@ def vm_node(vm): """ zk_conn = pvc_common.startZKConnection(config['coordinators']) retflag, retdata = pvc_vm.get_list(zk_conn, None, None, vm, is_fuzzy=False) + + # If this is a single element, strip it out of the list + if isinstance(retdata, list) and len(retdata) == 1: + retdata = retdata[0] + if retflag: if retdata: retcode = 200 retdata = { 'name': vm, - 'node': retdata[0]['node'], - 'last_node': retdata[0]['last_node'] + 'node': retdata['node'], + 'last_node': retdata['last_node'] } else: retcode = 404 @@ -250,7 +301,7 @@ def vm_node(vm): retcode = 400 pvc_common.stopZKConnection(zk_conn) - return flask.jsonify(retdata), retcode + return retdata, retcode def vm_list(node=None, state=None, limit=None, is_fuzzy=True): """ @@ -258,6 +309,11 @@ def vm_list(node=None, state=None, limit=None, is_fuzzy=True): """ zk_conn = pvc_common.startZKConnection(config['coordinators']) retflag, retdata = pvc_vm.get_list(zk_conn, node, state, limit, is_fuzzy) + + # If this is a single element, strip it out of the list + if isinstance(retdata, list) and len(retdata) == 1: + retdata = retdata[0] + if retflag: if retdata: retcode = 200 @@ -270,7 +326,8 @@ def vm_list(node=None, state=None, limit=None, is_fuzzy=True): retcode = 400 pvc_common.stopZKConnection(zk_conn) - return flask.jsonify(retdata), retcode + + return retdata, retcode def vm_define(xml, node, limit, selector, autostart): """ @@ -287,14 +344,45 @@ def vm_define(xml, node, limit, selector, autostart): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode -def vm_meta(vm, limit, selector, autostart): +def get_vm_meta(vm): + """ + Get metadata of a VM. + """ + zk_conn = pvc_common.startZKConnection(config['coordinators']) + retflag, retdata = pvc_vm.get_list(zk_conn, None, None, vm, is_fuzzy=False) + + # If this is a single element, strip it out of the list + if isinstance(retdata, list) and len(retdata) == 1: + retdata = retdata[0] + + if retflag: + if retdata: + retcode = 200 + retdata = { + 'name': vm, + 'node_limit': retdata['node_limit'], + 'node_selector': retdata['node_selector'], + 'node_autostart': retdata['node_autostart'] + } + else: + retcode = 404 + retdata = { + 'message': 'VM not found.' + } + else: + retcode = 400 + + pvc_common.stopZKConnection(zk_conn) + return retdata, retcode + +def update_vm_meta(vm, limit, selector, autostart): """ Update metadata of a VM. """ zk_conn = pvc_common.startZKConnection(config['coordinators']) - retflag, retdata = pvc_vm.modify_vm_metadata(zk_conn, vm. limit, selector, autostart) + retflag, retdata = pvc_vm.modify_vm_metadata(zk_conn, vm. limit, selector, strtobool(autostart)) if retflag: retcode = 200 else: @@ -304,7 +392,7 @@ def vm_meta(vm, limit, selector, autostart): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def vm_modify(name, restart, xml): """ @@ -321,7 +409,7 @@ def vm_modify(name, restart, xml): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def vm_undefine(name): """ @@ -338,7 +426,7 @@ def vm_undefine(name): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def vm_remove(name): """ @@ -355,7 +443,7 @@ def vm_remove(name): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def vm_start(name): """ @@ -372,7 +460,7 @@ def vm_start(name): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def vm_restart(name): """ @@ -389,7 +477,7 @@ def vm_restart(name): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def vm_shutdown(name): """ @@ -406,7 +494,7 @@ def vm_shutdown(name): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def vm_stop(name): """ @@ -423,7 +511,7 @@ def vm_stop(name): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def vm_move(name, node): """ @@ -440,7 +528,7 @@ def vm_move(name, node): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def vm_migrate(name, node, flag_force): """ @@ -457,7 +545,7 @@ def vm_migrate(name, node, flag_force): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def vm_unmigrate(name): """ @@ -474,14 +562,23 @@ def vm_unmigrate(name): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode -def vm_flush_locks(name): +def vm_flush_locks(vm): """ Flush locks of a (stopped) VM. """ zk_conn = pvc_common.startZKConnection(config['coordinators']) - retflag, retdata = pvc_vm.flush_locks(zk_conn, name) + retflag, retdata = pvc_vm.get_list(zk_conn, None, None, vm, is_fuzzy=False) + + # If this is a single element, strip it out of the list + if isinstance(retdata, list) and len(retdata) == 1: + retdata = retdata[0] + + if retdata['state'] not in ['stop', 'disable']: + return {"message":"VM must be stopped to flush locks"}, 400 + + retflag, retdata = pvc_vm.flush_locks(zk_conn, vm) if retflag: retcode = 200 else: @@ -491,17 +588,22 @@ def vm_flush_locks(name): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode # # Network functions # -def net_list(limit=None): +def net_list(limit=None, is_fuzzy=True): """ Return a list of client networks with limit LIMIT. """ zk_conn = pvc_common.startZKConnection(config['coordinators']) - retflag, retdata = pvc_network.get_list(zk_conn, limit) + retflag, retdata = pvc_network.get_list(zk_conn, limit, is_fuzzy) + + # If this is a single element, strip it out of the list + if isinstance(retdata, list) and len(retdata) == 1: + retdata = retdata[0] + if retflag: if retdata: retcode = 200 @@ -514,7 +616,7 @@ def net_list(limit=None): retcode = 400 pvc_common.stopZKConnection(zk_conn) - return flask.jsonify(retdata), retcode + return retdata, retcode def net_add(vni, description, nettype, domain, name_servers, ip4_network, ip4_gateway, ip6_network, ip6_gateway, @@ -535,7 +637,7 @@ def net_add(vni, description, nettype, domain, name_servers, output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def net_modify(vni, description, domain, name_servers, ip4_network, ip4_gateway, @@ -557,7 +659,7 @@ def net_modify(vni, description, domain, name_servers, output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def net_remove(network): """ @@ -574,7 +676,7 @@ def net_remove(network): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def net_dhcp_list(network, limit=None, static=False): """ @@ -594,7 +696,7 @@ def net_dhcp_list(network, limit=None, static=False): retcode = 400 pvc_common.stopZKConnection(zk_conn) - return flask.jsonify(retdata), retcode + return retdata, retcode def net_dhcp_add(network, ipaddress, macaddress, hostname): """ @@ -611,7 +713,7 @@ def net_dhcp_add(network, ipaddress, macaddress, hostname): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def net_dhcp_remove(network, macaddress): """ @@ -628,14 +730,14 @@ def net_dhcp_remove(network, macaddress): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode -def net_acl_list(network, limit=None, direction=None): +def net_acl_list(network, limit=None, direction=None, is_fuzzy=True): """ Return a list of network ACLs in network NETWORK with limit LIMIT. """ zk_conn = pvc_common.startZKConnection(config['coordinators']) - retflag, retdata = pvc_network.get_list_acl(zk_conn, network, limit, direction) + retflag, retdata = pvc_network.get_list_acl(zk_conn, network, limit, direction, is_fuzzy=True) if retflag: if retdata: retcode = 200 @@ -647,11 +749,12 @@ def net_acl_list(network, limit=None, direction=None): else: retcode = 400 + # If this is a single element, strip it out of the list + if isinstance(retdata, list) and len(retdata) == 1: + retdata = retdata[0] + pvc_common.stopZKConnection(zk_conn) - output = { - 'message': retdata.replace('\"', '\'') - } - return flask.jsonify(output), retcode + return retdata, retcode def net_acl_add(network, direction, description, rule, order): """ @@ -668,14 +771,14 @@ def net_acl_add(network, direction, description, rule, order): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode -def net_acl_remove(network, direction, description): +def net_acl_remove(network, description): """ Remove an ACL from a virtual client network. """ zk_conn = pvc_common.startZKConnection(config['coordinators']) - retflag, retdata = pvc_network.remove_acl(zk_conn, network, description, direction) + retflag, retdata = pvc_network.remove_acl(zk_conn, network, description) if retflag: retcode = 200 else: @@ -685,7 +788,7 @@ def net_acl_remove(network, direction, description): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode # # Ceph functions @@ -702,7 +805,7 @@ def ceph_status(): retcode = 400 pvc_common.stopZKConnection(zk_conn) - return flask.jsonify(retdata), retcode + return retdata, retcode def ceph_radosdf(): """ @@ -716,7 +819,7 @@ def ceph_radosdf(): retcode = 400 pvc_common.stopZKConnection(zk_conn) - return flask.jsonify(retdata), retcode + return retdata, retcode def ceph_osd_list(limit=None): """ @@ -736,7 +839,7 @@ def ceph_osd_list(limit=None): else: retcode = 400 - return flask.jsonify(retdata), retcode + return retdata, retcode def ceph_osd_state(osd): zk_conn = pvc_common.startZKConnection(config['coordinators']) @@ -756,7 +859,7 @@ def ceph_osd_state(osd): in_state = retdata[0]['stats']['in'] up_state = retdata[0]['stats']['up'] - return flask.jsonify({ "id": osd, "in": in_state, "up": up_state }), retcode + return { "id": osd, "in": in_state, "up": up_state }, retcode def ceph_osd_add(node, device, weight): """ @@ -773,7 +876,7 @@ def ceph_osd_add(node, device, weight): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def ceph_osd_remove(osd_id): """ @@ -790,7 +893,7 @@ def ceph_osd_remove(osd_id): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def ceph_osd_in(osd_id): """ @@ -807,7 +910,7 @@ def ceph_osd_in(osd_id): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def ceph_osd_out(osd_id): """ @@ -824,7 +927,7 @@ def ceph_osd_out(osd_id): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def ceph_osd_set(option): """ @@ -841,7 +944,7 @@ def ceph_osd_set(option): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def ceph_osd_unset(option): """ @@ -858,14 +961,19 @@ def ceph_osd_unset(option): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode -def ceph_pool_list(limit=None): +def ceph_pool_list(limit=None, is_fuzzy=True): """ Get the list of RBD pools in the Ceph storage cluster. """ zk_conn = pvc_common.startZKConnection(config['coordinators']) - retflag, retdata = pvc_ceph.get_list_pool(zk_conn, limit) + retflag, retdata = pvc_ceph.get_list_pool(zk_conn, limit, is_fuzzy) + + # If this is a single element, strip it out of the list + if isinstance(retdata, list) and len(retdata) == 1: + retdata = retdata[0] + if retflag: if retdata: retcode = 200 @@ -878,7 +986,7 @@ def ceph_pool_list(limit=None): retcode = 400 pvc_common.stopZKConnection(zk_conn) - return flask.jsonify(retdata), retcode + return retdata, retcode def ceph_pool_add(name, pgs, replcfg): """ @@ -895,7 +1003,7 @@ def ceph_pool_add(name, pgs, replcfg): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def ceph_pool_remove(name): """ @@ -912,14 +1020,19 @@ def ceph_pool_remove(name): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode -def ceph_volume_list(pool=None, limit=None): +def ceph_volume_list(pool=None, limit=None, is_fuzzy=True): """ Get the list of RBD volumes in the Ceph storage cluster. """ zk_conn = pvc_common.startZKConnection(config['coordinators']) - retflag, retdata = pvc_ceph.get_list_volume(zk_conn, pool, limit) + retflag, retdata = pvc_ceph.get_list_volume(zk_conn, pool, limit, is_fuzzy) + + # If this is a single element, strip it out of the list + if isinstance(retdata, list) and len(retdata) == 1: + retdata = retdata[0] + if retflag: if retdata: retcode = 200 @@ -932,7 +1045,7 @@ def ceph_volume_list(pool=None, limit=None): retcode = 400 pvc_common.stopZKConnection(zk_conn) - return flask.jsonify(retdata), retcode + return retdata, retcode def ceph_volume_add(pool, name, size): """ @@ -949,7 +1062,7 @@ def ceph_volume_add(pool, name, size): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def ceph_volume_clone(pool, name, source_volume): """ @@ -966,7 +1079,7 @@ def ceph_volume_clone(pool, name, source_volume): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def ceph_volume_resize(pool, name, size): """ @@ -983,7 +1096,7 @@ def ceph_volume_resize(pool, name, size): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def ceph_volume_rename(pool, name, new_name): """ @@ -1000,7 +1113,7 @@ def ceph_volume_rename(pool, name, new_name): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def ceph_volume_remove(pool, name): """ @@ -1017,14 +1130,19 @@ def ceph_volume_remove(pool, name): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode -def ceph_volume_snapshot_list(pool=None, volume=None, limit=None): +def ceph_volume_snapshot_list(pool=None, volume=None, limit=None, is_fuzzy=True): """ Get the list of RBD volume snapshots in the Ceph storage cluster. """ zk_conn = pvc_common.startZKConnection(config['coordinators']) - retflag, retdata = pvc_ceph.get_list_snapshot(zk_conn, pool, volume, limit) + retflag, retdata = pvc_ceph.get_list_snapshot(zk_conn, pool, volume, limit, is_fuzzy) + + # If this is a single element, strip it out of the list + if isinstance(retdata, list) and len(retdata) == 1: + retdata = retdata[0] + if retflag: if retdata: retcode = 200 @@ -1037,13 +1155,12 @@ def ceph_volume_snapshot_list(pool=None, volume=None, limit=None): retcode = 400 pvc_common.stopZKConnection(zk_conn) - return flask.jsonify(retdata), retcode + return retdata, retcode def ceph_volume_snapshot_add(pool, volume, name): """ Add a Ceph RBD volume snapshot to the PVC Ceph storage cluster. """ - return '', 200 zk_conn = pvc_common.startZKConnection(config['coordinators']) retflag, retdata = pvc_ceph.add_snapshot(zk_conn, pool, volume, name) if retflag: @@ -1055,7 +1172,7 @@ def ceph_volume_snapshot_add(pool, volume, name): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def ceph_volume_snapshot_rename(pool, volume, name, new_name): """ @@ -1072,7 +1189,7 @@ def ceph_volume_snapshot_rename(pool, volume, name, new_name): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode def ceph_volume_snapshot_remove(pool, volume, name): """ @@ -1089,5 +1206,5 @@ def ceph_volume_snapshot_remove(pool, volume, name): output = { 'message': retdata.replace('\"', '\'') } - return flask.jsonify(output), retcode + return output, retcode diff --git a/client-api/api_lib/pvcapi_provisioner.py b/client-api/api_lib/pvcapi_provisioner.py index 6f7d7fc6..e7a001e4 100755 --- a/client-api/api_lib/pvcapi_provisioner.py +++ b/client-api/api_lib/pvcapi_provisioner.py @@ -110,7 +110,7 @@ def list_template(limit, table, is_fuzzy=True): if table == 'network_template': for template_id, template_data in enumerate(data): # Fetch list of VNIs from network table - query = "SELECT vni FROM network WHERE network_template = %s;" + query = "SELECT * FROM network WHERE network_template = %s;" args = (template_data['id'],) cur.execute(query, args) vnis = cur.fetchall() @@ -119,13 +119,18 @@ def list_template(limit, table, is_fuzzy=True): if table == 'storage_template': for template_id, template_data in enumerate(data): # Fetch list of VNIs from network table - query = "SELECT * FROM storage WHERE storage_template = %s;" + query = 'SELECT * FROM storage WHERE storage_template = %s' args = (template_data['id'],) cur.execute(query, args) disks = cur.fetchall() data[template_id]['disks'] = disks close_database(conn, cur) + + # Strip outer list if only one element + if isinstance(data, list) and len(data) == 1: + data = data[0] + return data def list_template_system(limit, is_fuzzy=True): @@ -183,14 +188,14 @@ def template_list(limit): # # Template Create functions # -def create_template_system(name, vcpu_count, vram_mb, serial=False, vnc=False, vnc_bind=None, node_limit=None, node_selector=None, start_with_node=False): +def create_template_system(name, vcpu_count, vram_mb, serial=False, vnc=False, vnc_bind=None, node_limit=None, node_selector=None, node_autostart=False): if list_template_system(name, is_fuzzy=False): retmsg = { "message": "The system template {} already exists".format(name) } retcode = 400 return flask.jsonify(retmsg), retcode - query = "INSERT INTO system_template (name, vcpu_count, vram_mb, serial, vnc, vnc_bind, node_limit, node_selector, start_with_node) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s);" - args = (name, vcpu_count, vram_mb, serial, vnc, vnc_bind, node_limit, node_selector, start_with_node) + query = "INSERT INTO system_template (name, vcpu_count, vram_mb, serial, vnc, vnc_bind, node_limit, node_selector, node_autostart) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s);" + args = (name, vcpu_count, vram_mb, serial, vnc, vnc_bind, node_limit, node_selector, node_autostart) conn, cur = open_database(config) try: @@ -606,7 +611,7 @@ def delete_script(name): # def list_profile(limit, is_fuzzy=True): if limit: - if is_fuzzy: + if not is_fuzzy: # Handle fuzzy vs. non-fuzzy limits if not re.match('\^.*', limit): limit = '%' + limit @@ -629,6 +634,7 @@ def list_profile(limit, is_fuzzy=True): data = list() for profile in orig_data: profile_data = dict() + profile_data['id'] = profile['id'] profile_data['name'] = profile['name'] # Parse the name of each subelement for etype in 'system_template', 'network_template', 'storage_template', 'userdata_template', 'script': @@ -811,38 +817,38 @@ def create_vm(self, vm_name, vm_profile, define_vm=True, start_vm=True): vm_data = dict() # Get the profile information - query = "SELECT system_template, network_template, storage_template, script, arguments FROM profile WHERE name = %s" + query = "SELECT * FROM profile WHERE name = %s" args = (vm_profile,) db_cur.execute(query, args) profile_data = db_cur.fetchone() vm_data['script_arguments'] = profile_data['arguments'].split('|') # Get the system details - query = 'SELECT vcpu_count, vram_mb, serial, vnc, vnc_bind, node_limit, node_selector, start_with_node FROM system_template WHERE id = %s' + query = 'SELECT * FROM system_template WHERE id = %s' args = (profile_data['system_template'],) db_cur.execute(query, args) vm_data['system_details'] = db_cur.fetchone() # Get the MAC template - query = 'SELECT mac_template FROM network_template WHERE id = %s' + query = 'SELECT * FROM network_template WHERE id = %s' args = (profile_data['network_template'],) db_cur.execute(query, args) vm_data['mac_template'] = db_cur.fetchone()['mac_template'] # Get the networks - query = 'SELECT vni FROM network WHERE network_template = %s' + query = 'SELECT * FROM network WHERE network_template = %s' args = (profile_data['network_template'],) db_cur.execute(query, args) vm_data['networks'] = db_cur.fetchall() # Get the storage volumes - query = 'SELECT pool, disk_id, disk_size_gb, mountpoint, filesystem, filesystem_args FROM storage WHERE storage_template = %s' + query = 'SELECT * FROM storage WHERE storage_template = %s' args = (profile_data['storage_template'],) db_cur.execute(query, args) vm_data['volumes'] = db_cur.fetchall() # Get the script - query = 'SELECT script FROM script WHERE id = %s' + query = 'SELECT * FROM script WHERE id = %s' args = (profile_data['script'],) db_cur.execute(query, args) vm_data['script'] = db_cur.fetchone()['script'] @@ -1216,7 +1222,7 @@ def create_vm(self, vm_name, vm_profile, define_vm=True, start_vm=True): print("Defining and starting VM on cluster") if define_vm: - retcode, retmsg = pvc_vm.define_vm(zk_conn, vm_schema, target_node, vm_data['system_details']['node_limit'].split(','), vm_data['system_details']['node_selector'], vm_data['system_details']['start_with_node'], vm_profile) + retcode, retmsg = pvc_vm.define_vm(zk_conn, vm_schema, target_node, vm_data['system_details']['node_limit'].split(','), vm_data['system_details']['node_selector'], vm_data['system_details']['node_autostart'], vm_profile) print(retmsg) if start_vm: diff --git a/client-api/gen-doc.py b/client-api/gen-doc.py new file mode 100755 index 00000000..07d2c177 --- /dev/null +++ b/client-api/gen-doc.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +# gen-doc.py - Generate a Swagger JSON document for the API +# Part of the Parallel Virtual Cluster (PVC) system + +from flask_swagger import swagger +import sys +import json + +sys.path.append(',') + +pvc_api = __import__('pvc-api') + +swagger_file = "swagger.json" + +swagger_data = swagger(pvc_api.app) +swagger_data['info']['version'] = "1.0" +swagger_data['info']['title'] = "PVC Client and Provisioner API" + +with open(swagger_file, 'w') as fd: + fd.write(json.dumps(swagger_data, sort_keys=True, indent=4)) diff --git a/client-api/provisioner/schema.sql b/client-api/provisioner/schema.sql index 08c3f96b..c8800b2c 100644 --- a/client-api/provisioner/schema.sql +++ b/client-api/provisioner/schema.sql @@ -1,6 +1,6 @@ create database pvcprov with owner = pvcprov connection limit = -1; \c pvcprov -create table system_template (id SERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, vcpu_count INT NOT NULL, vram_mb INT NOT NULL, serial BOOL NOT NULL, vnc BOOL NOT NULL, vnc_bind TEXT, node_limit TEXT, node_selector TEXT, start_with_node BOOL NOT NULL); +create table system_template (id SERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, vcpu_count INT NOT NULL, vram_mb INT NOT NULL, serial BOOL NOT NULL, vnc BOOL NOT NULL, vnc_bind TEXT, node_limit TEXT, node_selector TEXT, node_autostart BOOL NOT NULL); create table network_template (id SERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, mac_template TEXT); create table network (id SERIAL PRIMARY KEY, network_template INT REFERENCES network_template(id), vni INT NOT NULL); create table storage_template (id SERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE); diff --git a/client-api/pvc-api.py b/client-api/pvc-api.py index 2c1fbfc5..94f2852b 100755 --- a/client-api/pvc-api.py +++ b/client-api/pvc-api.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# pvc-api.py - PVC HTTP API interface +# pvcapi.py - PVC HTTP API interface # Part of the Parallel Virtual Cluster (PVC) system # # Copyright (C) 2018-2019 Joshua M. Boniface @@ -20,15 +20,22 @@ # ############################################################################### -import flask import json import yaml import os -import distutils.util import gevent.pywsgi -import celery as Celery +import flask + +from distutils.util import strtobool + +from functools import wraps + +from flask_restful import Resource, Api, reqparse, abort +from flask_swagger import swagger + +from celery import Celery import api_lib.pvcapi_helper as api_helper import api_lib.pvcapi_provisioner as api_provisioner @@ -45,7 +52,7 @@ print('Starting PVC API daemon') # Read in the config try: with open(pvc_config_file, 'r') as cfgfile: - o_config = yaml.load(cfgfile) + o_config = yaml.load(cfgfile, Loader=yaml.BaseLoader) except Exception as e: print('ERROR: Failed to parse configuration file: {}'.format(e)) exit(1) @@ -57,7 +64,7 @@ try: 'coordinators': o_config['pvc']['coordinators'], 'listen_address': o_config['pvc']['api']['listen_address'], 'listen_port': int(o_config['pvc']['api']['listen_port']), - 'auth_enabled': o_config['pvc']['api']['authentication']['enabled'], + 'auth_enabled': strtobool(o_config['pvc']['api']['authentication']['enabled']), 'auth_secret_key': o_config['pvc']['api']['authentication']['secret_key'], 'auth_tokens': o_config['pvc']['api']['authentication']['tokens'], 'ssl_enabled': o_config['pvc']['api']['ssl']['enabled'], @@ -89,2028 +96,5079 @@ except Exception as e: print('ERROR: {}.'.format(e)) exit(1) -api = flask.Flask(__name__) -api.config['CELERY_BROKER_URL'] = 'redis://{}:{}{}'.format(config['queue_host'], config['queue_port'], config['queue_path']) -api.config['CELERY_RESULT_BACKEND'] = 'redis://{}:{}{}'.format(config['queue_host'], config['queue_port'], config['queue_path']) +# Create Flask app and set config values +app = flask.Flask(__name__) +app.config['CELERY_BROKER_URL'] = 'redis://{}:{}{}'.format(config['queue_host'], config['queue_port'], config['queue_path']) +app.config['CELERY_RESULT_BACKEND'] = 'redis://{}:{}{}'.format(config['queue_host'], config['queue_port'], config['queue_path']) if config['debug']: - api.config['DEBUG'] = True + app.config['DEBUG'] = True if config['auth_enabled']: - api.config["SECRET_KEY"] = config['auth_secret_key'] + app.config["SECRET_KEY"] = config['auth_secret_key'] -celery = Celery.Celery(api.name, broker=api.config['CELERY_BROKER_URL']) -celery.conf.update(api.config) +# Create Flask blueprint +blueprint = flask.Blueprint('api', __name__, url_prefix='/api/v1') + +# Create Flask-RESTful definition +api = Api(blueprint) +app.register_blueprint(blueprint) + +# Create celery definition +celery = Celery(app.name, broker=app.config['CELERY_BROKER_URL']) +celery.conf.update(app.config) + +# +# Custom decorators +# + +# Request parser decorator +class RequestParser(object): + def __init__(self, reqargs): + self.reqargs = reqargs + def __call__(self, function): + if not callable(function): + return + @wraps(function) + def wrapped_function(*args, **kwargs): + parser = reqparse.RequestParser() + # Parse and add each argument + for reqarg in self.reqargs: + parser.add_argument( + reqarg.get('name', None), + required=reqarg.get('required', False), + action=reqarg.get('action', None), + choices=reqarg.get('choices', ()), + help=reqarg.get('helptext', None) + ) + reqargs = parser.parse_args() + kwargs['reqargs'] = reqargs + return function(*args, **kwargs) + return wrapped_function # Authentication decorator function -def authenticator(function): +def Authenticator(function): + @wraps(function) def authenticate(*args, **kwargs): # No authentication required if not config['auth_enabled']: return function(*args, **kwargs) - # Session-based authentication if 'token' in flask.session: return function(*args, **kwargs) - # Key header-based authentication if 'X-Api-Key' in flask.request.headers: if any(token for token in secret_tokens if flask.request.headers.get('X-Api-Key') == token): return function(*args, **kwargs) else: - return "X-Api-Key Authentication failed\n", 401 - + return {"message":"X-Api-Key Authentication failed"}, 401 # All authentications failed - return "X-Api-Key Authentication required\n", 401 - - authenticate.__name__ = function.__name__ + return {"message":"X-Api-Key Authentication required"}, 401 return authenticate + # # Job functions # - @celery.task(bind=True) def create_vm(self, vm_name, profile_name): return api_provisioner.create_vm(self, vm_name, profile_name) + ########################################################## # API Root/Authentication ########################################################## -@api.route('/api/v1', methods=['GET']) -def api_root(): - return flask.jsonify({"message":"PVC API version 1"}), 209 +# / +class API_Root(Resource): + def get(self): + """ + Return the PVC API version string + --- + tags: + - root + responses: + 200: + description: OK + schema: + type: object + id: API-Version + properties: + message: + type: string + description: A text message + example: "PVC API version 1" + """ + return {"message":"PVC API version 1"} +api.add_resource(API_Root, '/') -@api.route('/api/v1/auth/login', methods=['GET', 'POST']) -def api_auth_login(): - # Just return a 200 if auth is disabled - if not config['auth_enabled']: - return flask.jsonify({"message":"Authentication is disabled."}), 200 +# /doc +class API_Doc(Resource): + def get(self): + return swagger(app) +api.add_resource(API_Doc, '/doc') - if flask.request.method == 'GET': - return ''' -
-

- Enter your authentication token: - - -

-
- ''' +# /login +class API_Login(Resource): + def post(self): + """ + Log in to the PVC API with an authentication key + --- + tags: + - root + parameters: + - in: query + name: token + type: string + required: true + responses: + 200: + description: OK + schema: + type: object + id: Message + properties: + message: + type: string + description: A text message + 302: + description: Authentication disabled + 401: + description: Unauthorized + schema: + type: object + id: Message + """ + if not config['auth_enabled']: + return flask.redirect(Api.url_for(api, API_Root)) - if flask.request.method == 'POST': if any(token for token in config['auth_tokens'] if flask.request.values['token'] in token['token']): flask.session['token'] = flask.request.form['token'] - return flask.redirect(flask.url_for('api_root')) + return { "message": "Authentication successful" }, 200 else: - return flask.jsonify({"message":"Authentication failed"}), 401 + { "message": "Authentication failed" }, 401 +api.add_resource(API_Login, '/login') -@api.route('/api/v1/auth/logout', methods=['GET', 'POST']) -def api_auth_logout(): - # Just return a 200 if auth is disabled - if not config['auth_enabled']: - return flask.jsonify({"message":"Authentication is disabled."}), 200 +# /logout +class API_Logout(Resource): + def post(self): + """ + Log out of an existing PVC API session + --- + tags: + - root + responses: + 200: + description: OK + schema: + type: object + id: Message + 302: + description: Authentication disabled + """ + if not config['auth_enabled']: + return flask.redirect(Api.url_for(api, API_Root)) + + flask.session.pop('token', None) + return { "message": "Deauthentication successful" }, 200 +api.add_resource(API_Logout, '/logout') + +# /initialize +class API_Initialize(Resource): + @Authenticator + def post(self): + """ + 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 + responses: + 200: + description: OK + schema: + type: object + id: Message + properties: + message: + type: string + description: A text message + 400: + description: Bad request + """ + # TODO: Implement me in api_helper + if api_helper.initialize_cluster(): + return { "message": "Successfully initialized a new PVC cluster" }, 200 + else: + return { "message": "PVC cluster already initialized" }, 400 +api.add_resource(API_Initialize, '/initialize') - # remove the username from the session if it's there - flask.session.pop('token', None) - return flask.redirect(flask.url_for('api_root')) ########################################################## -# Cluster API +# Client API - Node ########################################################## -# -# Node endpoints -# -@api.route('/api/v1/node', methods=['GET']) -@authenticator -def api_node_root(): - # Get name limit - if 'limit' in flask.request.values: - limit = flask.request.values['limit'] - else: - limit = None +# /node +class API_Node_Root(Resource): + @RequestParser([ + { 'name': 'limit' } + ]) + @Authenticator + def get(self, reqargs): + """ + Return a list of nodes in the cluster + --- + tags: + - node + definitions: + - schema: + type: object + id: node + properties: + name: + type: string + description: The name of the node + daemon_state: + type: string + description: The current daemon state + coordinator_state: + type: string + description: The current coordinator state + domain_state: + type: string + description: The current domain (VM) state + cpu_count: + type: integer + description: The number of available CPU cores + kernel: + type: string + desription: The running kernel version from uname + os: + type: string + description: The current operating system type + arch: + type: string + description: The architecture of the CPU + load: + type: number + format: float + description: The current 5-minute CPU load + domains_count: + type: integer + description: The number of running domains (VMs) + running_domains: + type: string + description: The list of running domains (VMs) by UUID + vcpu: + type: object + properties: + total: + type: integer + description: The total number of real CPU cores available + allocated: + type: integer + description: The total number of allocated vCPU cores + memory: + type: object + properties: + total: + type: integer + description: The total amount of node RAM in MB + allocated: + type: integer + description: The total amount of RAM allocated to domains in MB + used: + type: integer + description: The total used RAM on the node in MB + free: + type: integer + description: The total free RAM on the node in MB + parameters: + - in: query + name: limit + type: string + required: false + description: A search limit; fuzzy by default, use ^/$ to force exact matches + responses: + 200: + description: OK + schema: + type: array + items: + $ref: '#/definitions/node' + """ + return api_helper.node_list(reqargs.get('limit', None)) +api.add_resource(API_Node_Root, '/node') - return api_helper.node_list(limit) +# /node/ +class API_Node_Element(Resource): + @Authenticator + def get(self, node): + """ + Return information about {node} + --- + tags: + - node + responses: + 200: + description: OK + schema: + $ref: '#/definitions/node' + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.node_list(node, is_fuzzy=False) +api.add_resource(API_Node_Element, '/node/') -@api.route('/api/v1/node/', methods=['GET']) -@authenticator -def api_node_element(node): - # Same as specifying /node?limit=NODE - return api_helper.node_list(node) - -@api.route('/api/v1/node//daemon-state', methods=['GET']) -@authenticator -def api_node_daemon_state(node): - if flask.request.method == 'GET': +# /node//daemon-state +class API_Node_DaemonState(Resource): + @Authenticator + def get(self, node): + """ + Return the daemon state of {node} + --- + tags: + - node + responses: + 200: + description: OK + schema: + type: object + id: NodeDaemonState + properties: + name: + type: string + description: The name of the node + daemon_state: + type: string + description: The current daemon state + 404: + description: Not found + schema: + type: object + id: Message + """ return api_helper.node_daemon_state(node) +api.add_resource(API_Node_DaemonState, '/node//daemon-state') -@api.route('/api/v1/node//coordinator-state', methods=['GET', 'POST']) -@authenticator -def api_node_coordinator_state(node): - if flask.request.method == 'GET': +# /node//coordinator-state +class API_Node_CoordinatorState(Resource): + @Authenticator + def get(self, node): + """ + Return the coordinator state of {node} + --- + tags: + - node + responses: + 200: + description: OK + schema: + type: object + id: NodeCoordinatorState + properties: + name: + type: string + description: The name of the node + coordinator_state: + type: string + description: The current coordinator state + 404: + description: Not found + schema: + type: object + id: Message + """ return api_helper.node_coordinator_state(node) - if flask.request.method == 'POST': - if not 'coordinator-state' in flask.request.values: - flask.abort(400) - new_state = flask.request.values['coordinator-state'] - if new_state == 'primary': + @RequestParser([ + { 'name': 'state', 'choices': ('primary', 'secondary'), 'helptext': "A valid state must be specified", 'required': True } + ]) + @Authenticator + def post(self, node, reqargs): + """ + Set the coordinator state of {node} + --- + tags: + - node + parameters: + - in: query + name: action + type: string + required: true + description: The new coordinator state of the node + enum: + - primary + - secondary + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + if reqargs['state'] == 'primary': return api_helper.node_primary(node) - if new_state == 'secondary': + if reqargs['state'] == 'secondary': return api_helper.node_secondary(node) - flask.abort(400) + abort(400) +api.add_resource(API_Node_CoordinatorState, '/node//coordinator-state') -@api.route('/api/v1/node//domain-state', methods=['GET', 'POST']) -@authenticator -def api_node_domain_state(node): - if flask.request.method == 'GET': +# /node//domain-state +class API_Node_DomainState(Resource): + @Authenticator + def get(self, node): + """ + Return the domain state of {node} + --- + tags: + - node + responses: + 200: + description: OK + schema: + type: object + id: NodeDomainState + properties: + name: + type: string + description: The name of the node + domain_state: + type: string + description: The current domain state + 404: + description: Not found + schema: + type: object + id: Message + """ return api_helper.node_domain_state(node) - if flask.request.method == 'POST': - if not 'domain-state' in flask.request.values: - flask.abort(400) - new_state = flask.request.values['domain-state'] - if new_state == 'ready': - return api_helper.node_ready(node) - if new_state == 'flush': + @RequestParser([ + { 'name': 'state', 'choices': ('ready', 'flush'), 'helptext': "A valid state must be specified", 'required': True } + ]) + @Authenticator + def post(self, node, reqargs): + """ + Set the domain state of {node} + --- + tags: + - node + parameters: + - in: query + name: action + type: string + required: true + description: The new domain state of the node + enum: + - flush + - ready + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + if reqargs['state'] == 'flush': return api_helper.node_flush(node) - flask.abort(400) + if reqargs['state'] == 'ready': + return api_helper.node_ready(node) + abort(400) +api.add_resource(API_Node_DomainState, '/node//domain-state') -# -# VM endpoints -# -@api.route('/api/v1/vm', methods=['GET', 'POST']) -@authenticator -def api_vm_root(): - if flask.request.method == 'GET': - # Get node limit - if 'node' in flask.request.values: - node = flask.request.values['node'] - else: - node = None - # Get state limit - if 'state' in flask.request.values: - state = flask.request.values['state'] - else: - state = None +########################################################## +# Client API - VM +########################################################## - # Get name limit - if 'limit' in flask.request.values: - limit = flask.request.values['limit'] - else: - limit = None +# /vm +class API_VM_Root(Resource): + @RequestParser([ + { 'name': 'limit' }, + { 'name': 'node' }, + { 'name': 'state' }, + ]) + @Authenticator + def get(self, reqargs): + """ + Return a list of VMs in the cluster + --- + tags: + - vm + definitions: + - schema: + type: object + id: vm + properties: + name: + type: string + description: The name of the VM + uuid: + type: string + description: The UUID of the VM + state: + type: string + description: The current state of the VM + node: + type: string + description: The node the VM is currently assigned to + last_node: + type: string + description: The last node the VM was assigned to before migrating + migrated: + type: string + description: Whether the VM has been migrated, either "no" or "from " + failed_reason: + type: string + description: Information about why the VM failed to start + node_limit: + type: array + description: The node(s) the VM is permitted to be assigned to + items: + type: string + node_selector: + type: string + description: The selector used to determine candidate nodes during migration + node_autostart: + type: boolean + description: Whether to autostart the VM when its node returns to ready domain state + description: + type: string + description: The description of the VM + profile: + type: string + description: The provisioner profile used to create the VM + memory: + type: integer + description: The assigned RAM of the VM in MB + vcpu: + type: integer + description: The assigned vCPUs of the VM + vcpu_topology: + type: string + description: The topology of the assigned vCPUs in Sockets/Cores/Threads format + type: + type: string + description: The type of the VM + arch: + type: string + description: The architecture of the VM + machine: + type: string + description: The QEMU machine type of the VM + console: + type: string + descritpion: The serial console type of the VM + emulator: + type: string + description: The binary emulator of the VM + features: + type: array + description: The available features of the VM + items: + type: string + networks: + type: array + description: The PVC networks attached to the VM + items: + type: object + properties: + type: + type: string + description: The PVC network type + mac: + type: string + description: The MAC address of the VM network interface + source: + type: string + description: The parent network bridge on the node + model: + type: string + description: The virtual network device model + disks: + type: array + description: The PVC storage volumes attached to the VM + items: + type: object + properties: + type: + type: string + description: The type of volume + name: + type: string + description: The full name of the volume in "pool/volume" format + dev: + type: string + description: The device ID of the volume in the VM + bus: + type: string + description: The virtual bus of the volume in the VM + controllers: + type: array + description: The device controllers attached to the VM + items: + type: object + properties: + type: + type: string + description: The type of the controller + model: + type: string + description: The model of the controller + xml: + type: string + description: The raw Libvirt XML definition of the VM + parameters: + - in: query + name: limit + type: string + required: false + description: A name search limit; fuzzy by default, use ^/$ to force exact matches + - in: query + name: node + type: string + required: false + description: Limit list to VMs assigned to this node + - in: query + name: state + type: string + required: false + description: Limit list to VMs in this state + responses: + 200: + description: OK + schema: + type: array + items: + $ref: '#/definitions/vm' + """ + return api_helper.vm_list( + reqargs.get('node', None), + reqargs.get('state', None), + reqargs.get('limit', None) + ) - return api_helper.vm_list(node, state, limit) + @RequestParser([ + { 'name': 'limit' }, + { 'name': 'node' }, + { 'name': 'selector', 'choices': ('mem', 'vcpu', 'load', 'vms'), 'helptext': "A valid selector must be specified" }, + { 'name': 'autostart' }, + { 'name': 'xml', 'required': True, 'helptext': "A Libvirt XML document must be specified" }, + ]) + @Authenticator + def post(self, reqargs): + """ + Create a new virtual machine + --- + tags: + - vm + parameters: + - in: query + name: xml + type: string + required: true + description: The raw Libvirt XML definition of the VM + - in: query + name: node + type: string + required: false + description: The node the VM should be assigned to; autoselect if empty or invalid + - in: query + name: limit + type: string + required: false + description: The CSV list of node(s) the VM is permitted to be assigned to; should include "node" and any other valid target nodes; this limit will be used for autoselection on definition and migration + - in: query + name: selector + type: string + required: false + description: The selector used to determine candidate nodes during migration + default: mem + enum: + - mem + - vcpu + - load + - vms + - in: query + name: autostart + type: boolean + required: false + description: Whether to autostart the VM when its node returns to ready domain state + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.vm_define( + reqargs.get('xml'), + reqargs.get('node', None), + reqargs.get('limit', None), + reqargs.get('selector', 'mem'), + reqargs.get('autostart', False) + ) +api.add_resource(API_VM_Root, '/vm') - if flask.request.method == 'POST': - # Get XML data - if 'xml' in flask.request.values: - libvirt_xml = flask.request.values['xml'] - else: - return flask.jsonify({"message":"ERROR: A Libvirt XML document must be specified."}), 400 - - # Get node name - if 'node' in flask.request.values: - node = flask.request.values['node'] - else: - node = None - - # Set target limit metadata - if 'limit' in flask.request.values: - limit = flask.request.values['limit'].split(',') - else: - limit = None - - # Set target selector metadata - if 'selector' in flask.request.values: - selector = flask.request.values['selector'] - else: - selector = 'mem' - - # Set target autostart metadata - if 'autostart' in flask.request.values: - autostart = True - else: - autostart = False - - return api_helper.vm_define(libvirt_xml, node, limit, selector, autostart) - -@api.route('/api/v1/vm/', methods=['GET', 'POST', 'PUT', 'DELETE']) -@authenticator -def api_vm_element(vm): - if flask.request.method == 'GET': - # Same as specifying /vm?limit=VM +# /vm/ +class API_VM_Element(Resource): + @Authenticator + def get(self, vm): + """ + Return information about {vm} + --- + tags: + - vm + responses: + 200: + description: OK + schema: + $ref: '#/definitions/vm' + 404: + description: Not found + schema: + type: object + id: Message + """ return api_helper.vm_list(None, None, vm, is_fuzzy=False) - if flask.request.method == 'POST': - # Set target limit metadata - if 'limit' in flask.request.values: - limit = flask.request.values['limit'].split(',') - else: - limit = None + @RequestParser([ + { 'name': 'limit' }, + { 'name': 'node' }, + { 'name': 'selector', 'choices': ('mem', 'vcpu', 'load', 'vms'), 'helptext': "A valid selector must be specified" }, + { 'name': 'autostart' }, + { 'name': 'xml', 'required': True, 'helptext': "A Libvirt XML document must be specified" }, + ]) + @Authenticator + def post(self, vm, reqargs): + """ + Create new {vm} + Note: The name {vm} is ignored; only the "name" value from the Libvirt XML is used + This endpoint is identical to "POST /api/v1/vm" + --- + tags: + - vm + parameters: + - in: query + name: xml + type: string + required: true + description: The raw Libvirt XML definition of the VM + - in: query + name: node + type: string + required: false + description: The node the VM should be assigned to; autoselect if empty or invalid + - in: query + name: limit + type: string + required: false + description: The CSV list of node(s) the VM is permitted to be assigned to; should include "node" and any other valid target nodes; this limit will be used for autoselection on definition and migration + - in: query + name: selector + type: string + required: false + description: The selector used to determine candidate nodes during migration + default: mem + enum: + - mem + - vcpu + - load + - vms + - in: query + name: autostart + type: boolean + required: false + description: Whether to autostart the VM when its node returns to ready domain state + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.vm_define( + reqargs.get('xml'), + reqargs.get('node', None), + reqargs.get('limit', None), + reqargs.get('selector', 'mem'), + reqargs.get('autostart', False) + ) - # Set target selector metadata - if 'selector' in flask.request.values: - selector = flask.request.values['selector'] - else: - selector = None + @RequestParser([ + { 'name': 'restart' }, + { 'name': 'xml', 'required': True, 'helptext': "A Libvirt XML document must be specified" }, + ]) + @Authenticator + def put(self, vm, reqargs): + """ + Update the Libvirt XML of {vm} + --- + tags: + - vm + parameters: + - in: query + name: xml + type: string + required: true + description: The raw Libvirt XML definition of the VM + - in: query + name: restart + type: boolean + description: Whether to automatically restart the VM to apply the new configuration + responses: + 200: + description: OK + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.vm_modify( + vm, + reqargs.get('restart', False), + reqargs.get('xml', None) + ) - # Set target autostart metadata - if 'no-autostart' in flask.request.values: - autostart = False - elif 'autostart' in flask.request.values: - autostart = True - else: - autostart = None - - return api_helper.vm_meta(vm, limit, selector, autostart) - - if flask.request.method == 'PUT': - libvirt_xml = flask.request.data - - if 'restart' in flask.request.values and flask.request.values['restart']: - flag_restart = True - else: - flag_restart = False - - return api_helper.vm_modify(vm, flag_restart, libvirt_xml) - - if flask.request.method == 'DELETE': - if 'delete_disks' in flask.request.values and flask.request.values['delete_disks']: + @RequestParser([ + { 'name': 'delete_disks' }, + ]) + @Authenticator + def delete(self, vm, reqargs): + """ + Remove {vm} + --- + tags: + - vm + parameters: + - in: query + name: delete_disks + type: boolean + default: false + description: Whether to automatically delete all VM disk volumes + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + 404: + description: VM not found + schema: + type: object + id: Message + """ + if reqargs.get('delete_disks', False): return api_helper.vm_remove(vm) else: return api_helper.vm_undefine(vm) +api.add_resource(API_VM_Element, '/vm/') -@api.route('/api/v1/vm//state', methods=['GET', 'POST']) -@authenticator -def api_vm_state(vm): - if flask.request.method == 'GET': +# /vm//meta +class API_VM_Metadata(Resource): + @Authenticator + def get(self, vm): + """ + Return the metadata of {vm} + --- + tags: + - vm + responses: + 200: + description: OK + schema: + type: object + id: VMMetadata + properties: + name: + type: string + description: The name of the VM + node_limit: + type: array + description: The node(s) the VM is permitted to be assigned to + items: + type: string + node_selector: + type: string + description: The selector used to determine candidate nodes during migration + node_autostart: + type: string + description: Whether to autostart the VM when its node returns to ready domain state + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.get_vm_meta(vm) + + @RequestParser([ + { 'name': 'limit' }, + { 'name': 'selector', 'choices': ('mem', 'vcpu', 'load', 'vms'), 'helptext': "A valid selector must be specified" }, + { 'name': 'autostart' }, + ]) + @Authenticator + def post(self, vm, reqargs): + """ + Set the metadata of {vm} + --- + tags: + - vm + parameters: + - in: query + name: limit + type: string + required: false + description: The CSV list of node(s) the VM is permitted to be assigned to; should include "node" and any other valid target nodes; this limit will be used for autoselection on definition and migration + - in: query + name: selector + type: string + required: false + description: The selector used to determine candidate nodes during migration + enum: + - mem + - vcpu + - load + - vms + - in: query + name: autostart + type: boolean + required: false + description: Whether to autostart the VM when its node returns to ready domain state + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.update_vm_meta( + reqargs.get('limit', None), + reqargs.get('selector', None), + reqargs.get('autostart', None) + ) +api.add_resource(API_VM_Metadata, '/vm//meta') + +# /vm//node', methods=['GET', 'POST']) -@authenticator -def api_vm_node(vm): - if flask.request.method == 'GET': + if state == 'start': + return api_helper.vm_start(vm) + if state == 'shutdown': + return api_helper.vm_shutdown(vm) + if state == 'stop': + return api_helper.vm_stop(vm) + if state == 'restart': + return api_helper.vm_restart(vm) + if state == 'disable': + return api_helper.vm_disable(vm) + abort(400) +api.add_resource(API_VM_State, '/vm//state') + +# /vm//node +class API_VM_Node(Resource): + @Authenticator + def get(self, vm): + """ + Return the node information of {vm} + --- + tags: + - vm + responses: + 200: + description: OK + schema: + type: object + id: VMNode + properties: + name: + type: string + description: The name of the VM + node: + type: string + description: The node the VM is currently assigned to + last_node: + type: string + description: The last node the VM was assigned to before migrating + 404: + description: Not found + schema: + type: object + id: Message + """ return api_helper.vm_node(vm) - if flask.request.method == 'POST': - if 'action' in flask.request.values: - action = flask.request.values['action'] - else: - flask.abort(400) + @RequestParser([ + { 'name': 'action', 'choices': ('migrate', 'unmigrate', 'move'), 'helptext': "A valid action must be specified", 'required': True }, + { 'name': 'node' }, + { 'name': 'force' } + ]) + @Authenticator + def post(self, vm, reqargs): + """ + Set the node of {vm} + --- + tags: + - vm + parameters: + - in: query + name: action + type: string + required: true + description: The action to take to change nodes + enum: + - migrate + - unmigrate + - move + - in: query + name: node + type: string + description: The node the VM should be assigned to; autoselect if empty or invalid + - in: query + name: force + type: boolean + description: Whether to force an already-migrated VM to a new node + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + action = reqargs.get('action', None) + node = reqargs.get('node', None) + force = reqargs.get('force', False) - # Get node name - if 'node' in flask.request.values: - node = flask.request.values['node'] - else: - node = None - # Get permanent flag - if 'permanent' in flask.request.values and flask.request.values['permanent']: - flag_permanent = True - else: - flag_permanent = False - # Get force flag - if 'force' in flask.request.values and flask.request.values['force']: - flag_force = True - else: - flag_force = False - - # Check if VM is presently migrated - is_migrated = api_helper.vm_is_migrated(vm) - - if action == 'migrate' and not flag_permanent: - return api_helper.vm_migrate(vm, node, flag_force) - if action == 'migrate' and flag_permanent: + if action == 'move': return api_helper.vm_move(vm, node) - if action == 'unmigrate' and is_migrated: + if action == 'migrate': + return api_helper.vm_migrate(vm, node, force) + if action == 'unmigrate': return api_helper.vm_unmigrate(vm) + abort(400) +api.add_resource(API_VM_Node, '/vm//node') - flask.abort(400) - -@api.route('/api/v1/vm//locks', methods=['GET', 'POST']) -@authenticator -def api_vm_locks(vm): - if flask.request.method == 'GET': - return "Not implemented", 400 - - if flask.request.method == 'POST': +# /vm//locks +class API_VM_Locks(Resource): + @Authenticator + def post(self, vm): + """ + Flush disk locks of {vm} + --- + tags: + - vm + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ return api_helper.vm_flush_locks(vm) +api.add_resource(API_VM_Locks, '/vm//locks') -# -# Network endpoints -# -@api.route('/api/v1/network', methods=['GET', 'POST']) -@authenticator -def api_net_root(): - if flask.request.method == 'GET': - # Get name limit - if 'limit' in flask.request.values: - limit = flask.request.values['limit'] - else: - limit = None +########################################################## +# Client API - Network +########################################################## - return api_helper.net_list(limit) +# /network +class API_Network_Root(Resource): + @RequestParser([ + { 'name': 'limit' } + ]) + @Authenticator + def get(self, reqargs): + """ + Return a list of networks in the cluster + --- + tags: + - network + definitions: + - schema: + type: object + id: network + properties: + vni: + type: integer + description: The VNI of the network + description: + type: string + description: The description of the network + type: + type: string + description: The type of network + enum: + - managed + - bridged + domain: + type: string + description: The DNS domain of the network ("managed" networks only) + name_servers: + type: array + description: The configured DNS nameservers of the network for NS records ("managed" networks only) + items: + type: string + ip4: + type: object + description: The IPv4 details of the network ("managed" networks only) + properties: + network: + type: string + description: The IPv4 network subnet in CIDR format + gateway: + type: string + description: The IPv4 default gateway address + dhcp_flag: + type: boolean + description: Whether DHCP is enabled + dhcp_start: + type: string + description: The IPv4 DHCP pool start address + dhcp_end: + type: string + description: The IPv4 DHCP pool end address + ip6: + type: object + description: The IPv6 details of the network ("managed" networks only) + properties: + network: + type: string + description: The IPv6 network subnet in CIDR format + gateway: + type: string + description: The IPv6 default gateway address + dhcp_flag: + type: boolean + description: Whether DHCPv6 is enabled + parameters: + - in: query + name: limit + type: string + required: false + description: A VNI or description search limit; fuzzy by default, use ^/$ to force exact matches + responses: + 200: + description: OK + schema: + type: array + items: + $ref: '#/definitions/network' + """ + return api_helper.net_list(reqargs.get('limit', None)) - if flask.request.method == 'POST': - # Get network VNI - if 'vni' in flask.request.values: - vni = flask.request.values['vni'] - else: - return flask.jsonify({"message":"ERROR: A VNI must be specified for the virtual network."}), 520 + @RequestParser([ + { 'name': 'vni', 'required': True }, + { 'name': 'description', 'required': True }, + { 'name': 'nettype', 'choices': ('managed', 'bridged'), 'helptext': 'A valid nettype must be specified', 'required': True }, + { 'name': 'domain' }, + { 'name': 'name_servers' }, + { 'name': 'ip4_network' }, + { 'name': 'ip4_gateway' }, + { 'name': 'ip6_network' }, + { 'name': 'ip6_gateway' }, + { 'name': 'dhcp4' }, + { 'name': 'dhcp4_start' }, + { 'name': 'dhcp4_end' } + ]) + @Authenticator + def post(self): + """ + Create a new network + --- + tags: + - network + parameters: + - in: query + name: vni + type: integer + required: true + description: The VNI of the network + - in: query + name: description + type: string + required: true + description: The description of the network + - in: query + name: nettype + type: string + required: true + description: The type of network + enum: + - managed + - bridged + - in: query + name: domain + type: string + description: The DNS domain of the network ("managed" networks only) + - in: query + name: name_servers + type: string + description: The CSV list of DNS nameservers for network NS records ("managed" networks only) + - in: query + name: ip4_network + type: string + description: The IPv4 network subnet of the network in CIDR format; IPv4 disabled if unspecified ("managed" networks only) + - in: query + name: ip4_gateway + type: string + description: The IPv4 default gateway address of the network ("managed" networks only) + - in: query + name: dhcp4 + type: boolean + description: Whether to enable DHCPv4 for the network ("managed" networks only) + - in: query + name: dhcp4_start + type: string + description: The DHCPv4 pool start address of the network ("managed" networks only) + - in: query + name: dhcp4_end + type: string + description: The DHCPv4 pool end address of the network ("managed" networks only) + - in: query + name: ip6_network + type: string + description: The IPv6 network subnet of the network in CIDR format; IPv6 disabled if unspecified; DHCPv6 is always used in IPv6 managed networks ("managed" networks only) + - in: query + name: ip6_gateway + type: string + description: The IPv6 default gateway address of the network ("managed" networks only) + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.net_add( + reqargs.get('vni', None), + reqargs.get('description', None), + reqargs.get('nettype', None), + reqargs.get('domain', None), + reqargs.get('name_servers', None).split(','), + reqargs.get('ip4_network', None), + reqargs.get('ip4_gateway', None), + reqargs.get('ip6_network', None), + reqargs.get('ip6_gateway', None), + reqargs.get('dhcp4_flag', None), + reqargs.get('dhcp4_start', None), + reqargs.get('dhcp4_end', None), + ) +api.add_resource(API_Network_Root, '/network') - # Get network description - if 'description' in flask.request.values: - description = flask.request.values['vni'] - else: - return flask.jsonify({"message":"ERROR: A VNI must be specified for the virtual network."}), 520 +# /network/ +class API_Network_Element(Resource): + @Authenticator + def get(self, vni): + """ + Return information about network {vni} + --- + tags: + - network + responses: + 200: + description: OK + schema: + $ref: '#/definitions/network' + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.net_list(vni, is_fuzzy=False) - # Get network type - if 'nettype' in flask.request.values: - nettype = flask.request.values['nettype'] - if not 'managed' in nettype and not 'bridged' in nettype: - return flask.jsonify({"message":"ERROR: A valid nettype must be specified: 'managed' or 'bridged'."}), 520 - else: - return flask.jsonify({"message":"ERROR: A nettype must be specified for the virtual network."}), 520 + @RequestParser([ + { 'name': 'description', 'required': True }, + { 'name': 'nettype', 'choices': ('managed', 'bridged'), 'helptext': 'A valid nettype must be specified', 'required': True }, + { 'name': 'domain' }, + { 'name': 'name_servers' }, + { 'name': 'ip4_network' }, + { 'name': 'ip4_gateway' }, + { 'name': 'ip6_network' }, + { 'name': 'ip6_gateway' }, + { 'name': 'dhcp4' }, + { 'name': 'dhcp4_start' }, + { 'name': 'dhcp4_end' } + ]) + @Authenticator + def post(self, vni, reqargs): + """ + Create a new network {vni} + --- + tags: + - network + parameters: + - in: query + name: description + type: string + required: true + description: The description of the network + - in: query + name: nettype + type: string + required: true + description: The type of network + enum: + - managed + - bridged + - in: query + name: domain + type: string + description: The DNS domain of the network ("managed" networks only) + - in: query + name: name_servers + type: string + description: The CSV list of DNS nameservers for network NS records ("managed" networks only) + - in: query + name: ip4_network + type: string + description: The IPv4 network subnet of the network in CIDR format; IPv4 disabled if unspecified ("managed" networks only) + - in: query + name: ip4_gateway + type: string + description: The IPv4 default gateway address of the network ("managed" networks only) + - in: query + name: dhcp4 + type: boolean + description: Whether to enable DHCPv4 for the network ("managed" networks only) + - in: query + name: dhcp4_start + type: string + description: The DHCPv4 pool start address of the network ("managed" networks only) + - in: query + name: dhcp4_end + type: string + description: The DHCPv4 pool end address of the network ("managed" networks only) + - in: query + name: ip6_network + type: string + description: The IPv6 network subnet of the network in CIDR format; IPv6 disabled if unspecified; DHCPv6 is always used in IPv6 managed networks ("managed" networks only) + - in: query + name: ip6_gateway + type: string + description: The IPv6 default gateway address of the network ("managed" networks only) + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.net_add( + reqargs.get('vni', None), + reqargs.get('description', None), + reqargs.get('nettype', None), + reqargs.get('domain', None), + reqargs.get('name_servers', None).split(','), + reqargs.get('ip4_network', None), + reqargs.get('ip4_gateway', None), + reqargs.get('ip6_network', None), + reqargs.get('ip6_gateway', None), + reqargs.get('dhcp4_flag', None), + reqargs.get('dhcp4_start', None), + reqargs.get('dhcp4_end', None), + ) - # Get network domain - if 'domain' in flask.request.values: - domain = flask.request.values['domain'] - else: - domain = None + @RequestParser([ + { 'name': 'description' }, + { 'name': 'domain' }, + { 'name': 'name_servers' }, + { 'name': 'ip4_network' }, + { 'name': 'ip4_gateway' }, + { 'name': 'ip6_network' }, + { 'name': 'ip6_gateway' }, + { 'name': 'dhcp4' }, + { 'name': 'dhcp4_start' }, + { 'name': 'dhcp4_end' } + ]) + @Authenticator + def put(self, vni): + """ + Update details of network {vni} + Note: A network's type cannot be changed; the network must be removed and recreated as the new type + --- + tags: + - network + parameters: + - in: query + name: description + type: string + description: The description of the network + - in: query + name: domain + type: string + description: The DNS domain of the network ("managed" networks only) + - in: query + name: name_servers + type: string + description: The CSV list of DNS nameservers for network NS records ("managed" networks only) + - in: query + name: ip4_network + type: string + description: The IPv4 network subnet of the network in CIDR format; IPv4 disabled if unspecified ("managed" networks only) + - in: query + name: ip4_gateway + type: string + description: The IPv4 default gateway address of the network ("managed" networks only) + - in: query + name: dhcp4 + type: boolean + description: Whether to enable DHCPv4 for the network ("managed" networks only) + - in: query + name: dhcp4_start + type: string + description: The DHCPv4 pool start address of the network ("managed" networks only) + - in: query + name: dhcp4_end + type: string + description: The DHCPv4 pool end address of the network ("managed" networks only) + - in: query + name: ip6_network + type: string + description: The IPv6 network subnet of the network in CIDR format; IPv6 disabled if unspecified; DHCPv6 is always used in IPv6 managed networks ("managed" networks only) + - in: query + name: ip6_gateway + type: string + description: The IPv6 default gateway address of the network ("managed" networks only) + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.net_modify( + reqargs.get('description', None), + reqargs.get('domain', None), + reqargs.get('name_servers', None).split(','), + reqargs.get('ip4_network', None), + reqargs.get('ip4_gateway', None), + reqargs.get('ip6_network', None), + reqargs.get('ip6_gateway', None), + reqargs.get('dhcp4_flag', None), + reqargs.get('dhcp4_start', None), + reqargs.get('dhcp4_end', None), + ) - # Get network name servers - if 'name_server' in flask.request.values: - name_servers = flask.request.values.getlist('name_server') - else: - name_servers = None + @Authenticator + def delete(self, vni): + """ + Remove network {vni} + --- + tags: + - network + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.net_remove(vni) +api.add_resource(API_Network_Element, '/network/') - # Get ipv4 network - if 'ip4_network' in flask.request.values: - ip4_network = flask.request.values['ip4_network'] - else: - ip4_network = None +# /network//lease +class API_Network_Lease_Root(Resource): + @RequestParser([ + { 'name': 'limit' }, + { 'name': 'static' } + ]) + @Authenticator + def get(self, vni, reqargs): + """ + Return a list of DHCP leases in network {vni} + --- + tags: + - network + definitions: + - schema: + type: object + id: lease + properties: + hostname: + type: string + description: The (short) hostname of the lease + ip4_address: + type: string + description: The IPv4 address of the lease + mac_address: + type: string + description: The MAC address of the lease + timestamp: + type: integer + description: The UNIX timestamp of the lease creation + parameters: + - in: query + name: limit + type: string + required: false + description: A MAC address search limit; fuzzy by default, use ^/$ to force exact matches + - in: query + name: static + type: boolean + required: false + default: false + description: Whether to show only static leases + responses: + 200: + description: OK + schema: + type: array + items: + $ref: '#/definitions/lease' + 400: + description: Bad request + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.net_dhcp_list( + vni, + reqargs.get('limit', None), + reqargs.get('static', False) + ) - # Get ipv4 gateway - if 'ip4_gateway' in flask.request.values: - ip4_gateway = flask.request.values['ip4_gateway'] - else: - ip4_gateway = None + @RequestParser([ + { 'name': 'macaddress', 'required': True }, + { 'name': 'ipaddress', 'required': True }, + { 'name': 'hostname' } + ]) + @Authenticator + def post(self, vni, reqargs): + """ + Create a new static DHCP lease in network {vni} + --- + tags: + - network + parameters: + - in: query + name: macaddress + type: string + required: false + description: A MAC address for the lease + - in: query + name: ipaddress + type: string + required: false + description: An IPv4 address for the lease + - in: query + name: hostname + type: string + required: false + description: An optional hostname for the lease + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.net_dhcp_add( + vni, + reqargs.get('ipaddress', None), + reqargs.get('macaddres', None), + reqargs.get('hostname', None) + ) +api.add_resource(API_Network_Lease_Root, '/network//lease') - # Get ipv6 network - if 'ip6_network' in flask.request.values: - ip6_network = flask.request.values['ip6_network'] - else: - ip6_network = None +# /network//lease/{mac} +class API_Network_Lease_Element(Resource): + @Authenticator + def get(self, vni, mac): + """ + Return information about DHCP lease {mac} in network {vni} + --- + tags: + - network + definitions: + - schema: + type: object + id: lease + properties: + hostname: + type: string + description: The (short) hostname of the lease + ip4_address: + type: string + description: The IPv4 address of the lease + mac_address: + type: string + description: The MAC address of the lease + timestamp: + type: integer + description: The UNIX timestamp of the lease creation + responses: + 200: + description: OK + schema: + type: array + items: + $ref: '#/definitions/lease' + 400: + description: Bad request + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.net_dhcp_list( + network, + lease, + False + ) - # Get ipv6 gateway - if 'ip6_gateway' in flask.request.values: - ip6_gateway = flask.request.values['ip6_gateway'] - else: - ip6_gateway = None + @RequestParser([ + { 'name': 'ipaddress', 'required': True }, + { 'name': 'hostname' } + ]) + @Authenticator + def post(self, vni, mac): + """ + Create a new static DHCP lease {mac} in network {vni} + --- + tags: + - network + parameters: + - in: query + name: macaddress + type: string + required: false + description: A MAC address for the lease + - in: query + name: ipaddress + type: string + required: false + description: An IPv4 address for the lease + - in: query + name: hostname + type: string + required: false + description: An optional hostname for the lease + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.net_dhcp_add( + vni, + reqargs.get('ipaddress', None), + mac, + reqargs.get('hostname', None) + ) - # Get ipv4 DHCP flag - if 'dhcp4' in flask.request.values and flask.request.values['dhcp4']: - dhcp4_flag = True - else: - dhcp4_flag = False + @Authenticator + def delete(self, vni, mac): + """ + Delete static DHCP lease {mac} + --- + tags: + - network + responses: + 200: + description: OK + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.net_dhcp_remove( + vni, + mac + ) +api.add_resource(API_Network_Lease_Element, '/network//lease/') - # Get ipv4 DHCP start - if 'dhcp4_start' in flask.request.values: - dhcp4_start = flask.request.values['dhcp4_start'] - else: - dhcp4_start = None +# /network//acl +class API_Network_ACL_Root(Resource): + @RequestParser([ + { 'name': 'limit' }, + { 'name': 'direction', 'choices': ('in', 'out'), 'helpmsg': "A valid direction must be specified" } + ]) + @Authenticator + def get(self, vni, reqargs): + """ + Return a list of ACLs in network {vni} + --- + tags: + - network + definitions: + - schema: + type: object + id: acl + properties: + description: + type: string + description: The description of the rule + direction: + type: string + description: The direction the rule applies in + order: + type: integer + description: The order of the rule in the chain + rule: + type: string + description: The NFT-format rule string + parameters: + - in: query + name: limit + type: string + required: false + description: A description search limit; fuzzy by default, use ^/$ to force exact matches + - in: query + name: direction + type: string + required: false + description: The direction of rules to display; both directions shown if unspecified + responses: + 200: + description: OK + schema: + type: array + items: + $ref: '#/definitions/acl' + 400: + description: Bad request + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.net_acl_list( + vni, + reqargs.get('limit', None), + reqargs.get('direction', None) + ) - # Get ipv4 DHCP end - if 'dhcp4_end' in flask.request.values: - dhcp4_end = flask.request.values['dhcp4_end'] - else: - dhcp4_end = None + @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': 'order' } + ]) + @Authenticator + def post(self, vni, reqargs): + """ + Create a new ACL in network {vni} + --- + tags: + - network + parameters: + - in: query + name: description + type: string + required: true + description: A whitespace-free description/name for the ACL + - in: query + name: direction + type: string + required: false + description: The direction of the ACL; defaults to "in" if unspecified + enum: + - in + - out + - in: query + name: order + type: integer + description: The order of the ACL in the chain; defaults to the end + - in: query + name: rule + type: string + required: true + description: The raw NFT firewall rule string + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.net_acl_add( + vni, + reqargs.get('direction', 'in'), + reqargs.get('description', None), + reqargs.get('rule', None), + reqargs.get('order', None) + ) +api.add_resource(API_Network_ACL_Root, '/network//acl') - return api_helper.net_add(vni, description, nettype, domain, name_servers, - ip4_network, ip4_gateway, ip6_network, ip6_gateway, - dhcp4_flag, dhcp4_start, dhcp4_end) +# /network//acl/ +class API_Network_ACL_Element(Resource): + @Authenticator + def get(self, vni, description): + """ + Return information about ACL {description} in network {vni} + --- + tags: + - network + responses: + 200: + description: OK + schema: + $ref: '#/definitions/acl' + 400: + description: Bad request + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.net_acl_list( + vni, + description, + None, + is_fuzzy=False + ) -@api.route('/api/v1/network/', methods=['GET', 'PUT', 'DELETE']) -@authenticator -def api_net_element(network): - # Same as specifying /network?limit=NETWORK - if flask.request.method == 'GET': - return api_helper.net_list(network) + @RequestParser([ + { 'name': 'rule', 'required': True, 'helpmsg': "A rule must be specified" }, + { 'name': 'direction', 'choices': ('in', 'out'), 'helpmsg': "A valid direction must be specified" }, + { 'name': 'order' } + ]) + @Authenticator + def post(self, vni, description, reqargs): + """ + Create a new ACL {description} in network {vni} + --- + tags: + - network + parameters: + - in: query + name: direction + type: string + required: false + description: The direction of the ACL; defaults to "in" if unspecified + enum: + - in + - out + - in: query + name: order + type: integer + description: The order of the ACL in the chain; defaults to the end + - in: query + name: rule + type: string + required: true + description: The raw NFT firewall rule string + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.net_acl_add( + vni, + reqargs.get('direction', 'in'), + description, + reqargs.get('rule', None), + reqargs.get('order', None) + ) - if flask.request.method == 'PUT': - # Get network description - if 'description' in flask.request.values: - description = flask.request.values['description'] - else: - description = None + @Authenticator + def delete(self, vni, description): + """ + Delete ACL {description} in network {vni} + --- + tags: + - network + responses: + 200: + description: OK + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.net_acl_remove( + vni, + description + ) +api.add_resource(API_Network_ACL_Element, '/network//acl/') - # Get network domain - if 'domain' in flask.request.values: - domain = flask.request.values['domain'] - else: - domain = None - # Get network name servers - if 'name_server' in flask.request.values: - name_servers = flask.request.values.getlist('name_server') - else: - name_servers = None - - # Get ipv4 network - if 'ip4_network' in flask.request.values: - ip4_network = flask.request.values['ip4_network'] - else: - ip4_network = None - - # Get ipv4 gateway - if 'ip4_gateway' in flask.request.values: - ip4_gateway = flask.request.values['ip4_gateway'] - else: - ip4_gateway = None - - # Get ipv6 network - if 'ip6_network' in flask.request.values: - ip6_network = flask.request.values['ip6_network'] - else: - ip6_network = None - - # Get ipv6 gateway - if 'ip6_gateway' in flask.request.values: - ip6_gateway = flask.request.values['ip6_gateway'] - else: - ip6_gateway = None - - # Get ipv4 DHCP flag - if 'dhcp4' in flask.request.values and flask.request.values['dhcp4']: - dhcp4_flag = True - else: - dhcp4_flag = False - - # Get ipv4 DHCP start - if 'dhcp4_start' in flask.request.values: - dhcp4_start = flask.request.values['dhcp4_start'] - else: - dhcp4_start = None - - # Get ipv4 DHCP end - if 'dhcp4_end' in flask.request.values: - dhcp4_end = flask.request.values['dhcp4_end'] - else: - dhcp4_end = None - - return api_helper.net_modify(network, description, domain, name_servers, - ip4_network, ip4_gateway, - ip6_network, ip6_gateway, - dhcp4_flag, dhcp4_start, dhcp4_end) - - if flask.request.method == 'DELETE': - return api_helper.net_remove(network) - -@api.route('/api/v1/network//lease', methods=['GET', 'POST']) -@authenticator -def api_net_lease_root(network): - if flask.request.method == 'GET': - # Get name limit - if 'limit' in flask.request.values: - limit = flask.request.values['limit'] - else: - limit = None - - # Get static-only flag - if 'static' in flask.request.values and flask.request.values['static']: - flag_static = True - else: - flag_static = False - - return api_helper.net_dhcp_list(network, limit. flag_static) - - if flask.request.method == 'POST': - # Get lease macaddr - if 'macaddress' in flask.request.values: - macaddress = flask.request.values['macaddress'] - else: - return flask.jsonify({"message":"ERROR: An IP address must be specified for the lease."}), 400 - # Get lease ipaddress - if 'ipaddress' in flask.request.values: - ipaddress = flask.request.values['ipaddress'] - else: - return flask.jsonify({"message":"ERROR: An IP address must be specified for the lease."}), 400 - - # Get lease hostname - if 'hostname' in flask.request.values: - hostname = flask.request.values['hostname'] - else: - hostname = None - - return api_helper.net_dhcp_add(network, ipaddress, lease, hostname) - -@api.route('/api/v1/network//lease/', methods=['GET', 'DELETE']) -@authenticator -def api_net_lease_element(network, lease): - if flask.request.method == 'GET': - # Same as specifying /network?limit=NETWORK - return api_helper.net_dhcp_list(network, lease, False) - - if flask.request.method == 'DELETE': - return api_helper.net_dhcp_remove(network, lease) - -@api.route('/api/v1/network//acl', methods=['GET', 'POST']) -@authenticator -def api_net_acl_root(network): - if flask.request.method == 'GET': - # Get name limit - if 'limit' in flask.request.values: - limit = flask.request.values['limit'] - else: - limit = None - - # Get direction limit - if 'direction' in flask.request.values: - direction = flask.request.values['direction'] - if not 'in' in direction and not 'out' in direction: - return flash.jsonify({"message":"ERROR: Direction must be either 'in' or 'out'; for both, do not specify a direction."}), 400 - else: - direction = None - - return api_helper.net_acl_list(network, limit, direction) - - if flask.request.method == 'POST': - # Get ACL description - if 'description' in flask.request.values: - description = flask.request.values['description'] - else: - return flask.jsonify({"message":"ERROR: A description must be provided."}), 400 - - # Get rule direction - if 'direction' in flask.request.values: - direction = flask.request.values['limit'] - if not 'in' in direction and not 'out' in direction: - return flask.jsonify({"message":"ERROR: Direction must be either 'in' or 'out'."}), 400 - else: - return flask.jsonify({"message":"ERROR: A direction must be specified for the ACL."}), 400 - - # Get rule data - if 'rule' in flask.request.values: - rule = flask.request.values['rule'] - else: - return flask.jsonify({"message":"ERROR: A valid NFT rule line must be specified for the ACL."}), 400 - - # Get order value - if 'order' in flask.request.values: - order = flask.request.values['order'] - else: - order = None - - return api_helper.net_acl_add(network, direction, acl, rule, order) - -@api.route('/api/v1/network//acl/', methods=['GET', 'DELETE']) -@authenticator -def api_net_acl_element(network, acl): - if flask.request.method == 'GET': - # Same as specifying /network?limit=NETWORK - return api_helper.net_acl_list(network, acl, None) - - if flask.request.method == 'DELETE': - # Get rule direction - if 'direction' in flask.request.values: - direction = flask.request.values['limit'] - if not 'in' in direction and not 'out' in direction: - return flask.jsonify({"message":"ERROR: Direction must be either 'in' or 'out'."}), 400 - else: - return flask.jsonify({"message":"ERROR: A direction must be specified for the ACL."}), 400 - - return api_helper.net_acl_remove(network, direction, acl) - -# -# Storage (Ceph) endpoints -# +########################################################## +# Client API - Storage +########################################################## # Note: The prefix `/storage` allows future potential storage subsystems. # Since Ceph is the only section not abstracted by PVC directly # (i.e. it references Ceph-specific concepts), this makes more -# sense in the long-term. -# -@api.route('/api/v1/storage', methods=['GET']) -def api_storage(): - return flask.jsonify({"message":"Manage the storage of the PVC cluster."}), 200 +# sense in the long-term.# -@api.route('/api/v1/storage/ceph', methods=['GET']) -@api.route('/api/v1/storage/ceph/status', methods=['GET']) -@authenticator -def api_ceph_status(): - return api_helper.ceph_status() +# /storage +class API_Storage_Root(Resource): + @Authenticator + def get(self): + pass +api.add_resource(API_Storage_Root, '/storage') -@api.route('/api/v1/storage/ceph/df', methods=['GET']) -@authenticator -def api_ceph_radosdf(): - return api_helper.ceph_radosdf() +# /storage/ceph +class API_Storage_Ceph_Root(Resource): + @Authenticator + def get(self): + pass +api.add_resource(API_Storage_Ceph_Root, '/storage/ceph') -@api.route('/api/v1/storage/ceph/cluster-option', methods=['POST']) -@authenticator -def api_ceph_cluster_option(): - if flask.request.method == 'POST': - # Get action - if 'action' in flask.request.values: - action = flask.request.values['action'] - if not 'set' in action and not 'unset' in action: - return flask.jsonify({"message":"ERROR: Action must be one of: set, unset"}), 400 - else: - return flask.jsonify({"message":"ERROR: An action must be specified."}), 400 - # Get option - if 'option' in flask.request.values: - option = flask.request.values['option'] - else: - return flask.jsonify({"message":"ERROR: An option must be specified."}), 400 +# /storage/ceph/status +class API_Storage_Ceph_Status(Resource): + @Authenticator + def get(self): + """ + Return status data for the PVC Ceph cluster + --- + tags: + - storage / ceph + responses: + 200: + description: OK + schema: + type: object + properties: + type: + type: string + description: The type of Ceph data returned + primary_node: + type: string + description: The curent primary node in the cluster + ceph_data: + type: string + description: The raw output data + """ + return api_helper.ceph_status() +api.add_resource(API_Storage_Ceph_Status, '/storage/ceph/status') - if action == 'set': - return api_helper.ceph_osd_set(option) - if action == 'unset': - return api_helper.ceph_osd_unset(option) +# /storage/ceph/utilization +class API_Storage_Ceph_Utilization(Resource): + @Authenticator + def get(self): + """ + Return utilization data for the PVC Ceph cluster + --- + tags: + - storage / ceph + responses: + 200: + description: OK + schema: + type: object + properties: + type: + type: string + description: The type of Ceph data returned + primary_node: + type: string + description: The curent primary node in the cluster + ceph_data: + type: string + description: The raw output data + """ + return api_helper.ceph_radosdf() +api.add_resource(API_Storage_Ceph_Utilization, '/storage/ceph/utilization') -@api.route('/api/v1/storage/ceph/osd', methods=['GET', 'POST']) -@authenticator -def api_ceph_osd_root(): - if flask.request.method == 'GET': - # Get name limit - if 'limit' in flask.request.values: - limit = flask.request.values['limit'] - else: - limit = None +# /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" }, + ]) + @Authenticator + def post(self, reqargs): + """ + Set or unset OSD options on the Ceph cluster + --- + tags: + - storage / ceph + parameters: + - in: query + name: option + type: string + required: true + description: The Ceph OSD option to act on; must be valid to "ceph osd set/unset" + - in: query + name: action + type: string + required: true + description: The action to take + enum: + - set + - unset + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + if reqargs.get('action') == 'set': + return api_helper.ceph_osd_set(reqargs.get('option')) + if reqargs.get('action') == 'unset': + return api_helper.ceph_osd_unset(reqargs.get('option')) + abort(400) +api.add_resource(API_Storage_Ceph_Option, '/storage/ceph/option') - return api_helper.ceph_osd_list(limit) +# /storage/ceph/osd +class API_Storage_Ceph_OSD_Root(Resource): + @RequestParser([ + { 'name': 'limit' }, + ]) + @Authenticator + def get(self, reqargs): + """ + TODO + """ + api_helper.ceph_osd_list( + reqargs.get('limit', None) + ) - if flask.request.method == 'POST': - # Get OSD node - if 'node' in flask.request.values: - node = flask.request.values['node'] - else: - return flask.jsonify({"message":"ERROR: A node must be specified."}), 400 + @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" }, + ]) + @Authenticator + def post(self, reqargs): + """ + Add a Ceph OSD to the cluster + Note: This task may take up to 30s to complete and return + --- + tags: + - storage / ceph + parameters: + - in: query + name: node + type: string + required: true + description: The PVC node to create the OSD on + - in: query + name: device + type: string + required: true + description: The block device (e.g. "/dev/sdb", "/dev/disk/by-path/...", etc.) to create the OSD on + - in: query + name: weight + type: number + required: true + description: The Ceph CRUSH weight for the OSD + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.ceph_osd_add( + reqargs.get('node', None), + reqargs.get('device', None), + reqargs.get('weight', None) + ) +api.add_resource(API_Storage_Ceph_OSD_Root, '/storage/ceph/osd') - # Get OSD device - if 'device' in flask.request.values: - device = flask.request.values['device'] - else: - return flask.jsonify({"message":"ERROR: A block device must be specified."}), 400 +# /storage/ceph/osd/ +class API_Storage_Ceph_OSD_Element(Resource): + @Authenticator + def get(self, osdid): + """ + TODO + """ + return api_helper.ceph_osd_list( + osdid + ) - # Get OSD weight - if 'weight' in flask.request.values: - weight = flask.request.values['weight'] - else: - return flask.jsonify({"message":"ERROR: An OSD weight must be specified."}), 400 + @RequestParser([ + { 'name': 'yes-i-really-mean-it', 'required': True, 'helpmsg': "Please confirm that yes-i-really-mean-it" } + ]) + @Authenticator + def delete(self, osdid, reqargs): + """ + Remove Ceph OSD {osdid} + Note: This task may take up to 30s to complete and return + Warning: This operation may have unintended consequences for the storage cluster; ensure the cluster can support removing the OSD before proceeding + --- + tags: + - storage / ceph + 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 + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.ceph_osd_remove( + osdid + ) +api.add_resource(API_Storage_Ceph_OSD_Element, '/storage/ceph/osd/') - return api_helper.ceph_osd_add(node, device, weight) +# /storage/ceph/osd//state +class API_Storage_Ceph_OSD_State(Resource): + @Authenticator + def get(self, osdid): + """ + TODO + """ + return api_helper.ceph_osd_state( + osdid + ) -@api.route('/api/v1/storage/ceph/osd/', methods=['GET', 'DELETE']) -@authenticator -def api_ceph_osd_element(osd): - if flask.request.method == 'GET': - # Same as specifying /osd?limit=OSD - return api_helper.ceph_osd_list(osd) + @RequestParser([ + { 'name': 'state', 'choices': ('in', 'out'), 'required': True, 'helpmsg': "A valid state must be specified" }, + ]) + @Authenticator + def post(self, osdid, reqargs): + """ + TODO + """ + if reqargs.get('state', None) == 'in': + return api_helper.ceph_osd_in( + osdid + ) + if reqargs.get('state', None) == 'out': + return api_helper.ceph_osd_out( + osdid + ) + abort(400) +api.add_resource(API_Storage_Ceph_OSD_State, '/storage/ceph/osd//state') - if flask.request.method == 'DELETE': - # Verify yes-i-really-mean-it flag - if not 'yes_i_really_mean_it' in flask.request.values: - return flask.jsonify({"message":"ERROR: This command can have unintended consequences and should not be automated; if you're sure you know what you're doing, resend with the argument 'yes_i_really_mean_it'."}), 400 +# /storage/ceph/pool +class API_Storage_Ceph_Pool_Root(Resource): + @RequestParser([ + { 'name': 'limit' } + ]) + @Authenticator + def get(self, reqargs): + """ + Return a list of pools in the cluster + --- + tags: + - storage / ceph + definitions: + - schema: + type: object + id: pool + properties: + name: + type: string + description: The name of the pool + stats: + type: object + properties: + id: + type: integer + description: The Ceph pool ID + free_bytes: + type: integer + description: The total free space (in bytes) + used_bytes: + type: integer + description: The total used space (in bytes) + used_percent: + type: number + description: The ratio of used space to free space + num_objects: + type: integer + description: The number of Ceph objects before replication + num_object_clones: + type: integer + description: The total number of cloned Ceph objects + num_object_copies: + type: integer + description: The total number of Ceph objects after replication + num_objects_missing_on_primary: + type: integer + description: The total number of missing-on-primary Ceph objects + num_objects_unfound: + type: integer + description: The total number of unfound Ceph objects + num_objects_degraded: + type: integer + description: The total number of degraded Ceph objects + read_ops: + type: integer + description: The total read operations on the pool (pool-lifetime) + read_bytes: + type: integer + description: The total read bytes on the pool (pool-lifetime) + write_ops: + type: integer + description: The total write operations on the pool (pool-lifetime) + write_bytes: + type: integer + description: The total write bytes on the pool (pool-lifetime) + parameters: + - in: query + name: limit + type: string + required: false + description: A pool name search limit; fuzzy by default, use ^/$ to force exact matches + responses: + 200: + description: OK + schema: + type: array + items: + $ref: '#/definitions/pool' + """ + return api_helper.ceph_pool_list( + reqargs.get('limit', None) + ) - return api_helper.ceph_osd_remove(osd) + @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" } + ]) + @Authenticator + def post(self, reqargs): + """ + Create a new Ceph pool + --- + tags: + - storage / ceph + parameters: + - in: query + name: pool + type: string + required: true + description: The name of the pool + - in: query + name: pgs + type: integer + required: true + description: The number of placement groups (PGs) for the pool + - in: query + name: replcfg + type: string + required: true + description: The replication configuration (e.g. "copies=3,mincopies=2") for the pool + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + api_helper.ceph_pool_add( + reqargs.get('pool', None), + reqargs.get('pgs', None), + reqargs.get('replcfg', None) + ) + pass +api.add_resource(API_Storage_Ceph_Pool_Root, '/storage/ceph/pool') -@api.route('/api/v1/storage/ceph/osd//state', methods=['GET', 'POST']) -@authenticator -def api_ceph_osd_state(osd): - if flask.request.method == 'GET': - return api_helper.ceph_osd_state(osd) +# /storage/ceph/pool/ +class API_Storage_Ceph_Pool_Element(Resource): + @Authenticator + def get(self, pool): + """ + Return information about {pool} + --- + tags: + - storage / ceph + responses: + 200: + description: OK + schema: + $ref: '#/definitions/pool' + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper,ceph_pool_list( + pool, + is_fuzzy=False + ) - if flask.request.method == 'POST': - if 'state' in flask.request.values: - state = flask.request.values['state'] - if not 'in' in state and not 'out' in state: - return flask.jsonify({"message":"ERROR: State must be one of: in, out."}), 400 - else: - return flask.jsonify({"message":"ERROR: A state must be specified."}), 400 + @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" } + ]) + @Authenticator + def post(self, pool): + """ + Create a new Ceph pool {pool} + --- + tags: + - storage / ceph + parameters: + - in: query + name: pgs + type: integer + required: true + description: The number of placement groups (PGs) for the pool + - in: query + name: replcfg + type: string + required: true + description: The replication configuration (e.g. "copies=3,mincopies=2") for the pool + responses: + 200: + description: OK + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + api_helper.ceph_pool_add( + pool, + reqargs.get('pgs', None), + reqargs.get('replcfg', None) + ) - if state == 'in': - return api_helper.ceph_osd_in(osd) - if state == 'out': - return api_helper.ceph_osd_out(osd) + @RequestParser([ + { 'name': 'yes-i-really-mean-it', 'required': True, 'helpmsg': "Please confirm that yes-i-really-mean-it" } + ]) + @Authenticator + def delete(self, pool, reqargs): + """ + Remove Ceph pool {pool} + Note: This task may take up to 30s to complete and return + --- + tags: + - storage / ceph + 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 + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.ceph_pool_remove( + pool + ) +api.add_resource(API_Storage_Ceph_Pool_Element, '/storage/ceph/pool/') -@api.route('/api/v1/storage/ceph/pool', methods=['GET', 'POST']) -@authenticator -def api_ceph_pool_root(): - if flask.request.method == 'GET': - # Get name limit - if 'limit' in flask.request.values: - limit = flask.request.values['limit'] - else: - limit = None +# /storage/ceph/volume +class API_Storage_Ceph_Volume_Root(Resource): + @RequestParser([ + { 'name': 'limit' }, + { 'name': 'pool' } + ]) + @Authenticator + def get(self, reqargs): + """ + Return a list of volumes in the cluster + --- + tags: + - storage / ceph + definitions: + - schema: + type: object + id: volume + properties: + name: + type: string + description: The name of the volume + pool: + type: string + description: The name of the pool containing the volume + stats: + type: object + properties: + name: + type: string + description: The name of the volume + id: + type: string + description: The Ceph volume ID + size: + type: string + description: The size of the volume (human-readable values) + objects: + type: integer + description: The number of Ceph objects making up the volume + order: + type: integer + description: The Ceph volume order ID + object_size: + type: integer + description: The size of each object in bytes + snapshot_count: + type: integer + description: The number of snapshots of the volume + block_name_prefix: + type: string + description: The Ceph-internal block name prefix + format: + type: integer + description: The Ceph RBD volume format + features: + type: array + items: + type: string + description: The Ceph RBD feature + op_features: + type: array + items: + type: string + description: The Ceph RBD operational features + flags: + type: array + items: + type: string + description: The Ceph RBD volume flags + create_timestamp: + type: string + description: The volume creation timestamp + access_timestamp: + type: string + description: The volume access timestamp + modify_timestamp: + type: string + description: The volume modification timestamp + parameters: + - in: query + name: limit + type: string + required: false + description: A volume name search limit; fuzzy by default, use ^/$ to force exact matches + - in: query + name: pool + type: string + required: false + description: A pool to limit the search to + responses: + 200: + description: OK + schema: + type: array + items: + $ref: '#/definitions/volume' + """ + return api_helper.ceph_volume_list( + reqargs.get('pool', None), + reqargs.get('limit', None) + ) - return api_helper.ceph_pool_list(limit) + @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" } + ]) + @Authenticator + def post(self, reqargs): + """ + Create a new Ceph volume + --- + tags: + - storage / ceph + parameters: + - in: query + name: volume + type: string + required: true + description: The name of the volume + - in: query + name: pool + type: integer + required: true + description: The name of the pool to contain the volume + - in: query + name: size + type: string + required: true + description: The volume size in bytes (or with a metric suffix, i.e. k/M/G/T) + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.ceph_volume_add( + reqargs.get('pool', None), + reqargs.get('volume', None), + reqargs.get('size', None) + ) +api.add_resource(API_Storage_Ceph_Volume_Root, '/storage/ceph/volume') - if flask.request.method == 'POST': - # Get pool name - if 'pool' in flask.request.values: - pool = flask.request.values['pool'] - else: - return flask.jsonify({"message":"ERROR: A pool name must be specified."}), 400 +# /storage/ceph/volume// +class API_Storage_Ceph_Volume_Element(Resource): + @Authenticator + def get(self, pool, volume): + """ + Return information about volume {volume} in pool {pool} + --- + tags: + - storage / ceph + responses: + 200: + description: OK + schema: + $ref: '#/definitions/volume' + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.ceph_volume_list( + pool, + volume, + is_fuzzy=False + ) - # Get placement groups - if 'pgs' in flask.request.values: - pgs = flask.request.values['pgs'] - else: - # We default to a very small number; DOCUMENT THIS - pgs = 128 + @RequestParser([ + { 'name': 'size', 'required': True, 'helpmsg': "A volume size in bytes (or with k/M/G/T suffix) must be specified" } + ]) + @Authenticator + def post(self, pool, volume, reqargs): + """ + Create a new Ceph volume {volume} in pool {pool} + --- + tags: + - storage / ceph + parameters: + - in: query + name: size + type: string + required: true + description: The volume size in bytes (or with a metric suffix, i.e. k/M/G/T) + responses: + 200: + description: OK + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.ceph_volume_add( + pool, + volume, + reqargs.get('size', None) + ) - # Get replication configuration - if 'replcfg' in flask.request.values: - replcfg = flask.request.values['replcfg'] - else: - # We default to copies=3,mincopies=2 - replcfg = 'copies=3,mincopies=2' + @RequestParser([ + { 'name': 'size' }, + { 'name': 'new_name' } + ]) + @Authenticator + def put(self, pool, volume, reqargs): + """ + Update the size or name of Ceph volume {volume} in pool {pool} + --- + tags: + - storage / ceph + parameters: + - in: query + name: size + type: string + required: false + description: The new volume size in bytes (or with a metric suffix, i.e. k/M/G/T); must be greater than the previous size (shrinking not supported) + - in: query + name: new_name + type: string + required: false + description: The new volume name + responses: + 200: + description: OK + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + if reqargs.get('size', None) and reqargs.get('new_name', None): + return { "message": "Can only perform one modification at once" }, 400 - return api_helper.ceph_pool_add(pool, pgs) + if reqargs.get('size', None): + return api_helper.ceph_volume_resize( + pool, + volume, + reqargs.get('size') + ) + if reqargs.get('new_name', None): + return api_helper.ceph_volume_rename( + pool, + volume, + reqargs.get('new_name') + ) + return { "message": "At least one modification must be specified" }, 400 -@api.route('/api/v1/storage/ceph/pool/', methods=['GET', 'DELETE']) -@authenticator -def api_ceph_pool_element(pool): - if flask.request.method == 'GET': - # Same as specifying /pool?limit=POOL - return api_helper.ceph_pool_list(pool) + @Authenticator + def delete(self, pool, volume): + """ + Remove Ceph volume {volume} from pool {pool} + Note: This task may take up to 30s to complete and return depending on the size of the volume + --- + tags: + - storage / ceph + responses: + 200: + description: OK + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.ceph_volume_remove( + pool, + volume + ) +api.add_resource(API_Storage_Ceph_Volume_Element, '/storage/ceph/volume//') - if flask.request.method == 'DELETE': - # Verify yes-i-really-mean-it flag - if not 'yes_i_really_mean_it' in flask.request.values: - return flask.jsonify({"message":"ERROR: This command can have unintended consequences and should not be automated; if you're sure you know what you're doing, resend with the argument 'yes_i_really_mean_it'."}), 400 +# /storage/ceph/volume///clone +class API_Storage_Ceph_Volume_Element_Clone(Resource): + @RequestParser([ + { 'name': 'new_volume', 'required': True, 'helpmsg': "A new volume name must be specified" } + ]) + @Authenticator + def post(self, pool, volume, reqargs): + """ + Clone Ceph volume {volume} in pool {pool} + --- + tags: + - storage / ceph + parameters: + - in: query + name: new_volume + type: string + required: true + description: The name of the new cloned volume + responses: + 200: + description: OK + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.ceph_volume_clone( + pool, + reqargs.get('new_volume', None), + volume + ) +api.add_resource(API_Storage_Ceph_Volume_Element_Clone, '/storage/ceph/volume///clone') - return api_helper.ceph_pool_remove(pool) +# /storage/ceph/snapshot +class API_Storage_Ceph_Snapshot_Root(Resource): + @RequestParser([ + { 'name': 'pool' }, + { 'name': 'volume' }, + { 'name': 'limit' }, + ]) + @Authenticator + def get(self, reqargs): + """ + Return a list of snapshots in the cluster + --- + tags: + - storage / ceph + definitions: + - schema: + type: object + id: snapshot + properties: + snapshot: + type: string + description: The name of the snapshot + volume: + type: string + description: The name of the volume + pool: + type: string + description: The name of the pool + parameters: + - in: query + name: limit + type: string + required: false + description: A volume name search limit; fuzzy by default, use ^/$ to force exact matches + - in: query + name: pool + type: string + required: false + description: A pool to limit the search to + - in: query + name: volume + type: string + required: false + description: A volume to limit the search to + responses: + 200: + description: OK + schema: + type: array + items: + $ref: '#/definitions/snapshot' + """ + return api_helper.ceph_volume_snapshot_list( + reqargs.get('pool', None), + reqargs.get('volume', None), + reqargs.get('limit', None) + ) -@api.route('/api/v1/storage/ceph/volume', methods=['GET', 'POST']) -@authenticator -def api_ceph_volume_root(): - if flask.request.method == 'GET': - # Get pool limit - if 'pool' in flask.request.values: - pool = flask.request.values['pool'] - else: - pool = None + @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" } + ]) + @Authenticator + def post(self, reqargs): + """ + Create a new Ceph snapshot + --- + tags: + - storage / ceph + parameters: + - in: query + name: snapshot + type: string + required: true + description: The name of the snapshot + - in: query + name: volume + type: string + required: true + description: The name of the volume + - in: query + name: pool + type: integer + required: true + description: The name of the pool + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.ceph_volume_snapshot_add( + reqargs.get('pool', None), + reqargs.get('volume', None), + reqargs.get('snapshot', None) + ) +api.add_resource(API_Storage_Ceph_Snapshot_Root, '/storage/ceph/snapshot') - # Get name limit - if 'limit' in flask.request.values: - limit = flask.request.values['limit'] - else: - limit = None +# /storage/ceph/snapshot/// +class API_Storage_Ceph_Snapshot_Element(Resource): + @Authenticator + def get(self, pool, volume, snapshot): + """ + Return information about snapshot {snapshot} of volume {volume} in pool {pool} + --- + tags: + - storage / ceph + responses: + 200: + description: OK + schema: + $ref: '#/definitions/snapshot' + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.ceph_volume_snapshot_list( + pool, + volume, + snapshot, + is_fuzzy=False + ) - return api_helper.ceph_volume_list(pool, limit) + @Authenticator + def post(self, pool, volume, snapshot): + """ + Create a new Ceph snapshot {snapshot} of volume {volume} in pool {pool} + --- + tags: + - storage / ceph + parameters: + - in: query + name: snapshot + type: string + required: true + description: The name of the snapshot + - in: query + name: volume + type: string + required: true + description: The name of the volume + - in: query + name: pool + type: integer + required: true + description: The name of the pool + responses: + 200: + description: OK + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.ceph_volume_snapshot_add( + pool, + volume, + snapshot + ) - if flask.request.method == 'POST': - # Get volume name - if 'volume' in flask.request.values: - volume = flask.request.values['volume'] - else: - return flask.jsonify({"message":"ERROR: A volume name must be specified."}), 400 + @RequestParser([ + { 'name': 'new_name', 'required': True, 'helpmsg': "A new name must be specified" } + ]) + @Authenticator + def put(self, pool, volume, snapshot, reqargs): + """ + Update the name of Ceph snapshot {snapshot} of volume {volume} in pool {pool} + --- + tags: + - storage / ceph + parameters: + - in: query + name: new_name + type: string + required: false + description: The new snaoshot name + responses: + 200: + description: OK + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + return api_helper.ceph_volume_snapshot_rename( + pool, + volume, + snapshot, + reqargs.get('new_name', None) + ) - # Get volume pool - if 'pool' in flask.request.values: - pool = flask.request.values['pool'] - else: - return flask.jsonify({"message":"ERROR: A pool name must be spcified."}), 400 + @Authenticator + def delete(self, pool, volume, snapshot): + """ + Remove Ceph snapshot {snapshot} of volume {volume} from pool {pool} + Note: This task may take up to 30s to complete and return depending on the size of the snapshot + --- + tags: + - storage / ceph + responses: + 200: + description: OK + schema: + type: object + id: Message + 404: + description: Not found + schema: + type: object + id: Message + """ + return api_helper.ceph_volume_snapshot_remove( + pool, + volume, + snapshot + ) +api.add_resource(API_Storage_Ceph_Snapshot_Element, '/storage/ceph/snapshot///') - # Get source_volume - if 'source_volume' in flask.request.values: - source_volume = flask.request.values['source_volume'] - else: - source_volume = None - - # Get volume size - if 'size' in flask.request.values: - size = flask.request.values['size'] - elif source_volume: - # We ignore size if we're cloning a volume - size = None - else: - return flask.jsonify({"message":"ERROR: A volume size in bytes (or with an M/G/T suffix) must be specified."}), 400 - - if source_volume: - return api_helper.ceph_volume_clone(pool, volume, source_volume) - else: - return api_helper.ceph_volume_add(pool, volume, size) - -@api.route('/api/v1/storage/ceph/volume//', methods=['GET', 'PUT', 'DELETE']) -@authenticator -def api_ceph_volume_element(pool, volume): - if flask.request.method == 'GET': - # Same as specifying /volume?limit=VOLUME - return api_helper.ceph_volume_list(pool, volume) - - if flask.request.method == 'PUT': - if 'size' in flask.request.values: - size = flask.request.values['size'] - - if 'name' in flask.request.values: - name = flask.request.values['name'] - - if size and not name: - return api_helper.ceph_volume_resize(pool, volume, size) - - if name and not size: - return api_helper.ceph_volume_rename(pool, volume, name) - - return flask.jsonify({"message":"ERROR: No name or size specified, or both specified; not changing anything."}), 400 - - if flask.request.method == 'DELETE': - return api_helper.ceph_volume_remove(pool, volume) - -@api.route('/api/v1/storage/ceph/volume/snapshot', methods=['GET', 'POST']) -@authenticator -def api_ceph_volume_snapshot_root(): - if flask.request.method == 'GET': - # Get pool limit - if 'pool' in flask.request.values: - pool = flask.request.values['pool'] - else: - pool = None - - # Get volume limit - if 'volume' in flask.request.values: - volume = flask.request.values['volume'] - else: - volume = None - - # Get name limit - if 'limit' in flask.request.values: - limit = flask.request.values['limit'] - else: - limit = None - - return api_helper.ceph_volume_snapshot_list(pool, volume, limit) - - if flask.request.method == 'POST': - # Get snapshot name - if 'snapshot' in flask.request.values: - snapshot = flask.request.values['snapshot'] - else: - return flask.jsonify({"message":"ERROR: A snapshot name must be specified."}), 400 - - # Get volume name - if 'volume' in flask.request.values: - volume = flask.request.values['volume'] - else: - return flask.jsonify({"message":"ERROR: A volume name must be specified."}), 400 - - # Get volume pool - if 'pool' in flask.request.values: - pool = flask.request.values['pool'] - else: - return flask.jsonify({"message":"ERROR: A pool name must be spcified."}), 400 - - return api_helper.ceph_volume_snapshot_add(pool, volume, snapshot) - - -@api.route('/api/v1/storage/ceph/volume/snapshot///', methods=['GET', 'PUT', 'DELETE']) -@authenticator -def api_ceph_volume_snapshot_element(pool, volume, snapshot): - if flask.request.method == 'GET': - # Same as specifying /snapshot?limit=VOLUME - return api_helper.ceph_volume_snapshot_list(pool, volume, snapshot) - - if flask.request.method == 'PUT': - if 'name' in flask.request.values: - name = flask.request.values['name'] - else: - return flask.jsonify({"message":"ERROR: A new name must be specified."}), 400 - - return api_helper.ceph_volume_snapshot_rename(pool, volume, snapshot, name) - - if flask.request.method == 'DELETE': - return api_helper.ceph_volume_snapshot_remove(pool, volume, snapshot) ########################################################## # Provisioner API ########################################################## -# -# Template endpoints -# -@api.route('/api/v1/provisioner/template', methods=['GET']) -@authenticator -def api_template_root(): - """ - /template - Manage provisioning templates for VM creation. +# /provisioner +class API_Provisioner_Root(Resource): + @Authenticator + def get(self): + """ + Unused endpoint + """ + abort(404) +api.add_resource(API_Provisioner_Root, '/provisioner') - GET: List all templates in the provisioning system. - ?limit: Specify a limit to queries. Fuzzy by default; use ^ and $ to force exact matches. - """ - # Get name limit - if 'limit' in flask.request.values: - limit = flask.request.values['limit'] - else: - limit = None +# /provisioner/template +class API_Provisioner_Template_Root(Resource): + @RequestParser([ + { 'name': 'limit' } + ]) + @Authenticator + def get(self, reqargs): + """ + Return a list of all templates + --- + tags: + - provisioner / template + definitions: + - schema: + type: object + id: all-templates + properties: + system-templates: + type: array + items: + $ref: '#/definitions/system-template' + network-templates: + type: array + items: + $ref: '#/definitions/network-template' + storage-templates: + type: array + items: + $ref: '#/definitions/storage-template' + userdata-templates: + type: array + items: + $ref: '#/definitions/userdata-template' + parameters: + - in: query + name: limit + type: string + required: false + description: A template name search limit; fuzzy by default, use ^/$ to force exact matches + responses: + 200: + description: OK + schema: + $ref: '#/definitions/all-templates' + """ + return api_provisioner.template_list( + reqargs.get('limit', None) + ) +api.add_resource(API_Provisioner_Template_Root, '/provisioner/template') - return flask.jsonify(api_provisioner.template_list(limit)), 200 +# /provisioner/template/system +class API_Provisioner_Template_System_Root(Resource): + @RequestParser([ + { 'name': 'limit' } + ]) + @Authenticator + def get(self, reqargs): + """ + Return a list of system templates + --- + tags: + - provisioner / template + definitions: + - schema: + type: object + id: system-template + properties: + id: + type: integer + description: Internal provisioner template ID + name: + type: string + description: Template name + vcpu_count: + type: integer + description: vCPU count for VM + vram_mb: + type: integer + description: vRAM size in MB for VM + serial: + type: boolean + description: Whether to enable serial console for VM + vnc: + type: boolean + description: Whether to enable VNC console for VM + vnc_bind: + type: string + description: VNC bind address when VNC console is enabled + node_limit: + type: string + description: CSV list of node(s) to limit VM assignment to + node_selector: + type: string + description: Selector to use for VM node assignment on migration/move + node_autostart: + type: boolean + description: Whether to start VM with node ready state (one-time) + parameters: + - in: query + name: limit + type: string + required: false + description: A template name search limit; fuzzy by default, use ^/$ to force exact matches + responses: + 200: + description: OK + schema: + type: list + items: + $ref: '#/definitions/system-template' + """ + return api_provisioner.list_template_system( + reqargs.get('limit', None) + ) -@api.route('/api/v1/provisioner/template/system', methods=['GET', 'POST']) -@authenticator -def api_template_system_root(): - """ - /template/system - Manage system provisioning templates for VM creation. - - GET: List all system templates in the provisioning system. - ?limit: Specify a limit to queries. Fuzzy by default; use ^ and $ to force exact matches. - * type: text - * optional: true - * requires: N/A - - POST: Add new system template. - ?name: The name of the template. - * type: text - * optional: false - * requires: N/A - ?vcpus: The number of VCPUs. - * type: integer - * optional: false - * requires: N/A - ?vram: The amount of RAM in MB. - * type: integer, Megabytes (MB) - * optional: false - * requires: N/A - ?serial: Enable serial console. - * type: boolean - * optional: false - * requires: N/A - ?vnc: True/False, enable VNC console. - * type: boolean - * optional: false - * requires: N/A - ?vnc_bind: Address to bind VNC to. - * default: '127.0.0.1' - * type: IP Address (or '0.0.0.0' wildcard) - * optional: true - * requires: vnc=True - ?node_limit: CSV list of node(s) to limit VM operation to - * type: CSV of valid PVC nodes - * optional: true - * requires: N/A - ?node_selector: Selector to use for node migrations after initial provisioning - * type: Valid PVC node selector - * optional: true - * requires: N/A - ?start_with_node: Whether to start limited node with the parent node - * default: false - * type: boolean - * optional: true - * requires: N/A - """ - if flask.request.method == 'GET': - # Get name limit - if 'limit' in flask.request.values: - limit = flask.request.values['limit'] - else: - limit = None - - return flask.jsonify(api_provisioner.list_template_system(limit)), 200 - - if flask.request.method == 'POST': - # Get name data - if 'name' in flask.request.values: - name = flask.request.values['name'] - else: - return flask.jsonify({"message": "A name must be specified."}), 400 - - # Get vcpus data - if 'vcpus' in flask.request.values: - try: - vcpu_count = int(flask.request.values['vcpus']) - except: - return flask.jsonify({"message": "A vcpus value must be an integer."}), 400 - else: - return flask.jsonify({"message": "A vcpus value must be specified."}), 400 - - # Get vram data - if 'vram' in flask.request.values: - try: - vram_mb = int(flask.request.values['vram']) - except: - return flask.jsonify({"message": "A vram integer value in Megabytes must be specified."}), 400 - else: - return flask.jsonify({"message": "A vram integer value in Megabytes must be specified."}), 400 - - # Get serial configuration - if 'serial' in flask.request.values and bool(distutils.util.strtobool(flask.request.values['serial'])): + @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': 'vnc_bind' }, + { 'name': 'node_limit' }, + { 'name': 'node_selector' }, + { 'name': 'node_autostart' } + ]) + @Authenticator + def post(self): + """ + Create a new system template + --- + tags: + - provisioner / template + parameters: + - in: query + name: name + type: string + required: true + description: Template name + - in: query + name: vcpus + type: integer + required: true + description: vCPU count for VM + - in: query + name: vram + type: integer + required: true + description: vRAM size in MB for VM + - in: query + name: serial + type: boolean + required: true + description: Whether to enable serial console for VM + - in: query + name: vnc + type: boolean + required: true + description: Whether to enable VNC console for VM + - in: query + name: vnc_bind + type: string + required: false + description: VNC bind address when VNC console is enabled + - in: query + name: node_limit + type: string + required: false + description: CSV list of node(s) to limit VM assignment to + - in: query + name: node_selector + type: string + required: false + description: Selector to use for VM node assignment on migration/move + - in: query + name: node_autostart + type: boolean + required: false + description: Whether to start VM with node ready state (one-time) + responses: + 200: + description: OK + schema: + type: object + id: Message + 400: + description: Bad request + schema: + type: object + id: Message + """ + # Validate arguments + if not isinstance(reqargs.get('vcpus'), int): + return { "message": "A vcpus value must be an integer" }, 400 + if not isinstance(reqargs.get('vram'), int): + return { "message": "A vram value must be an integer" }, 400 + # Cast boolean arguments + if bool(strtobool(reqargs.get('serial'))): serial = True else: serial = False - - # Get VNC configuration - if 'vnc' in flask.request.values and bool(distutils.util.strtobool(flask.request.values['vnc'])): + if bool(strtobool(reqargs.get('vnc'))): vnc = True - - if 'vnc_bind' in flask.request.values: - vnc_bind = flask.request.values['vnc_bind_address'] - else: - vnc_bind = None + vnc_bind = reqargs.get('vnc_bind', None) else: vnc = False vnc_bind = None - - # Get metadata - if 'node_limit' in flask.request.values: - node_limit = flask.request.values['node_limit'] + if bool(strtobool(reqargs.get('node_autostart'))): + node_autostart = True else: - node_limit = None + node_autostart = False - if 'node_selector' in flask.request.values: - node_selector = flask.request.values['node_selector'] - else: - node_selector = None + return api_provisioner.create_template_system( + reqargs.get('name'), + reqargs.get('vcpus'), + reqargs.get('vram'), + serial, + vnc, + vnc_bind, + reqargs.get('node_limit', None), + reqargs.get('node_selector', None), + node_autostart + ) +api.add_resource(API_Provisioner_Template_System_Root, '/provisioner/template/system') - if 'start_with_node' in flask.request.values and bool(distutils.util.strtobool(flask.request.values['start_with_node'])): - start_with_node = True - else: - start_with_node = False +# /provisioner/template/system/