Add basic api

This commit is contained in:
dave 2016-12-26 17:14:28 -08:00
parent 515d124316
commit 3a5f4ace3b
9 changed files with 420 additions and 33 deletions

View File

@ -1,6 +1,6 @@
{ {
"id": "banutoo", "machine_id": "banutoo",
"type": "q", "machine_type": "q",
"spec": { "spec": {
"options": { "options": {
"autostart": true, "autostart": true,

View File

@ -1,6 +1,6 @@
{ {
"id": "banutoo2", "machine_id": "banutoo2",
"type": "q", "machine_type": "q",
"spec": { "spec": {
"options": { "options": {
"autostart": true, "autostart": true,

View File

@ -10,7 +10,7 @@ setup(name='zhypervisor',
url='http://gitlab.xmopx.net/dave/zhypervisor', url='http://gitlab.xmopx.net/dave/zhypervisor',
author='dpedu', author='dpedu',
author_email='dave@davepedu.com', author_email='dave@davepedu.com',
packages=['zhypervisor', 'zhypervisor.clients', 'zhypervisor.tools'], packages=['zhypervisor', 'zhypervisor.clients', 'zhypervisor.api', 'zhypervisor.tools'],
entry_points={'console_scripts': ['zd = zhypervisor.daemon:main', entry_points={'console_scripts': ['zd = zhypervisor.daemon:main',
'zd_ifup = zhypervisor.tools.ifup:main']}, 'zd_ifup = zhypervisor.tools.ifup:main']},
zip_safe=False) zip_safe=False)

239
zhypervisor/api/api.py Normal file
View File

@ -0,0 +1,239 @@
import cherrypy
import logging
import json
import subprocess
from threading import Thread
class Mountable(object):
"""
Macro for encapsulating a component's config and methods into one object.
:param conf: cherrypy config dict for use when mounting this component
"""
def __init__(self, conf=None):
self.conf = conf if conf else {'/': {}}
def mount(self, path):
"""
Mount this component into the cherrypy tree
:param path: where to mount it e.g. /v1
:return: self
"""
cherrypy.tree.mount(self, path, self.conf)
return self
class ZApi(object):
def __init__(self, master):
"""
Main component of the API service. Inits and assembles the various classes. Provides .run() and .stop() to
control it.
:param master: parent BastionController reference.
"""
self.master = master
self.app_v1 = ZApiV1(self).mount('/api/v1')
# self.app_root = BSApiRoot(self).mount('/api')
# self.ui = Mountable(conf={'/': {
# 'tools.staticdir.on': True,
# 'tools.staticdir.dir': os.getcwd() + '/ui/build', # TODO don't hardcode
# 'tools.staticdir.index': 'index.html'}}).mount('/ui')
cherrypy.config.update({
'sessionFilter.on': True,
'tools.sessions.on': True,
'tools.sessions.locking': 'explicit',
'tools.sessions.timeout': 525600,
'request.show_tracebacks': True,
'server.socket_port': 3000, # TODO configurable port
'server.thread_pool': 25,
'server.socket_host': '0.0.0.0',
'server.show_tracebacks': True,
'server.socket_timeout': 5,
'log.screen': False,
'engine.autoreload.on': False
})
def run(self):
cherrypy.engine.start()
cherrypy.engine.block()
logging.info("API has shut down")
def stop(self):
cherrypy.engine.exit()
logging.info("API shutting down...")
class ZApiV1(Mountable):
"""
Provides the /v1/ api.
"""
def __init__(self, root):
super().__init__(conf={
"/machine": {'request.dispatch': cherrypy.dispatch.MethodDispatcher()},
# "/task": {'request.dispatch': cherrypy.dispatch.MethodDispatcher()}, # @TODO this conf belongs in the child
# "/logs": {
# 'tools.staticdir.on': True,
# 'tools.staticdir.dir': root.master.log_path,
# 'tools.staticdir.content_types': {'log': 'text/plain'}
# }
})
self.root = root
self.machine = ZApiMachines(self.root)
# self.task = BSApiTask(self.root)
# self.control = BSApiControl(self.root)
# self.socket = ApiWebsockets(self.root)
@cherrypy.expose
def index(self):
yield "It works!"
@cherrypy.expose
def create_disk(self, datastore, name, size, fmt):
"""
WORKAROUND for creating qemu disks
TODO replace me
"""
assert fmt in ["qcow2", "raw"], "Disk format is invalid"
assert name.endswith(".bin"), "Disk must be named <something>.bin"
disk_path = self.root.master.datastores[datastore].get_filepath(name)
img_args = ["qemu-img", "create", "-f", fmt, disk_path, "{}M".format(int(size))]
logging.info("Creating disk with: %s", str(img_args))
subprocess.check_call(img_args)
return name
@cherrypy.popargs("machine_id")
class ZApiMachineStop(object):
"""
Endpoint to stop running machines
"""
exposed = True
def __init__(self, root):
self.root = root
@cherrypy.tools.json_out()
def GET(self, machine_id):
"""
If the machine exists, stop it gracefully. This happens asynchronously.
"""
assert machine_id in self.root.master.machines
Thread(target=lambda: self.root.master.forceful_stop(machine_id)).start()
return machine_id
@cherrypy.popargs("machine_id")
class ZApiMachineStart(object):
"""
Endpoint to start stopped machines
"""
exposed = True
def __init__(self, root):
self.root = root
@cherrypy.tools.json_out()
def GET(self, machine_id=None):
"""
Start the machine
"""
self.root.master.machines[machine_id].start()
return machine_id
@cherrypy.popargs("machine_id")
class ZApiMachineRestart(object):
"""
Endpoint to restart machines
"""
exposed = True
def __init__(self, root):
self.root = root
@cherrypy.tools.json_out()
def GET(self, machine_id=None):
"""
Start the machine
TODO can we not repeat this from Stop/Start?
"""
assert machine_id in self.root.master.machines
self.root.master.forceful_stop(machine_id)
self.root.master.machines[machine_id].start()
return machine_id
@cherrypy.popargs("machine_id")
class ZApiMachines():
"""
Endpoint for managing machines
"""
exposed = True
def __init__(self, root):
"""
Endpoint to modify machines. PUT and DELETE require the machine not be running, which can be managed with the
stop and start methods below
"""
self.root = root
self.stop = ZApiMachineStop(self.root)
self.start = ZApiMachineStart(self.root)
self.restart = ZApiMachineRestart(self.root)
@cherrypy.tools.json_out()
def GET(self, machine_id=None, action=None, summary=False):
"""
Get a list of all machines or specific one if passed
:param task_id: task to retrieve
"""
summary = summary in [True, 'True', 'true', 'yes', '1', 1]
machines = {}
for _machine_id, machine_spec in self.root.master.machines.items():
machine = {"machine_id": _machine_id,
"_status": machine_spec.machine.get_status()}
if not summary:
machine.update({"machine_type": machine_spec.machine_type,
"spec": machine_spec.serialize()})
machines[_machine_id] = machine
if machine_id is not None:
try:
return [machines[machine_id]]
except KeyError:
raise cherrypy.HTTPError(status=404)
else:
return list(machines.values())
@cherrypy.tools.json_out()
def PUT(self, machine_id, machine_type, machine_spec):
"""
Create a new machine or update an existing machine
:param machine_id: id of machine to create or modify
:param machine_type: set machine type (currently, only "q")
'param machine_spec: json dictionary describing the machine. see the 'spec' key of example/banutoo.json
"""
assert machine_id not in self.root.master.machines or \
self.root.master.machines[machine_id].machine.get_status() == "stopped", \
"Machine must be stopped to modify"
machine_spec = json.loads(machine_spec)
self.root.master.add_machine(machine_id, machine_type, machine_spec, write=True)
return machine_id
def DELETE(self, machine_id):
"""
Delete a machine. Raises 404 if no machine exists. Raises error if machine is not stopped
:param machine_id: ID of machine to remove
"""
try:
assert self.root.master.machines[machine_id].machine.get_status() == "stopped", \
"Machine must be stopped to delete"
except KeyError:
raise cherrypy.HTTPError(status=404)
self.root.master.remove_machine(machine_id)
return machine_id

View File

@ -17,7 +17,17 @@ class QMachine(Machine):
self.block_respawns = False self.block_respawns = False
# TODO validate specs # TODO validate specs
def get_status(self):
"""
Return string "stopped" or "running" depending on machine status
@TODO machine status consts
"""
return "stopped" if self.proc is None else "running"
def start_machine(self): def start_machine(self):
"""
If needed, launch the machine.
"""
if self.proc: if self.proc:
raise Exception("Machine already running!") raise Exception("Machine already running!")
else: else:
@ -29,6 +39,9 @@ class QMachine(Machine):
Thread(target=self.wait_on_exit, args=[self.proc]).start() Thread(target=self.wait_on_exit, args=[self.proc]).start()
def wait_on_exit(self, proc): def wait_on_exit(self, proc):
"""
Listener used by above start_machine to restart the machine if the machine exits
"""
proc.wait() proc.wait()
logging.info("qemu process has exited") logging.info("qemu process has exited")
self.proc = None self.proc = None
@ -36,20 +49,30 @@ class QMachine(Machine):
self.start_machine() self.start_machine()
def stop_machine(self): def stop_machine(self):
"""
Send the powerdown signal to the running machine
"""
if self.proc: if self.proc:
logging.info("stopping machine...") logging.info("stopping machine %s", self.spec.machine_id)
self.proc.stdin.write(b"system_powerdown\n") self.proc.stdin.write(b"system_powerdown\n")
self.proc.stdin.flush() self.proc.stdin.flush()
self.proc.wait() self.proc.wait()
self.proc = None self.proc = None
def kill_machine(self): def kill_machine(self):
"""
Forcefully kill the running machine
"""
print("Terminating {}".format(self.proc))
if self.proc: if self.proc:
self.proc.terminate() self.proc.terminate()
self.proc.wait() self.proc.wait()
self.proc = None self.proc = None
def get_args(self, tap): def get_args(self, tap):
"""
Assemble the full argv array that will be executed for this machine
"""
argv = ['qemu-system-x86_64'] argv = ['qemu-system-x86_64']
argv += self.get_args_system() argv += self.get_args_system()
argv += self.get_args_drives() argv += self.get_args_drives()
@ -78,7 +101,7 @@ class QMachine(Machine):
def get_args_network(self, tap_name): def get_args_network(self, tap_name):
""" """
Hard-coded for now Return network related qemu args
""" """
args = [] args = []
for iface in self.spec.properties.get("netifaces"): for iface in self.spec.properties.get("netifaces"):
@ -87,7 +110,7 @@ class QMachine(Machine):
if iface_type == "tap": if iface_type == "tap":
if "ifname" not in iface: if "ifname" not in iface:
iface["ifname"] = tap_name iface["ifname"] = tap_name
iface["script"] = "/root/zhypervisor/testenv/bin/zd_ifup" # TODO fixme iface["script"] = "/root/zhypervisor/testenv/bin/zd_ifup" # TODO don't hard code
iface["downscript"] = "no" iface["downscript"] = "no"
args.append("-net") args.append("-net")

View File

@ -6,65 +6,133 @@ import logging
import argparse import argparse
from time import sleep from time import sleep
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from threading import Thread
from zhypervisor.logging import setup_logging from zhypervisor.logging import setup_logging
from zhypervisor.machine import MachineSpec from zhypervisor.machine import MachineSpec
from zhypervisor.api.api import ZApi
from pprint import pprint from pprint import pprint
class ZHypervisorDaemon(object): class ZHypervisorDaemon(object):
def __init__(self, config): def __init__(self, config):
self.config = config """
self.datastores = {} Z Hypervisor main thread. Roles:
self.machines = {} - Load and start machines and API on init
- Cleanup on shutdown
- Committing changes to machines to disk
- Primary interface to modify machines
"""
self.config = config # JSON config listing, mainly, datastore paths
self.datastores = {} # Mapping of datastore name -> objects
self.machines = {} # Mapping of machine name -> objects
self.running = True self.running = True
# Set up datastores and use the default datastore for "State" storage
self.init_datastores() self.init_datastores()
self.state = ZConfig(self.datastores["default"]) self.state = ZConfig(self.datastores["default"])
# start API
self.api = ZApi(self)
# Set up shutdown signal handlers
signal.signal(signal.SIGINT, self.signal_handler) # ctrl-c signal.signal(signal.SIGINT, self.signal_handler) # ctrl-c
signal.signal(signal.SIGTERM, self.signal_handler) # sigterm signal.signal(signal.SIGTERM, self.signal_handler) # sigterm
def init_datastores(self): def init_datastores(self):
"""
Per datastore in the config, create a ZDataStore object
"""
for name, info in self.config["datastores"].items(): for name, info in self.config["datastores"].items():
self.datastores[name] = ZDataStore(name, info["path"], info.get("init", False)) self.datastores[name] = ZDataStore(name, info["path"], info.get("init", False))
def init_machines(self): def init_machines(self):
"""
Per machine in the on-disk state, create a machine object
"""
for machine_info in self.state.get_machines(): for machine_info in self.state.get_machines():
machine_id = machine_info["id"] machine_id = machine_info["machine_id"]
self.add_machine(machine_id, machine_info["type"], machine_info["spec"]) self.add_machine(machine_id, machine_info["machine_type"], machine_info["spec"])
def add_machine(self, machine_id, machine_type, machine_spec): def add_machine(self, machine_id, machine_type, machine_spec, write=False):
machine = MachineSpec(self, machine_id, machine_type, machine_spec) """
self.machines[machine_id] = machine Create or update a machine.
if machine.options.get("autostart", False): :param machine_id: alphanumeric id of machine to modify/create
:param machine_type: runnable type e.g. "q"
:param machine_spec: dictionary of machine options - see example/ubuntu.json
:param write: commit machinge changes to on-disk state
"""
# Find / create the machine
if machine_id in self.machines:
machine = self.machines[machine_id]
machine.options = machine_spec["options"]
machine.properties = machine_spec["properties"]
else:
machine = MachineSpec(self, machine_id, machine_type, machine_spec)
self.machines[machine_id] = machine
# Update if necessary
if write:
self.state.write_machine(machine_id, machine_type, machine_spec)
# Launch if machine is an autostarted machine
if machine.options.get("autostart", False) and machine.machine.get_status() == "stopped":
machine.start() machine.start()
def signal_handler(self, signum, frame): def signal_handler(self, signum, frame):
"""
Handle signals sent to the daemon. On any, exit.
"""
logging.critical("Got signal {}".format(signum)) logging.critical("Got signal {}".format(signum))
self.stop() self.stop()
def run(self): def run(self):
# launch machines """
Main loop of the daemon. Sets up & starts machines, runs api, and waits.
"""
self.init_machines() self.init_machines()
self.api.run()
# start API
# TODO
# Wait?
while self.running:
sleep(1)
def stop(self): def stop(self):
"""
SHut down the hypervisor. Stop the API then shut down machines
"""
self.running = False self.running = False
self.api.stop()
with ThreadPoolExecutor(10) as pool: with ThreadPoolExecutor(10) as pool:
for machine_id, machine in self.machines.items(): for machine_id in self.machines.keys():
pool.submit(machine.stop) pool.submit(self.forceful_stop, machine_id)
# Sequential shutdown code below is easier to debug
# for machine_id in self.machines.keys():
# self.forceful_stop(machine_id)
def forceful_stop(self, machine_id, timeout=10): # make this timeout longer?
"""
Gracefully stop a machine by asking it nicely, waiting some time, then forcefully killing it.
"""
machine_spec = self.machines[machine_id]
nice_stop = Thread(target=machine_spec.stop)
nice_stop.start()
nice_stop.join(timeout)
if nice_stop.is_alive():
logging.error("%s did not respond in %s seconds, killing", machine_id, timeout)
machine_spec.machine.kill_machine()
def remove_machine(self, machine_id):
"""
Remove a stopped machine from the system
"""
assert self.machines[machine_id].machine.get_status() == "stopped"
self.state.remove_machine(machine_id)
del self.machines[machine_id]
class ZDataStore(object): class ZDataStore(object):
"""
Helper module representing a data storage location somewhere on disk
"""
def __init__(self, name, root_path, init_ok=False): def __init__(self, name, root_path, init_ok=False):
self.name = name self.name = name
self.root_path = root_path self.root_path = root_path
@ -86,6 +154,9 @@ class ZDataStore(object):
class ZConfig(object): class ZConfig(object):
"""
The Z Hypervisor daemon's interface to the on-disk config
"""
def __init__(self, datastore): def __init__(self, datastore):
self.datastore = datastore self.datastore = datastore
@ -95,6 +166,9 @@ class ZConfig(object):
os.makedirs(d, exist_ok=True) os.makedirs(d, exist_ok=True)
def get_machines(self): def get_machines(self):
"""
Return config of all machines on disk
"""
machines = [] machines = []
logging.info("Looking for machines in {}".format(self.machine_data_dir)) logging.info("Looking for machines in {}".format(self.machine_data_dir))
for mach_name in os.listdir(self.machine_data_dir): for mach_name in os.listdir(self.machine_data_dir):
@ -102,6 +176,28 @@ class ZConfig(object):
machines.append(json.load(f)) machines.append(json.load(f))
return machines return machines
def write_machine(self, machine_id, machine_type, machine_spec):
"""
Write a machine's config to the disk. Params similar to elsewhere.
"""
with open(os.path.join(self.machine_data_dir, "{}.json".format(machine_id)), "w") as f:
json.dump({"machine_id": machine_id,
"machine_type": machine_type,
"spec": machine_spec}, f, indent=4)
def write_machine_o(self, machine_obj):
"""
Similar to write_machine, but accepts a MachineSpec object
"""
self.write_machine(machine_obj.machine_id, machine_obj.machine_type, machine_obj.serialize())
def remove_machine(self, machine_id):
"""
Remove a machine from the on disk state
"""
json_path = os.path.join(self.machine_data_dir, "{}.json".format(machine_id))
os.unlink(json_path)
def main(): def main():
setup_logging() setup_logging()
@ -118,7 +214,7 @@ def main():
"state": "/opt/datastore/state/", "state": "/opt/datastore/state/",
"datastores": { "datastores": {
"default": { "default": {
"path": "/opt/datastore/machines/" "path": "/opt/z/datastore/machines/"
} }
}}, f, indent=4) }}, f, indent=4)
return return
@ -128,3 +224,4 @@ def main():
z = ZHypervisorDaemon(config) z = ZHypervisorDaemon(config)
z.run() z.run()
print("Z has been shut down")

View File

@ -4,26 +4,44 @@ from zhypervisor.clients.qmachine import QMachine
class MachineSpec(object): class MachineSpec(object):
"""
Represents a machine we may control
"""
def __init__(self, master, machine_id, machine_type, spec): def __init__(self, master, machine_id, machine_type, spec):
"""
Initialize options and properties of the machine. More importantly, initialize the self.machine object which
should be a subclass of zhypervisor.util.Machine.
"""
logging.info("Initting machine %s", machine_id) logging.info("Initting machine %s", machine_id)
self.master = master self.master = master
self.machine_id = machine_id self.machine_id = machine_id
self.machine_type = machine_type self.machine_type = machine_type
self.options = {} # hypervisor-level stuff like Autostart self.options = spec["options"]
self.properties = {} # machine level stuff like processor count self.properties = spec["properties"]
# TODO replace if/else with better system # TODO replace if/else with better system
if machine_type == "q": if machine_type == "q":
self.machine = QMachine(self) self.machine = QMachine(self)
self.options = spec["options"]
self.properties = spec["properties"]
else: else:
raise Exception("Unknown machine type: {}".format(machine_type)) raise Exception("Unknown machine type: {}".format(machine_type))
def start(self): def start(self):
"""
Start this machine (pass-through)
"""
self.machine.start_machine() self.machine.start_machine()
def stop(self): def stop(self):
"""
Stop this machine
"""
self.machine.block_respawns = True self.machine.block_respawns = True
self.machine.stop_machine() self.machine.stop_machine()
def serialize(self):
"""
Return a serializable form of this machine's specs
"""
return {"options": self.options,
"properties": self.properties}

View File

@ -8,6 +8,10 @@ from zhypervisor.logging import setup_logging
def main(): def main():
"""
Helper script for dealing with QEMU network interfaces. When QEMU starts, it calls this script passing an interface
name when the virtual machine has been started with it. This needs to enable the interface.
"""
setup_logging() setup_logging()
_, tap_name = sys.argv _, tap_name = sys.argv
logging.info("Enabling interface %s...", tap_name) logging.info("Enabling interface %s...", tap_name)

View File

@ -52,6 +52,12 @@ class Machine(object):
""" """
raise NotImplemented() raise NotImplemented()
def get_status(self):
"""
Get the machine's status (return one of "running" or "stopped")
"""
raise NotImplemented()
def get_datastore_path(self, datastore_name, *paths): def get_datastore_path(self, datastore_name, *paths):
""" """
Resolve the filesystem path for a path in the given datastore Resolve the filesystem path for a path in the given datastore