summaryrefslogtreecommitdiff
path: root/opendc-web/opendc-web-api
diff options
context:
space:
mode:
authorFabian Mastenbroek <mail.fabianm@gmail.com>2021-07-05 12:08:25 +0200
committerGitHub <noreply@github.com>2021-07-05 12:08:25 +0200
commit49fc69c9cf154f9ad727e58f451e4be24dbaaff0 (patch)
tree953ed9998107f46d5892addc7266e39b3484fdfa /opendc-web/opendc-web-api
parent07958ab26e94d5ab7e0873cc00d7beb9c417975e (diff)
parent6752b6d50faab447b3edc13bddf14f53401392f1 (diff)
web: Migrate web runner to REST API
This pull request updates the web runner to remove its hard dependency on a direct database connection. Instead, it now communicates via the REST API. * Add endpoint for scheduling simulation jobs * Create simple API client for web runner * Remove direct database connection from web runner * Improve validation of API input/output data Implements #144
Diffstat (limited to 'opendc-web/opendc-web-api')
-rwxr-xr-xopendc-web/opendc-web-api/app.py4
-rw-r--r--opendc-web/opendc-web-api/conftest.py15
-rw-r--r--opendc-web/opendc-web-api/opendc/api/jobs.py105
-rw-r--r--opendc-web/opendc-web-api/opendc/api/portfolios.py16
-rw-r--r--opendc-web/opendc-web-api/opendc/api/prefabs.py19
-rw-r--r--opendc-web/opendc-web-api/opendc/api/projects.py28
-rw-r--r--opendc-web/opendc-web-api/opendc/api/scenarios.py15
-rw-r--r--opendc-web/opendc-web-api/opendc/api/topologies.py18
-rw-r--r--opendc-web/opendc-web-api/opendc/api/traces.py6
-rw-r--r--opendc-web/opendc-web-api/opendc/auth.py3
-rw-r--r--opendc-web/opendc-web-api/opendc/database.py19
-rw-r--r--opendc-web/opendc-web-api/opendc/exts.py40
-rw-r--r--opendc-web/opendc-web-api/opendc/models/portfolio.py2
-rw-r--r--opendc-web/opendc-web-api/opendc/models/prefab.py3
-rw-r--r--opendc-web/opendc-web-api/opendc/models/project.py13
-rw-r--r--opendc-web/opendc-web-api/opendc/models/scenario.py32
-rw-r--r--opendc-web/opendc-web-api/opendc/models/topology.py4
-rw-r--r--opendc-web/opendc-web-api/opendc/models/trace.py9
-rw-r--r--opendc-web/opendc-web-api/static/schema.yml184
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_jobs.py139
20 files changed, 585 insertions, 89 deletions
diff --git a/opendc-web/opendc-web-api/app.py b/opendc-web/opendc-web-api/app.py
index 5916046b..36c80b7a 100755
--- a/opendc-web/opendc-web-api/app.py
+++ b/opendc-web/opendc-web-api/app.py
@@ -10,6 +10,7 @@ from flask_restful import Api
from flask_swagger_ui import get_swaggerui_blueprint
from marshmallow import ValidationError
+from opendc.api.jobs import JobList, Job
from opendc.api.portfolios import Portfolio, PortfolioScenarios
from opendc.api.prefabs import Prefab, PrefabList
from opendc.api.projects import ProjectList, Project, ProjectTopologies, ProjectPortfolios
@@ -60,6 +61,8 @@ def setup_api(app):
api.add_resource(TraceList, '/traces/')
api.add_resource(Trace, '/traces/<string:trace_id>')
api.add_resource(SchedulerList, '/schedulers/')
+ api.add_resource(JobList, '/jobs/')
+ api.add_resource(Job, '/jobs/<string:job_id>')
@app.errorhandler(AuthError)
def handle_auth_error(ex):
@@ -89,6 +92,7 @@ def setup_swagger(app):
},
oauth_config={
'clientId': os.environ.get("AUTH0_DOCS_CLIENT_ID", ""),
+ 'additionalQueryStringParams': {'audience': os.environ.get("AUTH0_AUDIENCE", "https://api.opendc.org/v2/")},
}
)
app.register_blueprint(swaggerui_blueprint)
diff --git a/opendc-web/opendc-web-api/conftest.py b/opendc-web/opendc-web-api/conftest.py
index 430262f1..958a5894 100644
--- a/opendc-web/opendc-web-api/conftest.py
+++ b/opendc-web/opendc-web-api/conftest.py
@@ -8,7 +8,7 @@ from flask import _request_ctx_stack, g
from opendc.database import Database
-def decorator(f):
+def requires_auth_mock(f):
@wraps(f)
def decorated_function(*args, **kwargs):
_request_ctx_stack.top.current_user = {'sub': 'test'}
@@ -16,13 +16,24 @@ def decorator(f):
return decorated_function
+def requires_scope_mock(required_scope):
+ def decorator(f):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ return f(*args, **kwargs)
+ return decorated_function
+ return decorator
+
+
@pytest.fixture
def client():
"""Returns a Flask API client to interact with."""
# Disable authorization for test API endpoints
from opendc import exts
- exts.requires_auth = decorator
+ exts.requires_auth = requires_auth_mock
+ exts.requires_scope = requires_scope_mock
+ exts.has_scope = lambda x: False
from app import create_app
diff --git a/opendc-web/opendc-web-api/opendc/api/jobs.py b/opendc-web/opendc-web-api/opendc/api/jobs.py
new file mode 100644
index 00000000..6fb0522b
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/jobs.py
@@ -0,0 +1,105 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+from flask import request
+from flask_restful import Resource
+from marshmallow import fields, Schema, validate
+from werkzeug.exceptions import BadRequest, Conflict
+
+from opendc.exts import requires_auth, requires_scope
+from opendc.models.scenario import Scenario
+
+
+def convert_to_job(scenario):
+ """Convert a scenario to a job.
+ """
+ return JobSchema().dump({
+ '_id': scenario['_id'],
+ 'scenarioId': scenario['_id'],
+ 'state': scenario['simulation']['state'],
+ 'heartbeat': scenario['simulation'].get('heartbeat', None),
+ 'results': scenario.get('results', {})
+ })
+
+
+class JobSchema(Schema):
+ """
+ Schema representing a simulation job.
+ """
+ _id = fields.String(dump_only=True)
+ scenarioId = fields.String(dump_only=True)
+ state = fields.String(required=True,
+ validate=validate.OneOf(["QUEUED", "CLAIMED", "RUNNING", "FINISHED", "FAILED"]))
+ heartbeat = fields.DateTime()
+ results = fields.Dict()
+
+
+class JobList(Resource):
+ """
+ Resource representing the list of available jobs.
+ """
+ method_decorators = [requires_auth, requires_scope('runner')]
+
+ def get(self):
+ """Get all available jobs."""
+ jobs = Scenario.get_jobs()
+ data = list(map(convert_to_job, jobs.obj))
+ return {'data': data}
+
+
+class Job(Resource):
+ """
+ Resource representing a single job.
+ """
+ method_decorators = [requires_auth, requires_scope('runner')]
+
+ def get(self, job_id):
+ """Get the details of a single job."""
+ job = Scenario.from_id(job_id)
+ job.check_exists()
+ data = convert_to_job(job.obj)
+ return {'data': data}
+
+ def post(self, job_id):
+ """Update the details of a single job."""
+ action = JobSchema(only=('state', 'results')).load(request.json)
+
+ job = Scenario.from_id(job_id)
+ job.check_exists()
+
+ old_state = job.obj['simulation']['state']
+ new_state = action['state']
+
+ if old_state == new_state:
+ data = job.update_state(new_state)
+ elif (old_state, new_state) == ('QUEUED', 'CLAIMED'):
+ data = job.update_state('CLAIMED')
+ elif (old_state, new_state) == ('CLAIMED', 'RUNNING'):
+ data = job.update_state('RUNNING')
+ elif (old_state, new_state) == ('RUNNING', 'FINISHED'):
+ data = job.update_state('FINISHED', results=action.get('results', None))
+ elif old_state in ('CLAIMED', 'RUNNING') and new_state == 'FAILED':
+ data = job.update_state('FAILED')
+ else:
+ raise BadRequest('Invalid state transition')
+
+ if not data:
+ raise Conflict('State conflict')
+
+ return {'data': convert_to_job(data)}
diff --git a/opendc-web/opendc-web-api/opendc/api/portfolios.py b/opendc-web/opendc-web-api/opendc/api/portfolios.py
index b07e9da5..eea82289 100644
--- a/opendc-web/opendc-web-api/opendc/api/portfolios.py
+++ b/opendc-web/opendc-web-api/opendc/api/portfolios.py
@@ -22,7 +22,7 @@ from flask import request
from flask_restful import Resource
from marshmallow import Schema, fields
-from opendc.exts import requires_auth, current_user
+from opendc.exts import requires_auth, current_user, has_scope
from opendc.models.portfolio import Portfolio as PortfolioModel, PortfolioSchema
from opendc.models.project import Project
from opendc.models.scenario import ScenarioSchema, Scenario
@@ -42,9 +42,12 @@ class Portfolio(Resource):
portfolio = PortfolioModel.from_id(portfolio_id)
portfolio.check_exists()
- portfolio.check_user_access(current_user['sub'], False)
- data = portfolio.obj
+ # Users with scope runner can access all portfolios
+ if not has_scope('runner'):
+ portfolio.check_user_access(current_user['sub'], False)
+
+ data = PortfolioSchema().dump(portfolio.obj)
return {'data': data}
def put(self, portfolio_id):
@@ -63,7 +66,7 @@ class Portfolio(Resource):
portfolio.set_property('targets.repeatsPerScenario', result['portfolio']['targets']['repeatsPerScenario'])
portfolio.update()
- data = portfolio.obj
+ data = PortfolioSchema().dump(portfolio.obj)
return {'data': data}
def delete(self, portfolio_id):
@@ -84,7 +87,8 @@ class Portfolio(Resource):
project.update()
old_object = portfolio.delete()
- return {'data': old_object}
+ data = PortfolioSchema().dump(old_object)
+ return {'data': data}
class PutSchema(Schema):
"""
@@ -125,7 +129,7 @@ class PortfolioScenarios(Resource):
portfolio.obj['scenarioIds'].append(scenario.get_id())
portfolio.update()
- data = scenario.obj
+ data = ScenarioSchema().dump(scenario.obj)
return {'data': data}
class PostSchema(Schema):
diff --git a/opendc-web/opendc-web-api/opendc/api/prefabs.py b/opendc-web/opendc-web-api/opendc/api/prefabs.py
index 7bb17e7d..730546ba 100644
--- a/opendc-web/opendc-web-api/opendc/api/prefabs.py
+++ b/opendc-web/opendc-web-api/opendc/api/prefabs.py
@@ -24,7 +24,6 @@ from flask_restful import Resource
from marshmallow import Schema, fields
from opendc.models.prefab import Prefab as PrefabModel, PrefabSchema
-from opendc.database import Database
from opendc.exts import current_user, requires_auth, db
@@ -56,14 +55,15 @@ class PrefabList(Resource):
result = schema.load(request.json)
prefab = PrefabModel(result['prefab'])
- prefab.set_property('datetimeCreated', Database.datetime_to_string(datetime.now()))
- prefab.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
+ prefab.set_property('datetimeCreated', datetime.now())
+ prefab.set_property('datetimeLastEdited', datetime.now())
user_id = current_user['sub']
prefab.set_property('authorId', user_id)
prefab.insert()
- return {'data': prefab.obj}
+ data = PrefabSchema().dump(prefab.obj)
+ return {'data': data}
class PostSchema(Schema):
"""
@@ -83,7 +83,8 @@ class Prefab(Resource):
prefab = PrefabModel.from_id(prefab_id)
prefab.check_exists()
prefab.check_user_access(current_user['sub'])
- return {'data': prefab.obj}
+ data = PrefabSchema().dump(prefab.obj)
+ return {'data': data}
def put(self, prefab_id):
"""Update a prefab's name and/or contents."""
@@ -97,10 +98,11 @@ class Prefab(Resource):
prefab.set_property('name', result['prefab']['name'])
prefab.set_property('rack', result['prefab']['rack'])
- prefab.set_property('datetime_last_edited', Database.datetime_to_string(datetime.now()))
+ prefab.set_property('datetimeLastEdited', datetime.now())
prefab.update()
- return {'data': prefab.obj}
+ data = PrefabSchema().dump(prefab.obj)
+ return {'data': data}
def delete(self, prefab_id):
"""Delete this Prefab."""
@@ -111,7 +113,8 @@ class Prefab(Resource):
old_object = prefab.delete()
- return {'data': old_object}
+ data = PrefabSchema().dump(old_object)
+ return {'data': data}
class PutSchema(Schema):
"""
diff --git a/opendc-web/opendc-web-api/opendc/api/projects.py b/opendc-web/opendc-web-api/opendc/api/projects.py
index 8c44b680..05f02a84 100644
--- a/opendc-web/opendc-web-api/opendc/api/projects.py
+++ b/opendc-web/opendc-web-api/opendc/api/projects.py
@@ -27,7 +27,6 @@ from opendc.models.portfolio import Portfolio, PortfolioSchema
from opendc.models.topology import Topology, TopologySchema
from opendc.models.project import Project as ProjectModel, ProjectSchema
from opendc.exts import current_user, requires_auth
-from opendc.database import Database
class ProjectList(Resource):
@@ -40,7 +39,8 @@ class ProjectList(Resource):
"""Get the authorized projects of the user"""
user_id = current_user['sub']
projects = ProjectModel.get_for_user(user_id)
- return {'data': projects}
+ data = ProjectSchema().dump(projects, many=True)
+ return {'data': data}
def post(self):
"""Create a new project, and return that new project."""
@@ -53,8 +53,8 @@ class ProjectList(Resource):
topology.insert()
project = ProjectModel(result['project'])
- project.set_property('datetimeCreated', Database.datetime_to_string(datetime.now()))
- project.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
+ project.set_property('datetimeCreated', datetime.now())
+ project.set_property('datetimeLastEdited', datetime.now())
project.set_property('topologyIds', [topology.get_id()])
project.set_property('portfolioIds', [])
project.set_property('authorizations', [{'userId': user_id, 'level': 'OWN'}])
@@ -63,7 +63,8 @@ class ProjectList(Resource):
topology.set_property('projectId', project.get_id())
topology.update()
- return {'data': project.obj}
+ data = ProjectSchema().dump(project.obj)
+ return {'data': data}
class Project(Resource):
@@ -79,7 +80,8 @@ class Project(Resource):
project.check_exists()
project.check_user_access(current_user['sub'], False)
- return {'data': project.obj}
+ data = ProjectSchema().dump(project.obj)
+ return {'data': data}
def put(self, project_id):
"""Update a project's name."""
@@ -92,10 +94,11 @@ class Project(Resource):
project.check_user_access(current_user['sub'], True)
project.set_property('name', result['project']['name'])
- project.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
+ project.set_property('datetimeLastEdited', datetime.now())
project.update()
- return {'data': project.obj}
+ data = ProjectSchema().dump(project.obj)
+ return {'data': data}
def delete(self, project_id):
"""Delete this Project."""
@@ -113,8 +116,8 @@ class Project(Resource):
portfolio.delete()
old_object = project.delete()
-
- return {'data': old_object}
+ data = ProjectSchema().dump(old_object)
+ return {'data': data}
class PutSchema(Schema):
"""
@@ -148,10 +151,11 @@ class ProjectTopologies(Resource):
topology.insert()
project.obj['topologyIds'].append(topology.get_id())
- project.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
+ project.set_property('datetimeLastEdited', datetime.now())
project.update()
- return {'data': topology.obj}
+ data = TopologySchema().dump(topology.obj)
+ return {'data': data}
class PutSchema(Schema):
"""
diff --git a/opendc-web/opendc-web-api/opendc/api/scenarios.py b/opendc-web/opendc-web-api/opendc/api/scenarios.py
index b566950a..eacb0b49 100644
--- a/opendc-web/opendc-web-api/opendc/api/scenarios.py
+++ b/opendc-web/opendc-web-api/opendc/api/scenarios.py
@@ -24,7 +24,7 @@ from marshmallow import Schema, fields
from opendc.models.scenario import Scenario as ScenarioModel, ScenarioSchema
from opendc.models.portfolio import Portfolio
-from opendc.exts import current_user, requires_auth
+from opendc.exts import current_user, requires_auth, has_scope
class Scenario(Resource):
@@ -37,8 +37,12 @@ class Scenario(Resource):
"""Get scenario by identifier."""
scenario = ScenarioModel.from_id(scenario_id)
scenario.check_exists()
- scenario.check_user_access(current_user['sub'], False)
- data = scenario.obj
+
+ # Users with scope runner can access all scenarios
+ if not has_scope('runner'):
+ scenario.check_user_access(current_user['sub'], False)
+
+ data = ScenarioSchema().dump(scenario.obj)
return {'data': data}
def put(self, scenario_id):
@@ -54,7 +58,7 @@ class Scenario(Resource):
scenario.set_property('name', result['scenario']['name'])
scenario.update()
- data = scenario.obj
+ data = ScenarioSchema().dump(scenario.obj)
return {'data': data}
def delete(self, scenario_id):
@@ -72,7 +76,8 @@ class Scenario(Resource):
portfolio.update()
old_object = scenario.delete()
- return {'data': old_object}
+ data = ScenarioSchema().dump(old_object)
+ return {'data': data}
class PutSchema(Schema):
"""
diff --git a/opendc-web/opendc-web-api/opendc/api/topologies.py b/opendc-web/opendc-web-api/opendc/api/topologies.py
index eedf049d..c0b2e7ee 100644
--- a/opendc-web/opendc-web-api/opendc/api/topologies.py
+++ b/opendc-web/opendc-web-api/opendc/api/topologies.py
@@ -24,10 +24,9 @@ from flask import request
from flask_restful import Resource
from marshmallow import Schema, fields
-from opendc.database import Database
from opendc.models.project import Project
from opendc.models.topology import Topology as TopologyModel, TopologySchema
-from opendc.exts import current_user, requires_auth
+from opendc.exts import current_user, requires_auth, has_scope
class Topology(Resource):
@@ -42,8 +41,12 @@ class Topology(Resource):
"""
topology = TopologyModel.from_id(topology_id)
topology.check_exists()
- topology.check_user_access(current_user['sub'], False)
- data = topology.obj
+
+ # Users with scope runner can access all topologies
+ if not has_scope('runner'):
+ topology.check_user_access(current_user['sub'], False)
+
+ data = TopologySchema().dump(topology.obj)
return {'data': data}
def put(self, topology_id):
@@ -60,10 +63,10 @@ class Topology(Resource):
topology.set_property('name', result['topology']['name'])
topology.set_property('rooms', result['topology']['rooms'])
- topology.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
+ topology.set_property('datetimeLastEdited', datetime.now())
topology.update()
- data = topology.obj
+ data = TopologySchema().dump(topology.obj)
return {'data': data}
def delete(self, topology_id):
@@ -84,7 +87,8 @@ class Topology(Resource):
project.update()
old_object = topology.delete()
- return {'data': old_object}
+ data = TopologySchema().dump(old_object)
+ return {'data': data}
class PutSchema(Schema):
"""
diff --git a/opendc-web/opendc-web-api/opendc/api/traces.py b/opendc-web/opendc-web-api/opendc/api/traces.py
index f685f00c..6be8c5e5 100644
--- a/opendc-web/opendc-web-api/opendc/api/traces.py
+++ b/opendc-web/opendc-web-api/opendc/api/traces.py
@@ -21,7 +21,7 @@
from flask_restful import Resource
from opendc.exts import requires_auth
-from opendc.models.trace import Trace as TraceModel
+from opendc.models.trace import Trace as TraceModel, TraceSchema
class TraceList(Resource):
@@ -33,7 +33,7 @@ class TraceList(Resource):
def get(self):
"""Get all available Traces."""
traces = TraceModel.get_all()
- data = traces.obj
+ data = TraceSchema().dump(traces.obj, many=True)
return {'data': data}
@@ -47,5 +47,5 @@ class Trace(Resource):
"""Get trace information by identifier."""
trace = TraceModel.from_id(trace_id)
trace.check_exists()
- data = trace.obj
+ data = TraceSchema().dump(trace.obj)
return {'data': data}
diff --git a/opendc-web/opendc-web-api/opendc/auth.py b/opendc-web/opendc-web-api/opendc/auth.py
index 1870f01c..6db06fb1 100644
--- a/opendc-web/opendc-web-api/opendc/auth.py
+++ b/opendc-web/opendc-web-api/opendc/auth.py
@@ -42,8 +42,7 @@ def get_token():
if parts[0].lower() != "bearer":
raise AuthError({
"code": "invalid_header",
- "description": "Authorization header must start with"
- " Bearer"
+ "description": "Authorization header must start with Bearer"
}, 401)
if len(parts) == 1:
raise AuthError({"code": "invalid_header", "description": "Token not found"}, 401)
diff --git a/opendc-web/opendc-web-api/opendc/database.py b/opendc-web/opendc-web-api/opendc/database.py
index f9a33b66..dd6367f2 100644
--- a/opendc-web/opendc-web-api/opendc/database.py
+++ b/opendc-web/opendc-web-api/opendc/database.py
@@ -19,9 +19,8 @@
# SOFTWARE.
import urllib.parse
-from datetime import datetime
-from pymongo import MongoClient
+from pymongo import MongoClient, ReturnDocument
DATETIME_STRING_FORMAT = '%Y-%m-%dT%H:%M:%S'
CONNECTION_POOL = None
@@ -77,6 +76,12 @@ class Database:
"""Updates an existing object."""
return getattr(self.opendc_db, collection).update({'_id': _id}, obj)
+ def fetch_and_update(self, query, update, collection):
+ """Updates an existing object."""
+ return getattr(self.opendc_db, collection).find_one_and_update(query,
+ update,
+ return_document=ReturnDocument.AFTER)
+
def delete_one(self, query, collection):
"""Deletes one object matching the given query.
@@ -90,13 +95,3 @@ class Database:
The query needs to be in json format, i.e.: `{'name': prefab_name}`.
"""
getattr(self.opendc_db, collection).delete_many(query)
-
- @staticmethod
- def datetime_to_string(datetime_to_convert):
- """Return a database-compatible string representation of the given datetime object."""
- return datetime_to_convert.strftime(DATETIME_STRING_FORMAT)
-
- @staticmethod
- def string_to_datetime(string_to_convert):
- """Return a datetime corresponding to the given string representation."""
- return datetime.strptime(string_to_convert, DATETIME_STRING_FORMAT)
diff --git a/opendc-web/opendc-web-api/opendc/exts.py b/opendc-web/opendc-web-api/opendc/exts.py
index f088a29c..17dacd5e 100644
--- a/opendc-web/opendc-web-api/opendc/exts.py
+++ b/opendc-web/opendc-web-api/opendc/exts.py
@@ -2,10 +2,11 @@ import os
from functools import wraps
from flask import g, _request_ctx_stack
+from jose import jwt
from werkzeug.local import LocalProxy
from opendc.database import Database
-from opendc.auth import AuthContext, AsymmetricJwtAlgorithm, get_token
+from opendc.auth import AuthContext, AsymmetricJwtAlgorithm, get_token, AuthError
def get_db():
@@ -34,8 +35,7 @@ def get_auth_context():
_auth_context = AuthContext(
alg=AsymmetricJwtAlgorithm(jwks_url=f"https://{os.environ['AUTH0_DOMAIN']}/.well-known/jwks.json"),
issuer=f"https://{os.environ['AUTH0_DOMAIN']}/",
- audience=os.environ['AUTH0_AUDIENCE']
- )
+ audience=os.environ['AUTH0_AUDIENCE'])
g.auth_context = _auth_context
return _auth_context
@@ -46,7 +46,6 @@ auth_context = LocalProxy(get_auth_context)
def requires_auth(f):
"""Decorator to determine if the Access Token is valid.
"""
-
@wraps(f)
def decorated(*args, **kwargs):
token = get_token()
@@ -58,3 +57,36 @@ def requires_auth(f):
current_user = LocalProxy(lambda: getattr(_request_ctx_stack.top, 'current_user', None))
+
+
+def has_scope(required_scope):
+ """Determines if the required scope is present in the Access Token
+ Args:
+ required_scope (str): The scope required to access the resource
+ """
+ token = get_token()
+ unverified_claims = jwt.get_unverified_claims(token)
+ if unverified_claims.get("scope"):
+ token_scopes = unverified_claims["scope"].split()
+ for token_scope in token_scopes:
+ if token_scope == required_scope:
+ return True
+ return False
+
+
+def requires_scope(required_scope):
+ """Determines if the required scope is present in the Access Token
+ Args:
+ required_scope (str): The scope required to access the resource
+ """
+ def decorator(f):
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ if not has_scope(required_scope):
+ raise AuthError({
+ "code": "Unauthorized",
+ "description": "You don't have access to this resource"
+ }, 403)
+ return f(*args, **kwargs)
+ return decorated
+ return decorator
diff --git a/opendc-web/opendc-web-api/opendc/models/portfolio.py b/opendc-web/opendc-web-api/opendc/models/portfolio.py
index aff1d3f0..1643e23e 100644
--- a/opendc-web/opendc-web-api/opendc/models/portfolio.py
+++ b/opendc-web/opendc-web-api/opendc/models/portfolio.py
@@ -16,7 +16,7 @@ class PortfolioSchema(Schema):
"""
Schema representing a portfolio.
"""
- _id = fields.String()
+ _id = fields.String(dump_only=True)
projectId = fields.String()
name = fields.String(required=True)
scenarioIds = fields.List(fields.String())
diff --git a/opendc-web/opendc-web-api/opendc/models/prefab.py b/opendc-web/opendc-web-api/opendc/models/prefab.py
index d83ef4cb..5e4b81dc 100644
--- a/opendc-web/opendc-web-api/opendc/models/prefab.py
+++ b/opendc-web/opendc-web-api/opendc/models/prefab.py
@@ -9,7 +9,8 @@ class PrefabSchema(Schema):
"""
Schema for a Prefab.
"""
- _id = fields.String()
+ _id = fields.String(dump_only=True)
+ authorId = fields.String(dump_only=True)
name = fields.String(required=True)
datetimeCreated = fields.DateTime()
datetimeLastEdited = fields.DateTime()
diff --git a/opendc-web/opendc-web-api/opendc/models/project.py b/opendc-web/opendc-web-api/opendc/models/project.py
index ee84c73e..f2b3b564 100644
--- a/opendc-web/opendc-web-api/opendc/models/project.py
+++ b/opendc-web/opendc-web-api/opendc/models/project.py
@@ -1,20 +1,29 @@
-from marshmallow import Schema, fields
+from marshmallow import Schema, fields, validate
from werkzeug.exceptions import Forbidden
from opendc.models.model import Model
from opendc.exts import db
+class ProjectAuthorizations(Schema):
+ """
+ Schema representing a project authorization.
+ """
+ userId = fields.String(required=True)
+ level = fields.String(required=True, validate=validate.OneOf(["VIEW", "EDIT", "OWN"]))
+
+
class ProjectSchema(Schema):
"""
Schema representing a Project.
"""
- _id = fields.String()
+ _id = fields.String(dump_only=True)
name = fields.String(required=True)
datetimeCreated = fields.DateTime()
datetimeLastEdited = fields.DateTime()
topologyIds = fields.List(fields.String())
portfolioIds = fields.List(fields.String())
+ authorizations = fields.List(fields.Nested(ProjectAuthorizations))
class Project(Model):
diff --git a/opendc-web/opendc-web-api/opendc/models/scenario.py b/opendc-web/opendc-web-api/opendc/models/scenario.py
index 2911b1ae..0fb6c453 100644
--- a/opendc-web/opendc-web-api/opendc/models/scenario.py
+++ b/opendc-web/opendc-web-api/opendc/models/scenario.py
@@ -1,15 +1,12 @@
+from datetime import datetime
+
from marshmallow import Schema, fields
+
+from opendc.exts import db
from opendc.models.model import Model
from opendc.models.portfolio import Portfolio
-class SimulationSchema(Schema):
- """
- Simulation details.
- """
- state = fields.String()
-
-
class TraceSchema(Schema):
"""
Schema for specifying the trace of a scenario.
@@ -38,10 +35,9 @@ class ScenarioSchema(Schema):
"""
Schema representing a scenario.
"""
- _id = fields.String()
+ _id = fields.String(dump_only=True)
portfolioId = fields.String()
name = fields.String(required=True)
- simulation = fields.Nested(SimulationSchema)
trace = fields.Nested(TraceSchema)
topology = fields.Nested(TopologySchema)
operational = fields.Nested(OperationalSchema)
@@ -62,3 +58,21 @@ class Scenario(Model):
"""
portfolio = Portfolio.from_id(self.obj['portfolioId'])
portfolio.check_user_access(user_id, edit_access)
+
+ @classmethod
+ def get_jobs(cls):
+ """Obtain the scenarios that have been queued.
+ """
+ return cls(db.fetch_all({'simulation.state': 'QUEUED'}, cls.collection_name))
+
+ def update_state(self, new_state, results=None):
+ """Atomically update the state of the Scenario.
+ """
+ update = {'$set': {'simulation.state': new_state, 'simulation.heartbeat': datetime.now()}}
+ if results:
+ update['$set']['results'] = results
+ return db.fetch_and_update(
+ query={'_id': self.obj['_id'], 'simulation.state': self.obj['simulation']['state']},
+ update=update,
+ collection=self.collection_name
+ )
diff --git a/opendc-web/opendc-web-api/opendc/models/topology.py b/opendc-web/opendc-web-api/opendc/models/topology.py
index c6354ae6..71d2cade 100644
--- a/opendc-web/opendc-web-api/opendc/models/topology.py
+++ b/opendc-web/opendc-web-api/opendc/models/topology.py
@@ -72,8 +72,8 @@ class TopologySchema(Schema):
"""
Schema representing a datacenter topology.
"""
- _id = fields.String()
- projectId = fields.String()
+ _id = fields.String(dump_only=True)
+ projectId = fields.String(dump_only=True)
name = fields.String(required=True)
rooms = fields.List(fields.Nested(RoomSchema), required=True)
diff --git a/opendc-web/opendc-web-api/opendc/models/trace.py b/opendc-web/opendc-web-api/opendc/models/trace.py
index 2f6e4926..69287f29 100644
--- a/opendc-web/opendc-web-api/opendc/models/trace.py
+++ b/opendc-web/opendc-web-api/opendc/models/trace.py
@@ -1,6 +1,15 @@
+from marshmallow import Schema, fields
+
from opendc.models.model import Model
+class TraceSchema(Schema):
+ """Schema for a Trace."""
+ _id = fields.String(dump_only=True)
+ name = fields.String()
+ type = fields.String()
+
+
class Trace(Model):
"""Model representing a Trace."""
diff --git a/opendc-web/opendc-web-api/static/schema.yml b/opendc-web/opendc-web-api/static/schema.yml
index 99e88095..6a07ae52 100644
--- a/opendc-web/opendc-web-api/static/schema.yml
+++ b/opendc-web/opendc-web-api/static/schema.yml
@@ -965,7 +965,7 @@ paths:
application/json:
schema:
properties:
- project:
+ prefab:
$ref: "#/components/schemas/Prefab"
description: Prefab's new properties.
required: true
@@ -1040,6 +1040,135 @@ paths:
"application/json":
schema:
$ref: "#/components/schemas/NotFound"
+ /jobs:
+ get:
+ tags:
+ - jobs
+ description: Get all available jobs to run.
+ responses:
+ "200":
+ description: Successfully retrieved available jobs.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ type: array
+ items:
+ $ref: "#/components/schemas/Job"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "/jobs/{jobId}":
+ get:
+ tags:
+ - jobs
+ description: Get this Job.
+ parameters:
+ - name: jobId
+ in: path
+ description: Job's ID.
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Successfully retrieved Job.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Job"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from retrieving Job.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Job not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ post:
+ tags:
+ - jobs
+ description: Update this Job.
+ parameters:
+ - name: jobId
+ in: path
+ description: Job's ID.
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ job:
+ $ref: "#/components/schemas/Job"
+ description: Job's new properties.
+ required: true
+ responses:
+ "200":
+ description: Successfully updated Job.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Job"
+ "400":
+ description: Missing or incorrectly typed parameter.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Invalid"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from retrieving Job.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Job not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ "409":
+ description: State conflict.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Invalid"
components:
securitySchemes:
auth0:
@@ -1051,6 +1180,7 @@ components:
tokenUrl: https://opendc.eu.auth0.com/oauth/token
scopes:
openid: Grants access to user_id
+ runner: Grants access to runner jobs
schemas:
Unauthorized:
type: object
@@ -1153,6 +1283,10 @@ components:
object:
type: object
properties:
+ _id:
+ type: string
+ name:
+ type: string
capacity:
type: integer
powerCapacityW:
@@ -1162,52 +1296,70 @@ components:
items:
type: object
properties:
+ _id:
+ type: string
position:
type: integer
- cpuItems:
+ cpus:
type: array
items:
type: object
properties:
+ _id:
+ type: string
name:
type: string
clockRateMhz:
type: integer
numberOfCores:
type: integer
- gpuItems:
+ energyConsumptionW:
+ type: integer
+ gpus:
type: array
items:
type: object
properties:
+ _id:
+ type: string
name:
type: string
clockRateMhz:
type: integer
numberOfCores:
type: integer
- memoryItems:
+ energyConsumptionW:
+ type: integer
+ memories:
type: array
items:
type: object
properties:
+ _id:
+ type: string
name:
type: string
speedMbPerS:
type: integer
sizeMb:
type: integer
- storageItems:
+ energyConsumptionW:
+ type: integer
+ storages:
type: array
items:
- type: integer
+ type: object
properties:
+ _id:
+ type: string
name:
type: string
speedMbPerS:
type: integer
sizeMb:
type: integer
+ energyConsumptionW:
+ type: integer
Portfolio:
type: object
properties:
@@ -1239,13 +1391,6 @@ components:
type: string
name:
type: string
- simulation:
- type: object
- properties:
- state:
- type: string
- results:
- type: object
trace:
type: object
properties:
@@ -1267,6 +1412,19 @@ components:
type: boolean
schedulerName:
type: string
+ Job:
+ type: object
+ properties:
+ _id:
+ type: string
+ scenarioId:
+ type: string
+ state:
+ type: string
+ heartbeat:
+ type: string
+ results:
+ type: object
Trace:
type: object
properties:
diff --git a/opendc-web/opendc-web-api/tests/api/test_jobs.py b/opendc-web/opendc-web-api/tests/api/test_jobs.py
new file mode 100644
index 00000000..2efe6933
--- /dev/null
+++ b/opendc-web/opendc-web-api/tests/api/test_jobs.py
@@ -0,0 +1,139 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+#
+from datetime import datetime
+
+from opendc.exts import db
+
+test_id = 24 * '1'
+test_id_2 = 24 * '2'
+
+
+def test_get_jobs(client, mocker):
+ mocker.patch.object(db, 'fetch_all', return_value=[
+ {'_id': 'a', 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'}}
+ ])
+ res = client.get('/jobs/')
+ assert '200' in res.status
+
+
+def test_get_job_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.get(f'/jobs/{test_id}').status
+
+
+def test_get_job(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={
+ '_id': 'a', 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'}
+ })
+ res = client.get(f'/jobs/{test_id}')
+ assert '200' in res.status
+
+
+def test_update_job_nop(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={
+ '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'}
+ })
+ update_mock = mocker.patch.object(db, 'fetch_and_update', return_value={
+ '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y',
+ 'simulation': {'state': 'QUEUED', 'heartbeat': datetime.now()}
+ })
+ res = client.post(f'/jobs/{test_id}', json={'state': 'QUEUED'})
+ assert '200' in res.status
+ update_mock.assert_called_once()
+
+
+def test_update_job_invalid_state(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={
+ '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'}
+ })
+ res = client.post(f'/jobs/{test_id}', json={'state': 'FINISHED'})
+ assert '400' in res.status
+
+
+def test_update_job_claim(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={
+ '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'}
+ })
+ update_mock = mocker.patch.object(db, 'fetch_and_update', return_value={
+ '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y',
+ 'simulation': {'state': 'CLAIMED', 'heartbeat': datetime.now()}
+ })
+ res = client.post(f'/jobs/{test_id}', json={'state': 'CLAIMED'})
+ assert '200' in res.status
+ update_mock.assert_called_once()
+
+
+def test_update_job_conflict(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={
+ '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'}
+ })
+ update_mock = mocker.patch.object(db, 'fetch_and_update', return_value=None)
+ res = client.post(f'/jobs/{test_id}', json={'state': 'CLAIMED'})
+ assert '409' in res.status
+ update_mock.assert_called_once()
+
+
+def test_update_job_run(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={
+ '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'CLAIMED'}
+ })
+ update_mock = mocker.patch.object(db, 'fetch_and_update', return_value={
+ '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y',
+ 'simulation': {'state': 'RUNNING', 'heartbeat': datetime.now()}
+ })
+ res = client.post(f'/jobs/{test_id}', json={'state': 'RUNNING'})
+ assert '200' in res.status
+ update_mock.assert_called_once()
+
+
+def test_update_job_finished(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={
+ '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'RUNNING'}
+ })
+ update_mock = mocker.patch.object(db, 'fetch_and_update', return_value={
+ '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y',
+ 'simulation': {'state': 'FINISHED', 'heartbeat': datetime.now()}
+ })
+ res = client.post(f'/jobs/{test_id}', json={'state': 'FINISHED'})
+ assert '200' in res.status
+ update_mock.assert_called_once()
+
+
+def test_update_job_failed(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={
+ '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'RUNNING'}
+ })
+ update_mock = mocker.patch.object(db, 'fetch_and_update', return_value={
+ '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y',
+ 'simulation': {'state': 'FAILED', 'heartbeat': datetime.now()}
+ })
+ res = client.post(f'/jobs/{test_id}', json={'state': 'FAILED'})
+ assert '200' in res.status
+ update_mock.assert_called_once()