aetherscale

[unmaintained] code for a cloud provider tutorial
Log | Files | Refs | README | LICENSE

commit be926dce077524d4bb4e048bf034510e525fc3f9
parent dd4cbdf215e5b0adc3bf06dcdbe2872fafa77185
Author: Stefan Koch <programming@stefan-koch.name>
Date:   Sun, 31 Jan 2021 13:31:59 +0100

implement a simple HTTP interface

Diffstat:
MREADME.md | 56++++++++++++++++++++++++++++++--------------------------
Aaetherscale/api/flask.py | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Maetherscale/server.py | 7+++++--
Msetup.py | 3+++
Atests/test_api_flask.py | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 177 insertions(+), 28 deletions(-)

diff --git a/README.md b/README.md @@ -78,7 +78,7 @@ program, it feels too heavy for a proof-of-concept tool. Each VM is booted from a base image, which has to be created in advance. This means that at first you have to create a base image. Download an installation ISO for your favourite Linux distribution and install it to a -qcow2 file with the following command, following the installation instruction +qcow2 file with the following commands, following the installation instruction inside the started QEMU VM. ```bash @@ -91,25 +91,46 @@ qemu-system-x86_64 -cpu host -accel kvm -m 4096 -hda $BASE_IMAGE -cdrom $ISO The qcow2 is your base image. It must be located inside the `$BASE_IMAGE_FOLDER` directory. This is a configurable environment variable. -aetherscale expects a bridge network on the physical ethernet which can be -used to attach additional TAP interfaces for VMs. If this interface does not +aetherscale expects a bridge network `br0` on the physical ethernet which can +be used to attach additional TAP interfaces for VMs. If this interface does not exist, an error will be displayed on startup of the server. -Once the server is running, you can start a VM with: +aetherscale comes with an included HTTP server. While our HTTP implementation +does not allow scaling to multiple machines it simplifies the first steps. You +can start the HTTP server with: ```bash -aetherscale-cli create-vm --image base-image-name +aetherscale http ``` -The base image must exist as `$BASE_IMAGE_FOLDER/some-base-image-name.qcow2`. -You can configure the environment variable `$BASE_IMAGE_FOLDER` to any folder. +Once the server is running (on localhost port 5000 in this example) you can +create a new VM from the previously created base image and list all +VMs with: -You can then list all running VMs with: +```bash +curl -XPOST -H "Content-Type: application/json" \ + -d '{"image": "ubuntu-20.04.1-server-amd64"}' http://localhost:5000/vm +curl http://localhost:5000/vm +``` + +The base image must exist as +`$BASE_IMAGE_FOLDER/ubuntu-20.04.1-server-amd64.qcow2`. + +You can also stop a running VM and start a stopped VM by `PATCH`'ing the +VM's REST endpoint with the desired status: ```bash -aetherscale-cli list-vms +curl -XPATCH -H "Content-Type: application/json" \ + -d '{"status": "stopped"}' http://localhost:5000/vm/adbvzwdf + +curl -XPATCH -H "Content-Type: application/json" \ + -d '{"status": "started"}' http://localhost:5000/vm/adbvzwdf ``` +Please note that stopping a VM (gracefully) takes some time, so you cannot +start it immediately after you have issued a stop request. + + ## Run Tests You can run tests with `tox`: @@ -194,23 +215,6 @@ TODOs for VPN networking: - TODO: Structure files into subfolders, e.g. `CONFIG/vpn/vpn-abc/setup.sh`? -### Messages - -Create a new machine: - -```json -{ - "component": "computing", - "task": "create-vm", - "response-channel": "unique-channel-123456789", - "options": { - "image": "my-image", - "virtual-network": "my-virtual-subnet", - "public-ip": true, - } -} -``` - ### Computing Stuff I use for computing (and thus have learnt something about so far): diff --git a/aetherscale/api/flask.py b/aetherscale/api/flask.py @@ -0,0 +1,68 @@ +import flask +from pathlib import Path + +from aetherscale.computing import ComputingHandler +from aetherscale import services + + +app = flask.Flask(__name__) + + +@app.before_request +def initialize_handler(): + systemd_path = Path.home() / '.config/systemd/user' + service_manager = services.SystemdServiceManager(systemd_path) + handler = ComputingHandler(radvd=None, service_manager=service_manager) + flask.g.handler = handler + + +@app.route('/vm', methods=['GET']) +def list_vms(): + handler: ComputingHandler = flask.g.handler + result = list(handler.list_vms({}))[0] + + return flask.jsonify(result) + + +@app.route('/vm', methods=['POST']) +def create_vm(): + options = flask.request.json + handler: ComputingHandler = flask.g.handler + results = list(handler.create_vm(options)) + + # return the final status + return flask.jsonify(results[-1]) + + +@app.route('/vm/<vm_id>', methods=['PATCH']) +def update_vm_status(vm_id): + data = flask.request.json + + if not data or 'status' not in data: + return '"status" field in data missing', 400 + + handler: ComputingHandler = flask.g.handler + + if data['status'] == 'started': + dbg = handler.start_vm({'vm-id': vm_id}) + result = list(handler.start_vm({'vm-id': vm_id}))[0] + elif data['status'] == 'stopped': + result = list(handler.stop_vm({'vm-id': vm_id}))[0] + else: + return 'invalid value for "status"', 400 + + # return the final status + return flask.jsonify(result) + + +@app.route('/vm/<vm_id>', methods=['DELETE']) +def delete_vm(vm_id): + handler: ComputingHandler = flask.g.handler + result = list(handler.delete_vm({'vm-id': vm_id}))[0] + + return flask.jsonify(result) + + +def run(): + # TODO: Only needed for debugging, production applications must use WSGI + app.run() diff --git a/aetherscale/server.py b/aetherscale/server.py @@ -1,8 +1,9 @@ import sys from aetherscale import __version__ -from aetherscale.computing import run from aetherscale import dependencies +import aetherscale.computing +import aetherscale.api.flask def main(): @@ -14,5 +15,7 @@ def main(): if len(missing_deps) > 0: help_text = dependencies.build_dependency_help_text(missing_deps) print(help_text, file=sys.stderr) + elif len(sys.argv) >= 2 and sys.argv[1] == 'http': + aetherscale.api.flask.run() else: - run() + aetherscale.computing.run() diff --git a/setup.py b/setup.py @@ -10,6 +10,7 @@ with open('README.md', 'rb') as f: long_descr = f.read().decode('utf-8') install_requires = [ + 'flask', 'pika', 'psutil', ] @@ -18,7 +19,9 @@ setup( name='aetherscale', packages=[ 'aetherscale', + 'aetherscale.api', 'aetherscale.qemu', + 'aetherscale.vpn', ], entry_points={ 'console_scripts': [ diff --git a/tests/test_api_flask.py b/tests/test_api_flask.py @@ -0,0 +1,71 @@ +import json +from unittest import mock +import pytest + +import aetherscale.api.flask + + +@pytest.fixture +def client(): + with aetherscale.api.flask.app.test_client() as client: + return client + + +@mock.patch('aetherscale.api.flask.ComputingHandler') +def test_list_vms(handler, client): + handler.return_value.list_vms.return_value = [[]] + rv = client.get('/vm') + assert rv.json == [] + + handler.return_value.list_vms.return_value = [[{'vm-id': 'abc123'}]] + rv = client.get('/vm') + assert len(rv.json) == 1 + + +@mock.patch('aetherscale.api.flask.ComputingHandler') +def test_create_vm(handler, client): + client.post( + '/vm', data=json.dumps({'image': 'dummy-image'}), + content_type='application/json') + + handler.return_value.create_vm.assert_called_with({'image': 'dummy-image'}) + + +@mock.patch('aetherscale.api.flask.ComputingHandler') +def test_delete_vm(handler, client): + client.delete('/vm/my-vm-id') + handler.return_value.delete_vm.assert_called_with({'vm-id': 'my-vm-id'}) + + +@mock.patch('aetherscale.api.flask.ComputingHandler') +def test_start_vm(handler, client): + handler.return_value.start_vm.return_value = [[ + {'vm-id': 'my-vm-id', 'status': 'started'}, + ]] + + client.patch( + '/vm/my-vm-id', data=json.dumps({'status': 'started'}), + content_type='application/json') + + handler.return_value.start_vm.assert_called_with({'vm-id': 'my-vm-id'}) + + # missing message must lead to error + rv = client.patch('/vm/my-vm-id') + assert rv.status_code == 400 + + +@mock.patch('aetherscale.api.flask.ComputingHandler') +def test_stop_vm(handler, client): + handler.return_value.stop_vm.return_value = [[ + {'vm-id': 'my-vm-id', 'status': 'stopped'}, + ]] + + client.patch( + '/vm/my-vm-id', data=json.dumps({'status': 'stopped'}), + content_type='application/json') + + handler.return_value.stop_vm.assert_called_with({'vm-id': 'my-vm-id'}) + + # missing message must lead to error + rv = client.patch('/vm/my-vm-id') + assert rv.status_code == 400