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:
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