diff options
Diffstat (limited to 'opendc-web')
214 files changed, 7299 insertions, 6756 deletions
diff --git a/opendc-web/opendc-web-api/.coveragerc b/opendc-web/opendc-web-api/.coveragerc deleted file mode 100644 index 55d99c2e..00000000 --- a/opendc-web/opendc-web-api/.coveragerc +++ /dev/null @@ -1,5 +0,0 @@ -[run] -source = . -omit = - tests/* - conftest.py diff --git a/opendc-web/opendc-web-api/.dockerignore b/opendc-web/opendc-web-api/.dockerignore deleted file mode 100644 index 06d67de9..00000000 --- a/opendc-web/opendc-web-api/.dockerignore +++ /dev/null @@ -1,8 +0,0 @@ -Dockerfile - -.idea/ -**/out -*.iml -.idea_modules/ - -.pytest_cache diff --git a/opendc-web/opendc-web-api/.gitignore b/opendc-web/opendc-web-api/.gitignore deleted file mode 100644 index 9f8dfc5c..00000000 --- a/opendc-web/opendc-web-api/.gitignore +++ /dev/null @@ -1,19 +0,0 @@ -.DS_Store -*.pyc -*.pyo -venv -venv* -dist -build -*.egg -*.egg-info -_mailinglist -.tox -.cache/ -.idea/ -config.json -test.json -.env* -.coverage -coverage.xml -junit-report.xml diff --git a/opendc-web/opendc-web-api/.pylintrc b/opendc-web/opendc-web-api/.pylintrc deleted file mode 100644 index 4dbb0b50..00000000 --- a/opendc-web/opendc-web-api/.pylintrc +++ /dev/null @@ -1,523 +0,0 @@ -[MASTER] - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. -extension-pkg-whitelist= - -# Specify a score threshold to be exceeded before program exits with error. -fail-under=10 - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS - -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -# number of processors available to use. -jobs=1 - -# Control the amount of potential inferred values when inferring a single -# object. This can help the performance when dealing with large functions or -# complex, nested conditions. -limit-inference-results=100 - -# List of plugins (as comma separated values of python module names) to load, -# usually to register additional checkers. -load-plugins= - -# Pickle collected data for later comparisons. -persistent=yes - -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. -confidence= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once). You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use "--disable=all --enable=classes -# --disable=W". -disable=duplicate-code, - missing-module-docstring, - invalid-name, - bare-except, - too-few-public-methods, - fixme, - no-self-use - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable=c-extension-no-member - - -[REPORTS] - -# Python expression which should return a score less than or equal to 10. You -# have access to the variables 'error', 'warning', 'refactor', and 'convention' -# which contain the number of messages in each category, as well as 'statement' -# which is the total number of statements analyzed. This score is used by the -# global evaluation report (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details. -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio). You can also give a reporter class, e.g. -# mypackage.mymodule.MyReporterClass. -output-format=text - -# Tells whether to display a full report or only the messages. -reports=no - -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - -# Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then -# it will be considered as an explicit return statement and no message will be -# printed. -never-returning-functions=sys.exit - - -[LOGGING] - -# The type of string formatting that logging methods do. `old` means using % -# formatting, `new` is for `{}` formatting. -logging-format-style=old - -# Logging modules to check that the string format arguments are in logging -# function parameter format. -logging-modules=logging - - -[SPELLING] - -# Limits count of emitted suggestions for spelling mistakes. -max-spelling-suggestions=4 - -# Spelling dictionary name. Available dictionaries: none. To make it work, -# install the python-enchant package. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains the private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to the private dictionary (see the -# --spelling-private-dict-file option) instead of raising a message. -spelling-store-unknown-words=no - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME, - XXX, - TODO - -# Regular expression of note tags to take in consideration. -#notes-rgx= - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# Tells whether to warn about missing members when the owner of the attribute -# is inferred to be None. -ignore-none=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local - -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis). It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - -# List of decorators that change the signature of a decorated function. -signature-mutators= - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid defining new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_, - _cb - -# A regular expression matching the name of dummy variables (i.e. expected to -# not be used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore. -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )?<?https?://\S+>?$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=120 - -# Maximum number of lines in a module. -max-module-lines=1000 - -# List of optional constructs for which whitespace checking is disabled. `dict- -# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. -# `trailing-comma` allows a space between comma and closing bracket: (a, ). -# `empty-line` allows space-only lines. -no-space-check=trailing-comma, - dict-separator - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[SIMILARITIES] - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - -# Ignore imports when computing similarities. -ignore-imports=no - -# Minimum lines number of a similarity. -min-similarity-lines=4 - - -[BASIC] - -# Naming style matching correct argument names. -argument-naming-style=snake_case - -# Regular expression matching correct argument names. Overrides argument- -# naming-style. -#argument-rgx= - -# Naming style matching correct attribute names. -attr-naming-style=snake_case - -# Regular expression matching correct attribute names. Overrides attr-naming- -# style. -#attr-rgx= - -# Bad variable names which should always be refused, separated by a comma. -bad-names=foo, - bar, - baz, - toto, - tutu, - tata - -# Bad variable names regexes, separated by a comma. If names match any regex, -# they will always be refused -bad-names-rgxs= - -# Naming style matching correct class attribute names. -class-attribute-naming-style=any - -# Regular expression matching correct class attribute names. Overrides class- -# attribute-naming-style. -#class-attribute-rgx= - -# Naming style matching correct class names. -class-naming-style=PascalCase - -# Regular expression matching correct class names. Overrides class-naming- -# style. -#class-rgx= - -# Naming style matching correct constant names. -const-naming-style=UPPER_CASE - -# Regular expression matching correct constant names. Overrides const-naming- -# style. -#const-rgx= - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Naming style matching correct function names. -function-naming-style=snake_case - -# Regular expression matching correct function names. Overrides function- -# naming-style. -#function-rgx= - -# Good variable names which should always be accepted, separated by a comma. -good-names=i, - j, - k, - ex, - Run, - _ - -# Good variable names regexes, separated by a comma. If names match any regex, -# they will always be accepted -good-names-rgxs= - -# Include a hint for the correct naming format with invalid-name. -include-naming-hint=no - -# Naming style matching correct inline iteration names. -inlinevar-naming-style=any - -# Regular expression matching correct inline iteration names. Overrides -# inlinevar-naming-style. -#inlinevar-rgx= - -# Naming style matching correct method names. -method-naming-style=snake_case - -# Regular expression matching correct method names. Overrides method-naming- -# style. -#method-rgx= - -# Naming style matching correct module names. -module-naming-style=snake_case - -# Regular expression matching correct module names. Overrides module-naming- -# style. -#module-rgx= - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -# These decorators are taken in consideration only for invalid-name. -property-classes=abc.abstractproperty - -# Naming style matching correct variable names. -variable-naming-style=snake_case - -# Regular expression matching correct variable names. Overrides variable- -# naming-style. -#variable-rgx= - - -[STRING] - -# This flag controls whether inconsistent-quotes generates a warning when the -# character used as a quote delimiter is used inconsistently within a module. -check-quote-consistency=no - -# This flag controls whether the implicit-str-concat should generate a warning -# on implicit string concatenation in sequences defined over several lines. -check-str-concat-over-line-jumps=no - - -[IMPORTS] - -# List of modules that can be imported at any level, not just the top level -# one. -allow-any-import-level= - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Deprecated modules which should not be used, separated by a comma. -deprecated-modules=optparse,tkinter.tix - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled). -ext-import-graph= - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled). -import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled). -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - -# Couples of modules and preferred modules, separated by a comma. -preferred-modules= - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__, - __new__, - setUp, - __post_init__ - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict, - _fields, - _replace, - _source, - _make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=cls - - -[DESIGN] - -# Maximum number of arguments for function / method. -max-args=5 - -# Maximum number of attributes for a class (see R0902). -max-attributes=12 - -# Maximum number of boolean expressions in an if statement (see R0916). -max-bool-expr=5 - -# Maximum number of branch for function / method body. -max-branches=12 - -# Maximum number of locals for function / method body. -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body. -max-returns=6 - -# Maximum number of statements in function / method body. -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "BaseException, Exception". -overgeneral-exceptions=BaseException, - Exception diff --git a/opendc-web/opendc-web-api/.style.yapf b/opendc-web/opendc-web-api/.style.yapf deleted file mode 100644 index f5c26c57..00000000 --- a/opendc-web/opendc-web-api/.style.yapf +++ /dev/null @@ -1,3 +0,0 @@ -[style] -based_on_style = pep8 -column_limit=120 diff --git a/opendc-web/opendc-web-api/Dockerfile b/opendc-web/opendc-web-api/Dockerfile index 505a69de..ff300170 100644 --- a/opendc-web/opendc-web-api/Dockerfile +++ b/opendc-web/opendc-web-api/Dockerfile @@ -1,23 +1,17 @@ -FROM python:3.9-slim +FROM openjdk:17-slim MAINTAINER OpenDC Maintainers <opendc@atlarge-research.com> -# Ensure the STDOUT is not buffered by Python so that our logs become visible -# See https://stackoverflow.com/q/29663459/10213073 -ENV PYTHONUNBUFFERED 1 +# Obtain (cache) Gradle wrapper +COPY gradlew /app/ +COPY gradle /app/gradle +WORKDIR /app +RUN ./gradlew --version -# Copy OpenDC directory -COPY ./ /opendc +# Build project +COPY ./ /app/ +RUN ./gradlew --no-daemon :opendc-web:opendc-web-api:build -# Fetch web server dependencies -RUN pip install -r /opendc/requirements.txt && pip install pyuwsgi - -# Create opendc user -RUN groupadd --gid 1000 opendc \ - && useradd --uid 1000 --gid opendc --shell /bin/bash --create-home opendc -RUN chown -R opendc:opendc /opendc -USER opendc - -# Set working directory -WORKDIR /opendc - -CMD uwsgi -M --socket 0.0.0.0:80 --protocol=http --wsgi-file app.py --enable-threads --processes 2 --lazy-app +FROM openjdk:17-slim +COPY --from=0 /app/opendc-web/opendc-web-api/build/quarkus-app /opt/opendc +WORKDIR /opt/opendc +CMD java -jar quarkus-run.jar diff --git a/opendc-web/opendc-web-api/README.md b/opendc-web/opendc-web-api/README.md deleted file mode 100644 index d1c469c1..00000000 --- a/opendc-web/opendc-web-api/README.md +++ /dev/null @@ -1,122 +0,0 @@ -<h1 align="center"> - <img src="../../docs/images/logo.png" width="100" alt="OpenDC"> - <br> - OpenDC Web Server -</h1> -<p align="center"> - Collaborative Datacenter Simulation and Exploration for Everybody -</p> - -<br> - -The OpenDC web server is the bridge between OpenDC's frontend and database. It is built with Flask/SocketIO in Python -and implements the OpenAPI-compliant [OpenDC API specification](../../opendc-api-spec.yml). - -This document explains a high-level view of the web server architecture ([jump](#architecture)), and describes how to -set up the web server for local development ([jump](#setup-for-local-development)). - -## Architecture - -The following diagram shows a high-level view of the architecture of the OpenDC web server. Squared-off colored boxes -indicate packages (colors become more saturated as packages are nested); rounded-off boxes indicate individual -components; dotted lines indicate control flow; and solid lines indicate data flow. - - - -The OpenDC API is implemented by the `Main Server Loop`, which is the only component in the base package. - -### Util Package - -The `Util` package handles several miscellaneous tasks: - -* `Database API`: Wraps database access functionality used by `Models` to read themselves from/write themselves into the - database. -* `Exceptions`: Holds definitions for exceptions used throughout the web server. -* `Parameter Checker`: Recursively checks whether required `Request` parameters are present and correctly typed. -* `REST`: Parses HTTP messages into `Request` objects, and calls the appropriate `API` endpoint to get a `Response` - object to return to the `Main Server Loop`. - -### API Package - -The `API` package contains the logic for the HTTP methods in each API endpoint. Packages are structured to mirror the -API: the code for the endpoint `GET api/projects`, for example, would be located at the `endpoint.py` inside -the `projects` package (so at `api/projects/endpoint.py`). - -An `endpoint.py` file contains methods for each HTTP method it supports, which takes a request as input (such -as `def GET(request):`). Typically, such a method checks whether the parameters were passed correctly (using -the `Parameter Checker`); fetches some model from the database; checks whether the data exists and is accessible by the -user who made the request; possibly modifies this data and writes it back to the database; and returns a JSON -representation of the model. - -The `REST` component dynamically imports the appropriate method from the appropriate `endpoint`, according to request it -receives, and executes it. - -### Models Package - -The `models` package contains the logic for mapping Python objects to their database representations. This involves an -abstract `model` which has generic CRUD operations. Extensions of `model`, such as a `User` or `Project`, specify some -more specific operations and their collection metadata. - -`Endpoint`s import these `models` and use them to execute requests. - -## Setup for Local Development - -The following steps will guide you through setting up the OpenDC web server locally for development. - -### Local Setup - -#### Install requirements - -Make sure you have Python 3.7+ installed (if not, get it [here](https://www.python.org/)), as well as pip (if not, get -it [here](https://pip.pypa.io/en/stable/installing/)). Then run the following to install the requirements. - -```bash -pip install -r requirements.txt -``` - -The web server also requires a running MongoDB instance. We recommend setting this up through docker, by -running `docker-compose build` and `docker-compose up` in the [`mongodb` directory](../../database) of the main OpenDC -repository. - -#### Get and configure the code - -Clone OpenDC and follow the [instructions from the deployment guide](../../docs/deploy.md) to set up an [Auth0](https://auth0.com) -application and environment variables. - -**Important:** Be sure to set up environment variables according to those instructions, in a `.env` file. - -#### Set up the database - -You can selectively run only the database services from the standard OpenDC `docker-compose` setup (in the root -directory): - -```bash -docker-compose build mongo mongo-express -docker-compose up mongo mongo-express -``` - -This will set you up with a running MongoDB instance and a visual inspection tool running -on [localhost:8082](http://localhost:8082), with which you can view and manipulate the database. Add the simulator -images to the command lists above if you want to test simulation capabilities, as well. - -### Local Development - -Run the server. - -```bash -python3 -m flask run --port 8081 -``` - -When editing the web server code, restart the server (`CTRL` + `c` followed by `python app.py` in the console running -the server) to see the result of your changes. - -#### Code Style - -To format all files, run `format.sh` in this directory. The script uses `yapf` internally to format everything -automatically. - -To check if code style is up to modern standards, run `check.sh` in this directory. The script uses `pylint` internally. - -#### Testing - -Run `pytest opendc` in this directory to run all tests. diff --git a/opendc-web/opendc-web-api/app.py b/opendc-web/opendc-web-api/app.py deleted file mode 100755 index 36c80b7a..00000000 --- a/opendc-web/opendc-web-api/app.py +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env python3 -import mimetypes -import os - -from dotenv import load_dotenv -from flask import Flask, jsonify, redirect -from flask_compress import Compress -from flask_cors import CORS -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 -from opendc.api.scenarios import Scenario -from opendc.api.schedulers import SchedulerList -from opendc.api.topologies import Topology -from opendc.api.traces import TraceList, Trace -from opendc.auth import AuthError -from opendc.util import JSONEncoder - - -# Load environmental variables from dotenv file -load_dotenv() - - -def setup_sentry(): - """ - Setup the Sentry integration for Flask if a DSN is supplied via the environmental variables. - """ - if 'SENTRY_DSN' not in os.environ: - return - - import sentry_sdk - from sentry_sdk.integrations.flask import FlaskIntegration - - sentry_sdk.init( - integrations=[FlaskIntegration()], - traces_sample_rate=0.1 - ) - - -def setup_api(app): - """ - Setup the API interface. - """ - api = Api(app) - # Map to ('string', 'ObjectId') passing type and format - api.add_resource(ProjectList, '/projects/') - api.add_resource(Project, '/projects/<string:project_id>') - api.add_resource(ProjectTopologies, '/projects/<string:project_id>/topologies') - api.add_resource(ProjectPortfolios, '/projects/<string:project_id>/portfolios') - api.add_resource(Topology, '/topologies/<string:topology_id>') - api.add_resource(PrefabList, '/prefabs/') - api.add_resource(Prefab, '/prefabs/<string:prefab_id>') - api.add_resource(Portfolio, '/portfolios/<string:portfolio_id>') - api.add_resource(PortfolioScenarios, '/portfolios/<string:portfolio_id>/scenarios') - api.add_resource(Scenario, '/scenarios/<string:scenario_id>') - 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): - response = jsonify(ex.error) - response.status_code = ex.status_code - return response - - @app.errorhandler(ValidationError) - def handle_validation_error(ex): - return {'message': 'Input validation failed', 'errors': ex.messages}, 400 - - return api - - -def setup_swagger(app): - """ - Setup Swagger UI - """ - SWAGGER_URL = '/docs' - API_URL = '../schema.yml' - - swaggerui_blueprint = get_swaggerui_blueprint( - SWAGGER_URL, - API_URL, - config={ - 'app_name': "OpenDC API v2" - }, - 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) - - -def create_app(testing=False): - app = Flask(__name__, static_url_path='/') - app.config['TESTING'] = testing - app.config['SECRET_KEY'] = os.environ['OPENDC_FLASK_SECRET'] - app.config['RESTFUL_JSON'] = {'cls': JSONEncoder} - app.json_encoder = JSONEncoder - - # Define YAML content type - mimetypes.add_type('text/yaml', '.yml') - - # Setup Sentry if DSN is specified - setup_sentry() - - # Set up CORS support - CORS(app) - - # Setup compression - compress = Compress() - compress.init_app(app) - - setup_api(app) - setup_swagger(app) - - @app.route('/') - def index(): - """ - Redirect the user to the API documentation if it accesses the API root. - """ - return redirect('docs/') - - return app - - -application = create_app(testing="OPENDC_FLASK_TESTING" in os.environ) - -if __name__ == '__main__': - application.run() diff --git a/opendc-web/opendc-web-api/build.gradle.kts b/opendc-web/opendc-web-api/build.gradle.kts index 7edfd134..853632a7 100644 --- a/opendc-web/opendc-web-api/build.gradle.kts +++ b/opendc-web/opendc-web-api/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 AtLarge Research + * Copyright (c) 2020 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 @@ -19,3 +19,75 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ + +description = "REST API for the OpenDC website" + +/* Build configuration */ +plugins { + `kotlin-conventions` + kotlin("plugin.allopen") + kotlin("plugin.jpa") + `testing-conventions` + `jacoco-conventions` + id("io.quarkus") +} + +dependencies { + implementation(enforcedPlatform(libs.quarkus.bom)) + + implementation(projects.opendcWeb.opendcWebProto) + + implementation(libs.quarkus.kotlin) + implementation(libs.quarkus.resteasy.core) + implementation(libs.quarkus.resteasy.jackson) + implementation(libs.jackson.module.kotlin) + implementation(libs.quarkus.smallrye.openapi) + + implementation(libs.quarkus.security) + implementation(libs.quarkus.oidc) + + implementation(libs.quarkus.hibernate.orm) + implementation(libs.quarkus.hibernate.validator) + implementation(libs.quarkus.jdbc.postgresql) + quarkusDev(libs.quarkus.jdbc.h2) + + testImplementation(libs.quarkus.junit5.core) + testImplementation(libs.quarkus.junit5.mockk) + testImplementation(libs.quarkus.jacoco) + testImplementation(libs.restassured.core) + testImplementation(libs.restassured.kotlin) + testImplementation(libs.quarkus.test.security) + testImplementation(libs.quarkus.jdbc.h2) +} + +allOpen { + annotation("javax.ws.rs.Path") + annotation("javax.enterprise.context.ApplicationScoped") + annotation("io.quarkus.test.junit.QuarkusTest") + annotation("javax.persistence.Entity") +} + +tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> { + kotlinOptions.javaParameters = true +} + +tasks.quarkusDev { + workingDir = rootProject.projectDir.toString() +} + +tasks.test { + extensions.configure(JacocoTaskExtension::class) { + excludeClassLoaders = listOf("*QuarkusClassLoader") + // destinationFile = layout.buildDirectory.file("jacoco-quarkus.exec").get().asFile + } +} + +/* Fix for Quarkus/ktlint-gradle incompatibilities */ +tasks.named("runKtlintCheckOverMainSourceSet").configure { + mustRunAfter(tasks.quarkusGenerateCode) + mustRunAfter(tasks.quarkusGenerateCodeDev) +} + +tasks.named("runKtlintCheckOverTestSourceSet").configure { + mustRunAfter(tasks.quarkusGenerateCodeTests) +} diff --git a/opendc-web/opendc-web-api/check.sh b/opendc-web/opendc-web-api/check.sh deleted file mode 100755 index abe2c596..00000000 --- a/opendc-web/opendc-web-api/check.sh +++ /dev/null @@ -1 +0,0 @@ -pylint opendc --ignore-patterns=test_.*?py diff --git a/opendc-web/opendc-web-api/conftest.py b/opendc-web/opendc-web-api/conftest.py deleted file mode 100644 index 958a5894..00000000 --- a/opendc-web/opendc-web-api/conftest.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Configuration file for all unit tests. -""" - -from functools import wraps -import pytest -from flask import _request_ctx_stack, g -from opendc.database import Database - - -def requires_auth_mock(f): - @wraps(f) - def decorated_function(*args, **kwargs): - _request_ctx_stack.top.current_user = {'sub': 'test'} - return f(*args, **kwargs) - 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 = requires_auth_mock - exts.requires_scope = requires_scope_mock - exts.has_scope = lambda x: False - - from app import create_app - - app = create_app(testing=True) - - with app.app_context(): - g.db = Database() - with app.test_client() as client: - yield client diff --git a/opendc-web/opendc-web-api/docs/component-diagram.png b/opendc-web/opendc-web-api/docs/component-diagram.png Binary files differdeleted file mode 100644 index 91b26006..00000000 --- a/opendc-web/opendc-web-api/docs/component-diagram.png +++ /dev/null diff --git a/opendc-web/opendc-web-api/format.sh b/opendc-web/opendc-web-api/format.sh deleted file mode 100755 index 18cba452..00000000 --- a/opendc-web/opendc-web-api/format.sh +++ /dev/null @@ -1 +0,0 @@ -yapf **/*.py -i diff --git a/opendc-web/opendc-web-api/opendc/__init__.py b/opendc-web/opendc-web-api/opendc/__init__.py deleted file mode 100644 index e69de29b..00000000 --- a/opendc-web/opendc-web-api/opendc/__init__.py +++ /dev/null diff --git a/opendc-web/opendc-web-api/opendc/api/__init__.py b/opendc-web/opendc-web-api/opendc/api/__init__.py deleted file mode 100644 index e69de29b..00000000 --- a/opendc-web/opendc-web-api/opendc/api/__init__.py +++ /dev/null diff --git a/opendc-web/opendc-web-api/opendc/api/jobs.py b/opendc-web/opendc-web-api/opendc/api/jobs.py deleted file mode 100644 index 6fb0522b..00000000 --- a/opendc-web/opendc-web-api/opendc/api/jobs.py +++ /dev/null @@ -1,105 +0,0 @@ -# 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 deleted file mode 100644 index 4d8f54fd..00000000 --- a/opendc-web/opendc-web-api/opendc/api/portfolios.py +++ /dev/null @@ -1,153 +0,0 @@ -# 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 Schema, fields - -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 -from opendc.models.topology import Topology - - -class Portfolio(Resource): - """ - Resource representing a portfolio. - """ - method_decorators = [requires_auth] - - def get(self, portfolio_id): - """ - Get a portfolio by identifier. - """ - portfolio = PortfolioModel.from_id(portfolio_id) - - portfolio.check_exists() - - # 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): - """ - Replace the portfolio. - """ - schema = Portfolio.PutSchema() - result = schema.load(request.json) - - portfolio = PortfolioModel.from_id(portfolio_id) - portfolio.check_exists() - portfolio.check_user_access(current_user['sub'], True) - - portfolio.set_property('name', result['portfolio']['name']) - portfolio.set_property('targets.enabledMetrics', result['portfolio']['targets']['enabledMetrics']) - portfolio.set_property('targets.repeatsPerScenario', result['portfolio']['targets']['repeatsPerScenario']) - - portfolio.update() - data = PortfolioSchema().dump(portfolio.obj) - return {'data': data} - - def delete(self, portfolio_id): - """ - Delete a portfolio. - """ - portfolio = PortfolioModel.from_id(portfolio_id) - - portfolio.check_exists() - portfolio.check_user_access(current_user['sub'], True) - - portfolio_id = portfolio.get_id() - - project = Project.from_id(portfolio.obj['projectId']) - project.check_exists() - if portfolio_id in project.obj['portfolioIds']: - project.obj['portfolioIds'].remove(portfolio_id) - project.update() - - old_object = portfolio.delete() - data = PortfolioSchema().dump(old_object) - return {'data': data} - - class PutSchema(Schema): - """ - Schema for the PUT operation on a portfolio. - """ - portfolio = fields.Nested(PortfolioSchema, required=True) - - -class PortfolioScenarios(Resource): - """ - Resource representing the scenarios of a portfolio. - """ - method_decorators = [requires_auth] - - def get(self, portfolio_id): - """ - Get all scenarios belonging to a portfolio. - """ - portfolio = PortfolioModel.from_id(portfolio_id) - - portfolio.check_exists() - portfolio.check_user_access(current_user['sub'], True) - - scenarios = Scenario.get_for_portfolio(portfolio_id) - - data = ScenarioSchema().dump(scenarios, many=True) - return {'data': data} - - def post(self, portfolio_id): - """ - Add a new scenario to this portfolio - """ - schema = PortfolioScenarios.PostSchema() - result = schema.load(request.json) - - portfolio = PortfolioModel.from_id(portfolio_id) - - portfolio.check_exists() - portfolio.check_user_access(current_user['sub'], True) - - scenario = Scenario(result['scenario']) - - topology = Topology.from_id(scenario.obj['topology']['topologyId']) - topology.check_exists() - topology.check_user_access(current_user['sub'], True) - - scenario.set_property('portfolioId', portfolio.get_id()) - scenario.set_property('simulation', {'state': 'QUEUED'}) - scenario.set_property('topology.topologyId', topology.get_id()) - - scenario.insert() - - portfolio.obj['scenarioIds'].append(scenario.get_id()) - portfolio.update() - data = ScenarioSchema().dump(scenario.obj) - return {'data': data} - - class PostSchema(Schema): - """ - Schema for the POST operation on a portfolio's scenarios. - """ - scenario = fields.Nested(ScenarioSchema, required=True) diff --git a/opendc-web/opendc-web-api/opendc/api/prefabs.py b/opendc-web/opendc-web-api/opendc/api/prefabs.py deleted file mode 100644 index 730546ba..00000000 --- a/opendc-web/opendc-web-api/opendc/api/prefabs.py +++ /dev/null @@ -1,123 +0,0 @@ -# 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 datetime import datetime -from flask import request -from flask_restful import Resource -from marshmallow import Schema, fields - -from opendc.models.prefab import Prefab as PrefabModel, PrefabSchema -from opendc.exts import current_user, requires_auth, db - - -class PrefabList(Resource): - """ - Resource for the list of prefabs available to the user. - """ - method_decorators = [requires_auth] - - def get(self): - """ - Get the available prefabs for a user. - """ - user_id = current_user['sub'] - - own_prefabs = db.fetch_all({'authorId': user_id}, PrefabModel.collection_name) - public_prefabs = db.fetch_all({'visibility': 'public'}, PrefabModel.collection_name) - - authorizations = {"authorizations": []} - authorizations["authorizations"].append(own_prefabs) - authorizations["authorizations"].append(public_prefabs) - return {'data': authorizations} - - def post(self): - """ - Create a new prefab. - """ - schema = PrefabList.PostSchema() - result = schema.load(request.json) - - prefab = PrefabModel(result['prefab']) - 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() - data = PrefabSchema().dump(prefab.obj) - return {'data': data} - - class PostSchema(Schema): - """ - Schema for the POST operation on the prefab list. - """ - prefab = fields.Nested(PrefabSchema, required=True) - - -class Prefab(Resource): - """ - Resource representing a single prefab. - """ - method_decorators = [requires_auth] - - def get(self, prefab_id): - """Get this Prefab.""" - prefab = PrefabModel.from_id(prefab_id) - prefab.check_exists() - prefab.check_user_access(current_user['sub']) - data = PrefabSchema().dump(prefab.obj) - return {'data': data} - - def put(self, prefab_id): - """Update a prefab's name and/or contents.""" - - schema = Prefab.PutSchema() - result = schema.load(request.json) - - prefab = PrefabModel.from_id(prefab_id) - prefab.check_exists() - prefab.check_user_access(current_user['sub']) - - prefab.set_property('name', result['prefab']['name']) - prefab.set_property('rack', result['prefab']['rack']) - prefab.set_property('datetimeLastEdited', datetime.now()) - prefab.update() - - data = PrefabSchema().dump(prefab.obj) - return {'data': data} - - def delete(self, prefab_id): - """Delete this Prefab.""" - prefab = PrefabModel.from_id(prefab_id) - - prefab.check_exists() - prefab.check_user_access(current_user['sub']) - - old_object = prefab.delete() - - data = PrefabSchema().dump(old_object) - return {'data': data} - - class PutSchema(Schema): - """ - Schema for the PUT operation on a prefab. - """ - prefab = fields.Nested(PrefabSchema, required=True) diff --git a/opendc-web/opendc-web-api/opendc/api/projects.py b/opendc-web/opendc-web-api/opendc/api/projects.py deleted file mode 100644 index 2b47c12e..00000000 --- a/opendc-web/opendc-web-api/opendc/api/projects.py +++ /dev/null @@ -1,224 +0,0 @@ -# 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 datetime import datetime -from flask import request -from flask_restful import Resource -from marshmallow import Schema, fields - -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 - - -class ProjectList(Resource): - """ - Resource representing the list of projects available to a user. - """ - method_decorators = [requires_auth] - - def get(self): - """Get the authorized projects of the user""" - user_id = current_user['sub'] - projects = ProjectModel.get_for_user(user_id) - data = ProjectSchema().dump(projects, many=True) - return {'data': data} - - def post(self): - """Create a new project, and return that new project.""" - user_id = current_user['sub'] - - schema = Project.PutSchema() - result = schema.load(request.json) - - topology = Topology({'name': 'Default topology', 'rooms': []}) - topology.insert() - - project = ProjectModel(result['project']) - 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'}]) - project.insert() - - topology.set_property('projectId', project.get_id()) - topology.update() - - data = ProjectSchema().dump(project.obj) - return {'data': data} - - -class Project(Resource): - """ - Resource representing a single project. - """ - method_decorators = [requires_auth] - - def get(self, project_id): - """Get this Project.""" - project = ProjectModel.from_id(project_id) - - project.check_exists() - project.check_user_access(current_user['sub'], False) - - data = ProjectSchema().dump(project.obj) - return {'data': data} - - def put(self, project_id): - """Update a project's name.""" - schema = Project.PutSchema() - result = schema.load(request.json) - - project = ProjectModel.from_id(project_id) - - project.check_exists() - project.check_user_access(current_user['sub'], True) - - project.set_property('name', result['project']['name']) - project.set_property('datetimeLastEdited', datetime.now()) - project.update() - - data = ProjectSchema().dump(project.obj) - return {'data': data} - - def delete(self, project_id): - """Delete this Project.""" - project = ProjectModel.from_id(project_id) - - project.check_exists() - project.check_user_access(current_user['sub'], True) - - for topology_id in project.obj['topologyIds']: - topology = Topology.from_id(topology_id) - topology.delete() - - for portfolio_id in project.obj['portfolioIds']: - portfolio = Portfolio.from_id(portfolio_id) - portfolio.delete() - - old_object = project.delete() - data = ProjectSchema().dump(old_object) - return {'data': data} - - class PutSchema(Schema): - """ - Schema for the PUT operation on a project. - """ - project = fields.Nested(ProjectSchema, required=True) - - -class ProjectTopologies(Resource): - """ - Resource representing the topologies of a project. - """ - method_decorators = [requires_auth] - - def get(self, project_id): - """Get all topologies belonging to the project.""" - project = ProjectModel.from_id(project_id) - - project.check_exists() - project.check_user_access(current_user['sub'], True) - - topologies = Topology.get_for_project(project_id) - data = TopologySchema().dump(topologies, many=True) - - return {'data': data} - - def post(self, project_id): - """Add a new Topology to the specified project and return it""" - schema = ProjectTopologies.PutSchema() - result = schema.load(request.json) - - project = ProjectModel.from_id(project_id) - - project.check_exists() - project.check_user_access(current_user['sub'], True) - - topology = Topology({ - 'projectId': project.get_id(), - 'name': result['topology']['name'], - 'rooms': result['topology']['rooms'], - }) - - topology.insert() - - project.obj['topologyIds'].append(topology.get_id()) - project.set_property('datetimeLastEdited', datetime.now()) - project.update() - - data = TopologySchema().dump(topology.obj) - return {'data': data} - - class PutSchema(Schema): - """ - Schema for the PUT operation on a project topology. - """ - topology = fields.Nested(TopologySchema, required=True) - - -class ProjectPortfolios(Resource): - """ - Resource representing the portfolios of a project. - """ - method_decorators = [requires_auth] - - def get(self, project_id): - """Get all portfolios belonging to the project.""" - project = ProjectModel.from_id(project_id) - - project.check_exists() - project.check_user_access(current_user['sub'], True) - - portfolios = Portfolio.get_for_project(project_id) - data = PortfolioSchema().dump(portfolios, many=True) - - return {'data': data} - - def post(self, project_id): - """Add a new Portfolio for this Project.""" - schema = ProjectPortfolios.PutSchema() - result = schema.load(request.json) - - project = ProjectModel.from_id(project_id) - - project.check_exists() - project.check_user_access(current_user['sub'], True) - - portfolio = Portfolio(result['portfolio']) - - portfolio.set_property('projectId', project.get_id()) - portfolio.set_property('scenarioIds', []) - - portfolio.insert() - - project.obj['portfolioIds'].append(portfolio.get_id()) - project.update() - - data = PortfolioSchema().dump(portfolio.obj) - return {'data': data} - - class PutSchema(Schema): - """ - Schema for the PUT operation on a project portfolio. - """ - portfolio = fields.Nested(PortfolioSchema, required=True) diff --git a/opendc-web/opendc-web-api/opendc/api/scenarios.py b/opendc-web/opendc-web-api/opendc/api/scenarios.py deleted file mode 100644 index eacb0b49..00000000 --- a/opendc-web/opendc-web-api/opendc/api/scenarios.py +++ /dev/null @@ -1,86 +0,0 @@ -# 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 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, has_scope - - -class Scenario(Resource): - """ - A Scenario resource. - """ - method_decorators = [requires_auth] - - def get(self, scenario_id): - """Get scenario by identifier.""" - scenario = ScenarioModel.from_id(scenario_id) - scenario.check_exists() - - # 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): - """Update this Scenarios name.""" - schema = Scenario.PutSchema() - result = schema.load(request.json) - - scenario = ScenarioModel.from_id(scenario_id) - - scenario.check_exists() - scenario.check_user_access(current_user['sub'], True) - - scenario.set_property('name', result['scenario']['name']) - - scenario.update() - data = ScenarioSchema().dump(scenario.obj) - return {'data': data} - - def delete(self, scenario_id): - """Delete this Scenario.""" - scenario = ScenarioModel.from_id(scenario_id) - scenario.check_exists() - scenario.check_user_access(current_user['sub'], True) - - scenario_id = scenario.get_id() - - portfolio = Portfolio.from_id(scenario.obj['portfolioId']) - portfolio.check_exists() - if scenario_id in portfolio.obj['scenarioIds']: - portfolio.obj['scenarioIds'].remove(scenario_id) - portfolio.update() - - old_object = scenario.delete() - data = ScenarioSchema().dump(old_object) - return {'data': data} - - class PutSchema(Schema): - """ - Schema for the put operation. - """ - scenario = fields.Nested(ScenarioSchema, required=True) diff --git a/opendc-web/opendc-web-api/opendc/api/schedulers.py b/opendc-web/opendc-web-api/opendc/api/schedulers.py deleted file mode 100644 index b00d8c31..00000000 --- a/opendc-web/opendc-web-api/opendc/api/schedulers.py +++ /dev/null @@ -1,46 +0,0 @@ -# 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_restful import Resource -from opendc.exts import requires_auth - -SCHEDULERS = [ - 'mem', - 'mem-inv', - 'core-mem', - 'core-mem-inv', - 'active-servers', - 'active-servers-inv', - 'provisioned-cores', - 'provisioned-cores-inv', - 'random' -] - - -class SchedulerList(Resource): - """ - Resource for the list of schedulers to pick from. - """ - method_decorators = [requires_auth] - - def get(self): - """Get all available Traces.""" - return {'data': [{'name': name} for name in SCHEDULERS]} diff --git a/opendc-web/opendc-web-api/opendc/api/topologies.py b/opendc-web/opendc-web-api/opendc/api/topologies.py deleted file mode 100644 index c0b2e7ee..00000000 --- a/opendc-web/opendc-web-api/opendc/api/topologies.py +++ /dev/null @@ -1,97 +0,0 @@ -# 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 datetime import datetime - -from flask import request -from flask_restful import Resource -from marshmallow import Schema, fields - -from opendc.models.project import Project -from opendc.models.topology import Topology as TopologyModel, TopologySchema -from opendc.exts import current_user, requires_auth, has_scope - - -class Topology(Resource): - """ - Resource representing a single topology. - """ - method_decorators = [requires_auth] - - def get(self, topology_id): - """ - Get a single topology. - """ - topology = TopologyModel.from_id(topology_id) - topology.check_exists() - - # 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): - """ - Replace the topology. - """ - topology = TopologyModel.from_id(topology_id) - - schema = Topology.PutSchema() - result = schema.load(request.json) - - topology.check_exists() - topology.check_user_access(current_user['sub'], True) - - topology.set_property('name', result['topology']['name']) - topology.set_property('rooms', result['topology']['rooms']) - topology.set_property('datetimeLastEdited', datetime.now()) - - topology.update() - data = TopologySchema().dump(topology.obj) - return {'data': data} - - def delete(self, topology_id): - """ - Delete a topology. - """ - topology = TopologyModel.from_id(topology_id) - - topology.check_exists() - topology.check_user_access(current_user['sub'], True) - - topology_id = topology.get_id() - - project = Project.from_id(topology.obj['projectId']) - project.check_exists() - if topology_id in project.obj['topologyIds']: - project.obj['topologyIds'].remove(topology_id) - project.update() - - old_object = topology.delete() - data = TopologySchema().dump(old_object) - return {'data': data} - - class PutSchema(Schema): - """ - Schema for the PUT operation on a topology. - """ - topology = fields.Nested(TopologySchema, required=True) diff --git a/opendc-web/opendc-web-api/opendc/api/traces.py b/opendc-web/opendc-web-api/opendc/api/traces.py deleted file mode 100644 index 6be8c5e5..00000000 --- a/opendc-web/opendc-web-api/opendc/api/traces.py +++ /dev/null @@ -1,51 +0,0 @@ -# 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_restful import Resource - -from opendc.exts import requires_auth -from opendc.models.trace import Trace as TraceModel, TraceSchema - - -class TraceList(Resource): - """ - Resource for the list of traces to pick from. - """ - method_decorators = [requires_auth] - - def get(self): - """Get all available Traces.""" - traces = TraceModel.get_all() - data = TraceSchema().dump(traces.obj, many=True) - return {'data': data} - - -class Trace(Resource): - """ - Resource representing a single trace. - """ - method_decorators = [requires_auth] - - def get(self, trace_id): - """Get trace information by identifier.""" - trace = TraceModel.from_id(trace_id) - trace.check_exists() - 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 deleted file mode 100644 index d5da6ee5..00000000 --- a/opendc-web/opendc-web-api/opendc/auth.py +++ /dev/null @@ -1,236 +0,0 @@ -# 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. - -import json -import time - -import urllib3 -from flask import request -from jose import jwt, JWTError - - -def get_token(): - """ - Obtain the Access Token from the Authorization Header - """ - auth = request.headers.get("Authorization", None) - if not auth: - raise AuthError({ - "code": "authorization_header_missing", - "description": "Authorization header is expected" - }, 401) - - parts = auth.split() - - if parts[0].lower() != "bearer": - raise AuthError({"code": "invalid_header", "description": "Authorization header must start with Bearer"}, 401) - if len(parts) == 1: - raise AuthError({"code": "invalid_header", "description": "Token not found"}, 401) - if len(parts) > 2: - raise AuthError({"code": "invalid_header", "description": "Authorization header must be" " Bearer token"}, 401) - - token = parts[1] - return token - - -class AuthError(Exception): - """ - This error is thrown when the request failed to authorize. - """ - def __init__(self, error, status_code): - Exception.__init__(self, error) - self.error = error - self.status_code = status_code - - -class AuthContext: - """ - This class handles the authorization of requests. - """ - def __init__(self, alg, issuer, audience): - self._alg = alg - self._issuer = issuer - self._audience = audience - - def validate(self, token): - """ - Validate the specified JWT token. - :param token: The authorization token specified by the user. - :return: The token payload on success, otherwise `AuthError`. - """ - try: - header = jwt.get_unverified_header(token) - except JWTError as e: - raise AuthError({"code": "invalid_token", "message": str(e)}, 401) - - alg = header.get('alg', None) - if alg != self._alg.algorithm: - raise AuthError( - { - "code": - "invalid_header", - "message": - f"Signature algorithm of {alg} is not supported. Expected the ID token " - f"to be signed with {self._alg.algorithm}" - }, 401) - - kid = header.get('kid', None) - try: - secret_or_certificate = self._alg.get_key(key_id=kid) - except TokenValidationError as e: - raise AuthError({"code": "invalid_header", "message": str(e)}, 401) - try: - payload = jwt.decode(token, - key=secret_or_certificate, - algorithms=[self._alg.algorithm], - audience=self._audience, - issuer=self._issuer) - return payload - except jwt.ExpiredSignatureError: - raise AuthError({"code": "token_expired", "message": "Token is expired"}, 401) - except jwt.JWTClaimsError: - raise AuthError( - { - "code": "invalid_claims", - "message": "Incorrect claims, please check the audience and issuer" - }, 401) - except Exception as e: - print(e) - raise AuthError({"code": "invalid_header", "message": "Unable to parse authentication token."}, 401) - - -class SymmetricJwtAlgorithm: - """Verifier for HMAC signatures, which rely on shared secrets. - Args: - shared_secret (str): The shared secret used to decode the token. - algorithm (str, optional): The expected signing algorithm. Defaults to "HS256". - """ - def __init__(self, shared_secret, algorithm="HS256"): - self.algorithm = algorithm - self._shared_secret = shared_secret - - # pylint: disable=W0613 - def get_key(self, key_id=None): - """ - Obtain the key for this algorithm. - :param key_id: The identifier of the key. - :return: The JWK key. - """ - return self._shared_secret - - -class AsymmetricJwtAlgorithm: - """Verifier for RSA signatures, which rely on public key certificates. - Args: - jwks_url (str): The url where the JWK set is located. - algorithm (str, optional): The expected signing algorithm. Defaults to "RS256". - """ - def __init__(self, jwks_url, algorithm="RS256"): - self.algorithm = algorithm - self._fetcher = JwksFetcher(jwks_url) - - def get_key(self, key_id=None): - """ - Obtain the key for this algorithm. - :param key_id: The identifier of the key. - :return: The JWK key. - """ - return self._fetcher.get_key(key_id) - - -class TokenValidationError(Exception): - """ - Error thrown when the token cannot be validated - """ - - -class JwksFetcher: - """Class that fetches and holds a JSON web key set. - This class makes use of an in-memory cache. For it to work properly, define this instance once and re-use it. - Args: - jwks_url (str): The url where the JWK set is located. - cache_ttl (str, optional): The lifetime of the JWK set cache in seconds. Defaults to 600 seconds. - """ - CACHE_TTL = 600 # 10 min cache lifetime - - def __init__(self, jwks_url, cache_ttl=CACHE_TTL): - self._jwks_url = jwks_url - self._http = urllib3.PoolManager() - self._cache_value = {} - self._cache_date = 0 - self._cache_ttl = cache_ttl - self._cache_is_fresh = False - - def _fetch_jwks(self, force=False): - """Attempts to obtain the JWK set from the cache, as long as it's still valid. - When not, it will perform a network request to the jwks_url to obtain a fresh result - and update the cache value with it. - Args: - force (bool, optional): whether to ignore the cache and force a network request or not. Defaults to False. - """ - has_expired = self._cache_date + self._cache_ttl < time.time() - - if not force and not has_expired: - # Return from cache - self._cache_is_fresh = False - return self._cache_value - - # Invalidate cache and fetch fresh data - self._cache_value = {} - response = self._http.request('GET', self._jwks_url) - - if response.status == 200: - # Update cache - jwks = json.loads(response.data.decode('utf-8')) - self._cache_value = self._parse_jwks(jwks) - self._cache_is_fresh = True - self._cache_date = time.time() - return self._cache_value - - @staticmethod - def _parse_jwks(jwks): - """Converts a JWK string representation into a binary certificate in PEM format. - """ - keys = {} - - for key in jwks['keys']: - keys[key["kid"]] = key - return keys - - def get_key(self, key_id): - """Obtains the JWK associated with the given key id. - Args: - key_id (str): The id of the key to fetch. - Returns: - the JWK associated with the given key id. - - Raises: - TokenValidationError: when a key with that id cannot be found - """ - keys = self._fetch_jwks() - - if keys and key_id in keys: - return keys[key_id] - - if not self._cache_is_fresh: - keys = self._fetch_jwks(force=True) - if keys and key_id in keys: - return keys[key_id] - raise TokenValidationError(f"RSA Public Key with ID {key_id} was not found.") diff --git a/opendc-web/opendc-web-api/opendc/database.py b/opendc-web/opendc-web-api/opendc/database.py deleted file mode 100644 index dd6367f2..00000000 --- a/opendc-web/opendc-web-api/opendc/database.py +++ /dev/null @@ -1,97 +0,0 @@ -# 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. - -import urllib.parse - -from pymongo import MongoClient, ReturnDocument - -DATETIME_STRING_FORMAT = '%Y-%m-%dT%H:%M:%S' -CONNECTION_POOL = None - - -class Database: - """Object holding functionality for database access.""" - def __init__(self, db=None): - """Initializes the database connection.""" - self.opendc_db = db - - @classmethod - def from_credentials(cls, user, password, database, host): - """ - Construct a database instance from the specified credentials. - :param user: The username to connect with. - :param password: The password to connect with. - :param database: The database name to connect to. - :param host: The host to connect to. - :return: The database instance. - """ - user = urllib.parse.quote_plus(user) - password = urllib.parse.quote_plus(password) - database = urllib.parse.quote_plus(database) - host = urllib.parse.quote_plus(host) - - client = MongoClient('mongodb://%s:%s@%s/default_db?authSource=%s' % (user, password, host, database)) - return cls(client.opendc) - - def fetch_one(self, query, collection): - """Uses existing mongo connection to return a single (the first) document in a collection matching the given - query as a JSON object. - - The query needs to be in json format, i.e.: `{'name': prefab_name}`. - """ - return getattr(self.opendc_db, collection).find_one(query) - - def fetch_all(self, query, collection): - """Uses existing mongo connection to return all documents matching a given query, as a list of JSON objects. - - The query needs to be in json format, i.e.: `{'name': prefab_name}`. - """ - cursor = getattr(self.opendc_db, collection).find(query) - return list(cursor) - - def insert(self, obj, collection): - """Updates an existing object.""" - bson = getattr(self.opendc_db, collection).insert(obj) - - return bson - - def update(self, _id, obj, collection): - """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. - - The query needs to be in json format, i.e.: `{'name': prefab_name}`. - """ - getattr(self.opendc_db, collection).delete_one(query) - - def delete_all(self, query, collection): - """Deletes all objects matching the given query. - - The query needs to be in json format, i.e.: `{'name': prefab_name}`. - """ - getattr(self.opendc_db, collection).delete_many(query) diff --git a/opendc-web/opendc-web-api/opendc/exts.py b/opendc-web/opendc-web-api/opendc/exts.py deleted file mode 100644 index 3ee8babb..00000000 --- a/opendc-web/opendc-web-api/opendc/exts.py +++ /dev/null @@ -1,91 +0,0 @@ -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, AuthError - - -def get_db(): - """ - Return the configured database instance for the application. - """ - _db = getattr(g, 'db', None) - if _db is None: - _db = Database.from_credentials(user=os.environ['OPENDC_DB_USERNAME'], - password=os.environ['OPENDC_DB_PASSWORD'], - database=os.environ['OPENDC_DB'], - host=os.environ.get('OPENDC_DB_HOST', 'localhost')) - g.db = _db - return _db - - -db = LocalProxy(get_db) - - -def get_auth_context(): - """ - Return the configured auth context for the application. - """ - _auth_context = getattr(g, 'auth_context', None) - if _auth_context is None: - _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']) - g.auth_context = _auth_context - return _auth_context - - -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() - payload = auth_context.validate(token) - _request_ctx_stack.top.current_user = payload - return f(*args, **kwargs) - - return decorated - - -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/__init__.py b/opendc-web/opendc-web-api/opendc/models/__init__.py deleted file mode 100644 index e69de29b..00000000 --- a/opendc-web/opendc-web-api/opendc/models/__init__.py +++ /dev/null diff --git a/opendc-web/opendc-web-api/opendc/models/model.py b/opendc-web/opendc-web-api/opendc/models/model.py deleted file mode 100644 index 28299453..00000000 --- a/opendc-web/opendc-web-api/opendc/models/model.py +++ /dev/null @@ -1,61 +0,0 @@ -from bson.objectid import ObjectId -from werkzeug.exceptions import NotFound - -from opendc.exts import db - - -class Model: - """Base class for all models.""" - - collection_name = '<specified in subclasses>' - - @classmethod - def from_id(cls, _id): - """Fetches the document with given ID from the collection.""" - if isinstance(_id, str) and len(_id) == 24: - _id = ObjectId(_id) - - return cls(db.fetch_one({'_id': _id}, cls.collection_name)) - - @classmethod - def get_all(cls): - """Fetches all documents from the collection.""" - return cls(db.fetch_all({}, cls.collection_name)) - - def __init__(self, obj): - self.obj = obj - - def get_id(self): - """Returns the ID of the enclosed object.""" - return self.obj['_id'] - - def check_exists(self): - """Raises an error if the enclosed object does not exist.""" - if self.obj is None: - raise NotFound('Entity not found.') - - def set_property(self, key, value): - """Sets the given property on the enclosed object, with support for simple nested access.""" - if '.' in key: - keys = key.split('.') - self.obj[keys[0]][keys[1]] = value - else: - self.obj[key] = value - - def insert(self): - """Inserts the enclosed object and generates a UUID for it.""" - self.obj['_id'] = ObjectId() - db.insert(self.obj, self.collection_name) - - def update(self): - """Updates the enclosed object and updates the internal reference to the newly inserted object.""" - db.update(self.get_id(), self.obj, self.collection_name) - - def delete(self): - """Deletes the enclosed object in the database, if it existed.""" - if self.obj is None: - return None - - old_object = self.obj.copy() - db.delete_one({'_id': self.get_id()}, self.collection_name) - return old_object diff --git a/opendc-web/opendc-web-api/opendc/models/portfolio.py b/opendc-web/opendc-web-api/opendc/models/portfolio.py deleted file mode 100644 index eb016947..00000000 --- a/opendc-web/opendc-web-api/opendc/models/portfolio.py +++ /dev/null @@ -1,47 +0,0 @@ -from bson import ObjectId -from marshmallow import Schema, fields - -from opendc.exts import db -from opendc.models.project import Project -from opendc.models.model import Model - - -class TargetSchema(Schema): - """ - Schema representing a target. - """ - enabledMetrics = fields.List(fields.String()) - repeatsPerScenario = fields.Integer(required=True) - - -class PortfolioSchema(Schema): - """ - Schema representing a portfolio. - """ - _id = fields.String(dump_only=True) - projectId = fields.String() - name = fields.String(required=True) - scenarioIds = fields.List(fields.String()) - targets = fields.Nested(TargetSchema) - - -class Portfolio(Model): - """Model representing a Portfolio.""" - - collection_name = 'portfolios' - - def check_user_access(self, user_id, edit_access): - """Raises an error if the user with given [user_id] has insufficient access. - - Checks access on the parent project. - - :param user_id: The User ID of the user. - :param edit_access: True when edit access should be checked, otherwise view access. - """ - project = Project.from_id(self.obj['projectId']) - project.check_user_access(user_id, edit_access) - - @classmethod - def get_for_project(cls, project_id): - """Get all portfolios for the specified project id.""" - return db.fetch_all({'projectId': ObjectId(project_id)}, cls.collection_name) diff --git a/opendc-web/opendc-web-api/opendc/models/prefab.py b/opendc-web/opendc-web-api/opendc/models/prefab.py deleted file mode 100644 index 5e4b81dc..00000000 --- a/opendc-web/opendc-web-api/opendc/models/prefab.py +++ /dev/null @@ -1,31 +0,0 @@ -from marshmallow import Schema, fields -from werkzeug.exceptions import Forbidden - -from opendc.models.topology import ObjectSchema -from opendc.models.model import Model - - -class PrefabSchema(Schema): - """ - Schema for a Prefab. - """ - _id = fields.String(dump_only=True) - authorId = fields.String(dump_only=True) - name = fields.String(required=True) - datetimeCreated = fields.DateTime() - datetimeLastEdited = fields.DateTime() - rack = fields.Nested(ObjectSchema) - - -class Prefab(Model): - """Model representing a Prefab.""" - - collection_name = 'prefabs' - - def check_user_access(self, user_id): - """Raises an error if the user with given [user_id] has insufficient access to view this prefab. - - :param user_id: The user ID of the user. - """ - if self.obj['authorId'] != user_id and self.obj['visibility'] == "private": - raise Forbidden("Forbidden from retrieving prefab.") diff --git a/opendc-web/opendc-web-api/opendc/models/project.py b/opendc-web/opendc-web-api/opendc/models/project.py deleted file mode 100644 index f2b3b564..00000000 --- a/opendc-web/opendc-web-api/opendc/models/project.py +++ /dev/null @@ -1,48 +0,0 @@ -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(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): - """Model representing a Project.""" - - collection_name = 'projects' - - def check_user_access(self, user_id, edit_access): - """Raises an error if the user with given [user_id] has insufficient access. - - :param user_id: The User ID of the user. - :param edit_access: True when edit access should be checked, otherwise view access. - """ - for authorization in self.obj['authorizations']: - if user_id == authorization['userId'] and authorization['level'] != 'VIEW' or not edit_access: - return - raise Forbidden("Forbidden from retrieving project.") - - @classmethod - def get_for_user(cls, user_id): - """Get all projects for the specified user id.""" - return db.fetch_all({'authorizations.userId': user_id}, Project.collection_name) diff --git a/opendc-web/opendc-web-api/opendc/models/scenario.py b/opendc-web/opendc-web-api/opendc/models/scenario.py deleted file mode 100644 index 47771e06..00000000 --- a/opendc-web/opendc-web-api/opendc/models/scenario.py +++ /dev/null @@ -1,93 +0,0 @@ -from datetime import datetime - -from bson import ObjectId -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. - """ - traceId = fields.String() - loadSamplingFraction = fields.Float() - - -class TopologySchema(Schema): - """ - Schema for topology specification for a scenario. - """ - topologyId = fields.String() - - -class OperationalSchema(Schema): - """ - Schema for the operational phenomena for a scenario. - """ - failuresEnabled = fields.Boolean() - performanceInterferenceEnabled = fields.Boolean() - schedulerName = fields.String() - - -class ScenarioSchema(Schema): - """ - Schema representing a scenario. - """ - _id = fields.String(dump_only=True) - portfolioId = fields.String() - name = fields.String(required=True) - trace = fields.Nested(TraceSchema) - topology = fields.Nested(TopologySchema) - operational = fields.Nested(OperationalSchema) - simulation = fields.Nested(SimulationSchema, dump_only=True) - results = fields.Dict(dump_only=True) - - -class Scenario(Model): - """Model representing a Scenario.""" - - collection_name = 'scenarios' - - def check_user_access(self, user_id, edit_access): - """Raises an error if the user with given [user_id] has insufficient access. - - Checks access on the parent project. - - :param user_id: The User ID of the user. - :param edit_access: True when edit access should be checked, otherwise view access. - """ - 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)) - - @classmethod - def get_for_portfolio(cls, portfolio_id): - """Get all scenarios for the specified portfolio id.""" - return db.fetch_all({'portfolioId': ObjectId(portfolio_id)}, 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 deleted file mode 100644 index 44994818..00000000 --- a/opendc-web/opendc-web-api/opendc/models/topology.py +++ /dev/null @@ -1,108 +0,0 @@ -from bson import ObjectId -from marshmallow import Schema, fields - -from opendc.exts import db -from opendc.models.project import Project -from opendc.models.model import Model - - -class MemorySchema(Schema): - """ - Schema representing a memory unit. - """ - _id = fields.String() - name = fields.String() - speedMbPerS = fields.Integer() - sizeMb = fields.Integer() - energyConsumptionW = fields.Integer() - - -class PuSchema(Schema): - """ - Schema representing a processing unit. - """ - _id = fields.String() - name = fields.String() - clockRateMhz = fields.Integer() - numberOfCores = fields.Integer() - energyConsumptionW = fields.Integer() - - -class MachineSchema(Schema): - """ - Schema representing a machine. - """ - _id = fields.String() - position = fields.Integer() - cpus = fields.List(fields.Nested(PuSchema)) - gpus = fields.List(fields.Nested(PuSchema)) - memories = fields.List(fields.Nested(MemorySchema)) - storages = fields.List(fields.Nested(MemorySchema)) - rackId = fields.String() - - -class ObjectSchema(Schema): - """ - Schema representing a room object. - """ - _id = fields.String() - name = fields.String() - capacity = fields.Integer() - powerCapacityW = fields.Integer() - machines = fields.List(fields.Nested(MachineSchema)) - tileId = fields.String() - - -class TileSchema(Schema): - """ - Schema representing a room tile. - """ - _id = fields.String() - topologyId = fields.String() - positionX = fields.Integer() - positionY = fields.Integer() - rack = fields.Nested(ObjectSchema) - roomId = fields.String() - - -class RoomSchema(Schema): - """ - Schema representing a room. - """ - _id = fields.String() - name = fields.String(required=True) - topologyId = fields.String() - tiles = fields.List(fields.Nested(TileSchema), required=True) - - -class TopologySchema(Schema): - """ - Schema representing a datacenter topology. - """ - _id = fields.String(dump_only=True) - projectId = fields.String() - name = fields.String(required=True) - rooms = fields.List(fields.Nested(RoomSchema), required=True) - datetimeLastEdited = fields.DateTime() - - -class Topology(Model): - """Model representing a Project.""" - - collection_name = 'topologies' - - def check_user_access(self, user_id, edit_access): - """Raises an error if the user with given [user_id] has insufficient access. - - Checks access on the parent project. - - :param user_id: The User ID of the user. - :param edit_access: True when edit access should be checked, otherwise view access. - """ - project = Project.from_id(self.obj['projectId']) - project.check_user_access(user_id, edit_access) - - @classmethod - def get_for_project(cls, project_id): - """Get all topologies for the specified project id.""" - return db.fetch_all({'projectId': ObjectId(project_id)}, cls.collection_name) diff --git a/opendc-web/opendc-web-api/opendc/models/trace.py b/opendc-web/opendc-web-api/opendc/models/trace.py deleted file mode 100644 index 69287f29..00000000 --- a/opendc-web/opendc-web-api/opendc/models/trace.py +++ /dev/null @@ -1,16 +0,0 @@ -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.""" - - collection_name = 'traces' diff --git a/opendc-web/opendc-web-api/opendc/util.py b/opendc-web/opendc-web-api/opendc/util.py deleted file mode 100644 index e7dc07a4..00000000 --- a/opendc-web/opendc-web-api/opendc/util.py +++ /dev/null @@ -1,32 +0,0 @@ -# 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. - -import flask -from bson.objectid import ObjectId - - -class JSONEncoder(flask.json.JSONEncoder): - """ - A customized JSON encoder to handle unsupported types. - """ - def default(self, o): - if isinstance(o, ObjectId): - return str(o) - return flask.json.JSONEncoder.default(self, o) diff --git a/opendc-web/opendc-web-api/pytest.ini b/opendc-web/opendc-web-api/pytest.ini deleted file mode 100644 index 8e7964ba..00000000 --- a/opendc-web/opendc-web-api/pytest.ini +++ /dev/null @@ -1,5 +0,0 @@ -[pytest] -env = - OPENDC_FLASK_TESTING=True - OPENDC_FLASK_SECRET=Secret -junit_family = xunit2 diff --git a/opendc-web/opendc-web-api/requirements.txt b/opendc-web/opendc-web-api/requirements.txt deleted file mode 100644 index 6f3b42aa..00000000 --- a/opendc-web/opendc-web-api/requirements.txt +++ /dev/null @@ -1,47 +0,0 @@ -astroid==2.4.2 -attrs==20.3.0 -blinker==1.4 -Brotli==1.0.9 -certifi==2020.11.8 -click==7.1.2 -eventlet==0.31.0 -Flask==1.1.2 -Flask-Compress==1.5.0 -Flask-Cors==3.0.9 -Flask-SocketIO==4.3.1 -flask-swagger-ui==3.36.0 -Flask-Restful==0.3.8 -httplib2==0.19.0 -isort==4.3.21 -itsdangerous==1.1.0 -Jinja2==2.11.3 -lazy-object-proxy==1.4.3 -MarkupSafe==1.1.1 -marshmallow==3.12.1 -mccabe==0.6.1 -monotonic==1.5 -more-itertools==8.6.0 -oauth2client==4.1.3 -packaging==20.4 -pluggy==0.13.1 -py==1.10.0 -pyasn1==0.4.8 -pyasn1-modules==0.2.8 -pylint==2.5.3 -pymongo==3.10.1 -pyparsing==2.4.7 -pytest==5.4.3 -pytest-cov==2.11.1 -pytest-env==0.6.2 -pytest-mock==3.2.0 -python-dotenv==0.14.0 -python-jose==3.2.0 -rsa==4.7 -sentry-sdk==0.19.2 -six==1.15.0 -toml==0.10.2 -urllib3==1.26.5 -wcwidth==0.2.5 -Werkzeug==1.0.1 -wrapt==1.12.1 -yapf==0.30.0 diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/SimulationState.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/OpenDCApplication.kt index 2eadd747..ddbd5390 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/SimulationState.kt +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/OpenDCApplication.kt @@ -20,11 +20,11 @@ * SOFTWARE. */ -package org.opendc.web.client.model +package org.opendc.web.api + +import javax.ws.rs.core.Application /** - * The state of a simulation job. + * [Application] definition for the OpenDC web API. */ -public enum class SimulationState { - QUEUED, CLAIMED, RUNNING, FINISHED, FAILED -} +class OpenDCApplication : Application() diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Job.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Job.kt new file mode 100644 index 00000000..b09b46a1 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Job.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.model + +import org.hibernate.annotations.Type +import org.hibernate.annotations.TypeDef +import org.opendc.web.api.util.hibernate.json.JsonType +import org.opendc.web.proto.JobState +import java.time.Instant +import javax.persistence.* + +/** + * A simulation job to be run by the simulator. + */ +@TypeDef(name = "json", typeClass = JsonType::class) +@Entity +@Table(name = "jobs") +@NamedQueries( + value = [ + NamedQuery( + name = "Job.findAll", + query = "SELECT j FROM Job j WHERE j.state = :state" + ), + NamedQuery( + name = "Job.updateOne", + query = """ + UPDATE Job j + SET j.state = :newState, j.updatedAt = :updatedAt, j.results = :results + WHERE j.id = :id AND j.state = :oldState + """ + ) + ] +) +class Job( + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + val id: Long, + + @OneToOne(optional = false, mappedBy = "job", fetch = FetchType.EAGER) + @JoinColumn(name = "scenario_id", nullable = false) + val scenario: Scenario, + + @Column(name = "created_at", nullable = false, updatable = false) + val createdAt: Instant, + + /** + * The number of simulation runs to perform. + */ + @Column(nullable = false, updatable = false) + val repeats: Int +) { + /** + * The instant at which the job was updated. + */ + @Column(name = "updated_at", nullable = false) + var updatedAt: Instant = createdAt + + /** + * The state of the job. + */ + @Column(nullable = false) + var state: JobState = JobState.PENDING + + /** + * Experiment results in JSON + */ + @Type(type = "json") + @Column(columnDefinition = "jsonb") + var results: Map<String, Any>? = null + + /** + * Return a string representation of this job. + */ + override fun toString(): String = "Job[id=$id,scenario=${scenario.id},state=$state]" +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Portfolio.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Portfolio.kt new file mode 100644 index 00000000..c8b94daf --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Portfolio.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.model + +import org.hibernate.annotations.Type +import org.hibernate.annotations.TypeDef +import org.opendc.web.api.util.hibernate.json.JsonType +import org.opendc.web.proto.Targets +import javax.persistence.* + +/** + * A portfolio is the composition of multiple scenarios. + */ +@TypeDef(name = "json", typeClass = JsonType::class) +@Entity +@Table( + name = "portfolios", + uniqueConstraints = [UniqueConstraint(columnNames = ["project_id", "number"])], + indexes = [Index(name = "fn_portfolios_number", columnList = "project_id, number")] +) +@NamedQueries( + value = [ + NamedQuery( + name = "Portfolio.findAll", + query = "SELECT p FROM Portfolio p WHERE p.project.id = :projectId" + ), + NamedQuery( + name = "Portfolio.findOne", + query = "SELECT p FROM Portfolio p WHERE p.project.id = :projectId AND p.number = :number" + ) + ] +) +class Portfolio( + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + val id: Long, + + /** + * Unique number of the portfolio for the project. + */ + @Column(nullable = false) + val number: Int, + + @Column(nullable = false) + val name: String, + + @ManyToOne(optional = false) + @JoinColumn(name = "project_id", nullable = false) + val project: Project, + + /** + * The portfolio targets (metrics, repetitions). + */ + @Type(type = "json") + @Column(columnDefinition = "jsonb", nullable = false, updatable = false) + val targets: Targets, +) { + /** + * The scenarios in this portfolio. + */ + @OneToMany(cascade = [CascadeType.ALL], mappedBy = "portfolio", orphanRemoval = true) + @OrderBy("id ASC") + val scenarios: MutableSet<Scenario> = mutableSetOf() + + /** + * Return a string representation of this portfolio. + */ + override fun toString(): String = "Job[id=$id,name=$name,project=${project.id},targets=$targets]" +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Project.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Project.kt new file mode 100644 index 00000000..e0440bf4 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Project.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.model + +import java.time.Instant +import javax.persistence.* + +/** + * A project in OpenDC encapsulates all the datacenter designs and simulation runs for a set of users. + */ +@Entity +@Table(name = "projects") +@NamedQueries( + value = [ + NamedQuery( + name = "Project.findAll", + query = """ + SELECT a + FROM ProjectAuthorization a + WHERE a.key.userId = :userId + """ + ), + NamedQuery( + name = "Project.allocatePortfolio", + query = """ + UPDATE Project p + SET p.portfoliosCreated = :oldState + 1, p.updatedAt = :now + WHERE p.id = :id AND p.portfoliosCreated = :oldState + """ + ), + NamedQuery( + name = "Project.allocateTopology", + query = """ + UPDATE Project p + SET p.topologiesCreated = :oldState + 1, p.updatedAt = :now + WHERE p.id = :id AND p.topologiesCreated = :oldState + """ + ), + NamedQuery( + name = "Project.allocateScenario", + query = """ + UPDATE Project p + SET p.scenariosCreated = :oldState + 1, p.updatedAt = :now + WHERE p.id = :id AND p.scenariosCreated = :oldState + """ + ) + ] +) +class Project( + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + val id: Long, + + @Column(nullable = false) + var name: String, + + @Column(name = "created_at", nullable = false, updatable = false) + val createdAt: Instant, +) { + /** + * The instant at which the project was updated. + */ + @Column(name = "updated_at", nullable = false) + var updatedAt: Instant = createdAt + + /** + * The portfolios belonging to this project. + */ + @OneToMany(cascade = [CascadeType.ALL], mappedBy = "project", orphanRemoval = true) + @OrderBy("id ASC") + val portfolios: MutableSet<Portfolio> = mutableSetOf() + + /** + * The number of portfolios created for this project (including deleted portfolios). + */ + @Column(name = "portfolios_created", nullable = false) + var portfoliosCreated: Int = 0 + + /** + * The topologies belonging to this project. + */ + @OneToMany(cascade = [CascadeType.ALL], mappedBy = "project", orphanRemoval = true) + @OrderBy("id ASC") + val topologies: MutableSet<Topology> = mutableSetOf() + + /** + * The number of topologies created for this project (including deleted topologies). + */ + @Column(name = "topologies_created", nullable = false) + var topologiesCreated: Int = 0 + + /** + * The scenarios belonging to this project. + */ + @OneToMany(mappedBy = "project", orphanRemoval = true) + val scenarios: MutableSet<Scenario> = mutableSetOf() + + /** + * The number of scenarios created for this project (including deleted scenarios). + */ + @Column(name = "scenarios_created", nullable = false) + var scenariosCreated: Int = 0 + + /** + * The users authorized to access the project. + */ + @OneToMany(cascade = [CascadeType.ALL], mappedBy = "project", orphanRemoval = true) + val authorizations: MutableSet<ProjectAuthorization> = mutableSetOf() + + /** + * Return a string representation of this project. + */ + override fun toString(): String = "Project[id=$id,name=$name]" +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/ProjectAuthorization.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/ProjectAuthorization.kt new file mode 100644 index 00000000..a72ff06a --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/ProjectAuthorization.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.model + +import org.opendc.web.proto.user.ProjectRole +import javax.persistence.* + +/** + * An authorization for some user to participate in a project. + */ +@Entity +@Table(name = "project_authorizations") +class ProjectAuthorization( + /** + * The user identifier of the authorization. + */ + @EmbeddedId + val key: ProjectAuthorizationKey, + + /** + * The project that the user is authorized to participate in. + */ + @ManyToOne(optional = false) + @MapsId("projectId") + @JoinColumn(name = "project_id", updatable = false, insertable = false, nullable = false) + val project: Project, + + /** + * The role of the user in the project. + */ + @Column(nullable = false) + val role: ProjectRole +) { + /** + * Return a string representation of this project authorization. + */ + override fun toString(): String = "ProjectAuthorization[project=${key.projectId},user=${key.userId},role=$role]" +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/ProjectAuthorizationKey.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/ProjectAuthorizationKey.kt new file mode 100644 index 00000000..b5f66e70 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/ProjectAuthorizationKey.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.model + +import javax.persistence.Column +import javax.persistence.Embeddable + +/** + * Key for representing a [ProjectAuthorization] object. + */ +@Embeddable +data class ProjectAuthorizationKey( + @Column(name = "user_id", nullable = false) + val userId: String, + + @Column(name = "project_id", nullable = false) + val projectId: Long +) : java.io.Serializable diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Scenario.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Scenario.kt new file mode 100644 index 00000000..5c9cb259 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Scenario.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.model + +import org.hibernate.annotations.Type +import org.hibernate.annotations.TypeDef +import org.opendc.web.api.util.hibernate.json.JsonType +import org.opendc.web.proto.OperationalPhenomena +import javax.persistence.* + +/** + * A single scenario to be explored by the simulator. + */ +@TypeDef(name = "json", typeClass = JsonType::class) +@Entity +@Table( + name = "scenarios", + uniqueConstraints = [UniqueConstraint(columnNames = ["project_id", "number"])], + indexes = [Index(name = "fn_scenarios_number", columnList = "project_id, number")] +) +@NamedQueries( + value = [ + NamedQuery( + name = "Scenario.findAll", + query = "SELECT s FROM Scenario s WHERE s.project.id = :projectId" + ), + NamedQuery( + name = "Scenario.findAllForPortfolio", + query = """ + SELECT s + FROM Scenario s + JOIN Portfolio p ON p.id = s.portfolio.id AND p.number = :number + WHERE s.project.id = :projectId + """ + ), + NamedQuery( + name = "Scenario.findOne", + query = "SELECT s FROM Scenario s WHERE s.project.id = :projectId AND s.number = :number" + ) + ] +) +class Scenario( + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + val id: Long, + + /** + * Unique number of the scenario for the project. + */ + @Column(nullable = false) + val number: Int, + + @Column(nullable = false, updatable = false) + val name: String, + + @ManyToOne(optional = false) + @JoinColumn(name = "project_id", nullable = false) + val project: Project, + + @ManyToOne(optional = false) + @JoinColumn(name = "portfolio_id", nullable = false) + val portfolio: Portfolio, + + @Embedded + val workload: Workload, + + @ManyToOne(optional = false) + val topology: Topology, + + @Type(type = "json") + @Column(columnDefinition = "jsonb", nullable = false, updatable = false) + val phenomena: OperationalPhenomena, + + @Column(name = "scheduler_name", nullable = false, updatable = false) + val schedulerName: String, +) { + /** + * The [Job] associated with the scenario. + */ + @OneToOne(cascade = [CascadeType.ALL]) + lateinit var job: Job + + /** + * Return a string representation of this scenario. + */ + override fun toString(): String = "Scenario[id=$id,name=$name,project=${project.id},portfolio=${portfolio.id}]" +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Topology.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Topology.kt new file mode 100644 index 00000000..9b64e382 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Topology.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.model + +import org.hibernate.annotations.Type +import org.hibernate.annotations.TypeDef +import org.opendc.web.api.util.hibernate.json.JsonType +import org.opendc.web.proto.Room +import java.time.Instant +import javax.persistence.* + +/** + * A datacenter design in OpenDC. + */ +@TypeDef(name = "json", typeClass = JsonType::class) +@Entity +@Table( + name = "topologies", + uniqueConstraints = [UniqueConstraint(columnNames = ["project_id", "number"])], + indexes = [Index(name = "fn_topologies_number", columnList = "project_id, number")] +) +@NamedQueries( + value = [ + NamedQuery( + name = "Topology.findAll", + query = "SELECT t FROM Topology t WHERE t.project.id = :projectId" + ), + NamedQuery( + name = "Topology.findOne", + query = "SELECT t FROM Topology t WHERE t.project.id = :projectId AND t.number = :number" + ) + ] +) +class Topology( + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + val id: Long, + + /** + * Unique number of the topology for the project. + */ + @Column(nullable = false) + val number: Int, + + @Column(nullable = false) + val name: String, + + @ManyToOne(optional = false) + @JoinColumn(name = "project_id", nullable = false) + val project: Project, + + @Column(name = "created_at", nullable = false, updatable = false) + val createdAt: Instant, + + /** + * Datacenter design in JSON + */ + @Type(type = "json") + @Column(columnDefinition = "jsonb", nullable = false) + var rooms: List<Room> = emptyList() +) { + /** + * The instant at which the topology was updated. + */ + @Column(name = "updated_at", nullable = false) + var updatedAt: Instant = createdAt + + /** + * Return a string representation of this topology. + */ + override fun toString(): String = "Topology[id=$id,name=$name,project=${project.id}]" +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Trace.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Trace.kt new file mode 100644 index 00000000..2e2d71f8 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Trace.kt @@ -0,0 +1,58 @@ +/* + * 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. + */ + +package org.opendc.web.api.model + +import javax.persistence.* + +/** + * A workload trace available for simulation. + * + * @param id The unique identifier of the trace. + * @param name The name of the trace. + * @param type The type of trace. + */ +@Entity +@Table(name = "traces") +@NamedQueries( + value = [ + NamedQuery( + name = "Trace.findAll", + query = "SELECT t FROM Trace t" + ), + ] +) +class Trace( + @Id + val id: String, + + @Column(nullable = false, updatable = false) + val name: String, + + @Column(nullable = false, updatable = false) + val type: String, +) { + /** + * Return a string representation of this trace. + */ + override fun toString(): String = "Trace[id=$id,name=$name,type=$type]" +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Workload.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Workload.kt new file mode 100644 index 00000000..07fc096b --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Workload.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.model + +import javax.persistence.Column +import javax.persistence.Embeddable +import javax.persistence.ManyToOne + +/** + * Specification of the workload for a [Scenario]. + */ +@Embeddable +class Workload( + @ManyToOne(optional = false) + val trace: Trace, + + @Column(name = "sampling_fraction", nullable = false, updatable = false) + val samplingFraction: Double +) diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/JobRepository.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/JobRepository.kt new file mode 100644 index 00000000..558d7c38 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/JobRepository.kt @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.repository + +import org.opendc.web.api.model.Job +import org.opendc.web.proto.JobState +import java.time.Instant +import javax.enterprise.context.ApplicationScoped +import javax.inject.Inject +import javax.persistence.EntityManager + +/** + * A repository to manage [Job] entities. + */ +@ApplicationScoped +class JobRepository @Inject constructor(private val em: EntityManager) { + /** + * Find all jobs currently residing in [state]. + * + * @param state The state in which the jobs should be. + * @return The list of jobs in state [state]. + */ + fun findAll(state: JobState): List<Job> { + return em.createNamedQuery("Job.findAll", Job::class.java) + .setParameter("state", state) + .resultList + } + + /** + * Find the [Job] with the specified [id]. + * + * @param id The unique identifier of the job. + * @return The trace or `null` if it does not exist. + */ + fun findOne(id: Long): Job? { + return em.find(Job::class.java, id) + } + + /** + * Delete the specified [job]. + */ + fun delete(job: Job) { + em.remove(job) + } + + /** + * Save the specified [job] to the database. + */ + fun save(job: Job) { + em.persist(job) + } + + /** + * Atomically update the specified [job]. + * + * @param job The job to update atomically. + * @param newState The new state to enter into. + * @param time The time at which the update occurs. + * @param results The results to possible set. + * @return `true` when the update succeeded`, `false` when there was a conflict. + */ + fun updateOne(job: Job, newState: JobState, time: Instant, results: Map<String, Any>?): Boolean { + val count = em.createNamedQuery("Job.updateOne") + .setParameter("id", job.id) + .setParameter("oldState", job.state) + .setParameter("newState", newState) + .setParameter("updatedAt", Instant.now()) + .setParameter("results", results) + .executeUpdate() + em.refresh(job) + return count > 0 + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/PortfolioRepository.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/PortfolioRepository.kt new file mode 100644 index 00000000..34b3598c --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/PortfolioRepository.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.repository + +import org.opendc.web.api.model.Portfolio +import javax.enterprise.context.ApplicationScoped +import javax.inject.Inject +import javax.persistence.EntityManager + +/** + * A repository to manage [Portfolio] entities. + */ +@ApplicationScoped +class PortfolioRepository @Inject constructor(private val em: EntityManager) { + /** + * Find all [Portfolio]s that belong to [project][projectId]. + * + * @param projectId The unique identifier of the project. + * @return The list of portfolios that belong to the specified project. + */ + fun findAll(projectId: Long): List<Portfolio> { + return em.createNamedQuery("Portfolio.findAll", Portfolio::class.java) + .setParameter("projectId", projectId) + .resultList + } + + /** + * Find the [Portfolio] with the specified [number] belonging to [project][projectId]. + * + * @param projectId The unique identifier of the project. + * @param number The number of the portfolio. + * @return The portfolio or `null` if it does not exist. + */ + fun findOne(projectId: Long, number: Int): Portfolio? { + return em.createNamedQuery("Portfolio.findOne", Portfolio::class.java) + .setParameter("projectId", projectId) + .setParameter("number", number) + .setMaxResults(1) + .resultList + .firstOrNull() + } + + /** + * Delete the specified [portfolio]. + */ + fun delete(portfolio: Portfolio) { + em.remove(portfolio) + } + + /** + * Save the specified [portfolio] to the database. + */ + fun save(portfolio: Portfolio) { + em.persist(portfolio) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/ProjectRepository.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/ProjectRepository.kt new file mode 100644 index 00000000..6529f778 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/ProjectRepository.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.repository + +import org.opendc.web.api.model.Project +import org.opendc.web.api.model.ProjectAuthorization +import org.opendc.web.api.model.ProjectAuthorizationKey +import java.time.Instant +import javax.enterprise.context.ApplicationScoped +import javax.inject.Inject +import javax.persistence.EntityManager + +/** + * A repository to manage [Project] entities. + */ +@ApplicationScoped +class ProjectRepository @Inject constructor(private val em: EntityManager) { + /** + * List all projects for the user with the specified [userId]. + * + * @param userId The identifier of the user that is requesting the list of projects. + * @return A list of projects that the user has received authorization for. + */ + fun findAll(userId: String): List<ProjectAuthorization> { + return em.createNamedQuery("Project.findAll", ProjectAuthorization::class.java) + .setParameter("userId", userId) + .resultList + } + + /** + * Find the project with [id] for the user with the specified [userId]. + * + * @param userId The identifier of the user that is requesting the list of projects. + * @param id The unique identifier of the project. + * @return The project with the specified identifier or `null` if it does not exist or is not accessible to the + * user with the specified identifier. + */ + fun findOne(userId: String, id: Long): ProjectAuthorization? { + return em.find(ProjectAuthorization::class.java, ProjectAuthorizationKey(userId, id)) + } + + /** + * Delete the specified [project]. + */ + fun delete(project: Project) { + em.remove(project) + } + + /** + * Save the specified [project] to the database. + */ + fun save(project: Project) { + em.persist(project) + } + + /** + * Save the specified [auth] to the database. + */ + fun save(auth: ProjectAuthorization) { + em.persist(auth) + } + + /** + * Allocate the next portfolio number for the specified [project]. + * + * @param project The project to allocate the portfolio number for. + * @param time The time at which the new portfolio is created. + * @param tries The number of times to try to allocate the number before failing. + */ + fun allocatePortfolio(project: Project, time: Instant, tries: Int = 4): Int { + repeat(tries) { + val count = em.createNamedQuery("Project.allocatePortfolio") + .setParameter("id", project.id) + .setParameter("oldState", project.portfoliosCreated) + .setParameter("now", time) + .executeUpdate() + + if (count > 0) { + return project.portfoliosCreated + 1 + } else { + em.refresh(project) + } + } + + throw IllegalStateException("Failed to allocate next portfolio") + } + + /** + * Allocate the next topology number for the specified [project]. + * + * @param project The project to allocate the topology number for. + * @param time The time at which the new topology is created. + * @param tries The number of times to try to allocate the number before failing. + */ + fun allocateTopology(project: Project, time: Instant, tries: Int = 4): Int { + repeat(tries) { + val count = em.createNamedQuery("Project.allocateTopology") + .setParameter("id", project.id) + .setParameter("oldState", project.topologiesCreated) + .setParameter("now", time) + .executeUpdate() + + if (count > 0) { + return project.topologiesCreated + 1 + } else { + em.refresh(project) + } + } + + throw IllegalStateException("Failed to allocate next topology") + } + + /** + * Allocate the next scenario number for the specified [project]. + * + * @param project The project to allocate the scenario number for. + * @param time The time at which the new scenario is created. + * @param tries The number of times to try to allocate the number before failing. + */ + fun allocateScenario(project: Project, time: Instant, tries: Int = 4): Int { + repeat(tries) { + val count = em.createNamedQuery("Project.allocateScenario") + .setParameter("id", project.id) + .setParameter("oldState", project.scenariosCreated) + .setParameter("now", time) + .executeUpdate() + + if (count > 0) { + return project.scenariosCreated + 1 + } else { + em.refresh(project) + } + } + + throw IllegalStateException("Failed to allocate next scenario") + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/ScenarioRepository.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/ScenarioRepository.kt new file mode 100644 index 00000000..de116ad6 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/ScenarioRepository.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.repository + +import org.opendc.web.api.model.Scenario +import javax.enterprise.context.ApplicationScoped +import javax.inject.Inject +import javax.persistence.EntityManager + +/** + * A repository to manage [Scenario] entities. + */ +@ApplicationScoped +class ScenarioRepository @Inject constructor(private val em: EntityManager) { + /** + * Find all [Scenario]s that belong to [project][projectId]. + * + * @param projectId The unique identifier of the project. + * @return The list of scenarios that belong to the specified project. + */ + fun findAll(projectId: Long): List<Scenario> { + return em.createNamedQuery("Scenario.findAll", Scenario::class.java) + .setParameter("projectId", projectId) + .resultList + } + + /** + * Find all [Scenario]s that belong to [portfolio][number] of [project][projectId]. + * + * @param projectId The unique identifier of the project. + * @param number The number of the portfolio to which the scenarios should belong. + * @return The list of scenarios that belong to the specified portfolio. + */ + fun findAll(projectId: Long, number: Int): List<Scenario> { + return em.createNamedQuery("Scenario.findAllForPortfolio", Scenario::class.java) + .setParameter("projectId", projectId) + .setParameter("number", number) + .resultList + } + + /** + * Find the [Scenario] with the specified [number] belonging to [project][projectId]. + * + * @param projectId The unique identifier of the project. + * @param number The number of the scenario. + * @return The scenario or `null` if it does not exist. + */ + fun findOne(projectId: Long, number: Int): Scenario? { + return em.createNamedQuery("Scenario.findOne", Scenario::class.java) + .setParameter("projectId", projectId) + .setParameter("number", number) + .setMaxResults(1) + .resultList + .firstOrNull() + } + + /** + * Delete the specified [scenario]. + */ + fun delete(scenario: Scenario) { + em.remove(scenario) + } + + /** + * Save the specified [scenario] to the database. + */ + fun save(scenario: Scenario) { + em.persist(scenario) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/TopologyRepository.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/TopologyRepository.kt new file mode 100644 index 00000000..cd8f666e --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/TopologyRepository.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.repository + +import org.opendc.web.api.model.Topology +import javax.enterprise.context.ApplicationScoped +import javax.inject.Inject +import javax.persistence.EntityManager + +/** + * A repository to manage [Topology] entities. + */ +@ApplicationScoped +class TopologyRepository @Inject constructor(private val em: EntityManager) { + /** + * Find all [Topology]s that belong to [project][projectId]. + * + * @param projectId The unique identifier of the project. + * @return The list of topologies that belong to the specified project. + */ + fun findAll(projectId: Long): List<Topology> { + return em.createNamedQuery("Topology.findAll", Topology::class.java) + .setParameter("projectId", projectId) + .resultList + } + + /** + * Find the [Topology] with the specified [number] belonging to [project][projectId]. + * + * @param projectId The unique identifier of the project. + * @param number The number of the topology. + * @return The topology or `null` if it does not exist. + */ + fun findOne(projectId: Long, number: Int): Topology? { + return em.createNamedQuery("Topology.findOne", Topology::class.java) + .setParameter("projectId", projectId) + .setParameter("number", number) + .setMaxResults(1) + .resultList + .firstOrNull() + } + + /** + * Find the [Topology] with the specified [id]. + * + * @param id Unique identifier of the topology. + * @return The topology or `null` if it does not exist. + */ + fun findOne(id: Long): Topology? { + return em.find(Topology::class.java, id) + } + + /** + * Delete the specified [topology]. + */ + fun delete(topology: Topology) { + em.remove(topology) + } + + /** + * Save the specified [topology] to the database. + */ + fun save(topology: Topology) { + em.persist(topology) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/TraceRepository.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/TraceRepository.kt new file mode 100644 index 00000000..6652fc80 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/TraceRepository.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.repository + +import org.opendc.web.api.model.Trace +import javax.enterprise.context.ApplicationScoped +import javax.inject.Inject +import javax.persistence.EntityManager + +/** + * A repository to manage [Trace] entities. + */ +@ApplicationScoped +class TraceRepository @Inject constructor(private val em: EntityManager) { + /** + * Find all workload traces in the database. + * + * @return The list of available workload traces. + */ + fun findAll(): List<Trace> { + return em.createNamedQuery("Trace.findAll", Trace::class.java).resultList + } + + /** + * Find the [Trace] with the specified [id]. + * + * @param id The unique identifier of the trace. + * @return The trace or `null` if it does not exist. + */ + fun findOne(id: String): Trace? { + return em.find(Trace::class.java, id) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/SchedulerResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/SchedulerResource.kt new file mode 100644 index 00000000..735fdd9b --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/SchedulerResource.kt @@ -0,0 +1,48 @@ +/* + * 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. + */ + +package org.opendc.web.api.rest + +import javax.ws.rs.GET +import javax.ws.rs.Path + +/** + * A resource representing the available schedulers that can be used during experiments. + */ +@Path("/schedulers") +class SchedulerResource { + /** + * Obtain all available schedulers. + */ + @GET + fun getAll() = listOf( + "mem", + "mem-inv", + "core-mem", + "core-mem-inv", + "active-servers", + "active-servers-inv", + "provisioned-cores", + "provisioned-cores-inv", + "random" + ) +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/TraceResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/TraceResource.kt new file mode 100644 index 00000000..e87fe602 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/TraceResource.kt @@ -0,0 +1,51 @@ +/* + * 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. + */ + +package org.opendc.web.api.rest + +import org.opendc.web.api.service.TraceService +import org.opendc.web.proto.Trace +import javax.inject.Inject +import javax.ws.rs.* + +/** + * A resource representing the workload traces available in the OpenDC instance. + */ +@Path("/traces") +class TraceResource @Inject constructor(private val traceService: TraceService) { + /** + * Obtain all available traces. + */ + @GET + fun getAll(): List<Trace> { + return traceService.findAll() + } + + /** + * Obtain trace information by identifier. + */ + @GET + @Path("{id}") + fun get(@PathParam("id") id: String): Trace { + return traceService.findById(id) ?: throw WebApplicationException("Trace not found", 404) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/error/GenericExceptionMapper.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/error/GenericExceptionMapper.kt new file mode 100644 index 00000000..fb253758 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/error/GenericExceptionMapper.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.rest.error + +import org.opendc.web.proto.ProtocolError +import javax.ws.rs.WebApplicationException +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response +import javax.ws.rs.ext.ExceptionMapper +import javax.ws.rs.ext.Provider + +/** + * Helper class to transform an exception into an JSON error response. + */ +@Provider +class GenericExceptionMapper : ExceptionMapper<Exception> { + override fun toResponse(exception: Exception): Response { + val code = if (exception is WebApplicationException) exception.response.status else 500 + + return Response.status(code) + .entity(ProtocolError(code, exception.message ?: "Unknown error")) + .type(MediaType.APPLICATION_JSON) + .build() + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/error/MissingKotlinParameterExceptionMapper.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/error/MissingKotlinParameterExceptionMapper.kt new file mode 100644 index 00000000..57cd35d1 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/error/MissingKotlinParameterExceptionMapper.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.rest.error + +import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException +import org.opendc.web.proto.ProtocolError +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response +import javax.ws.rs.ext.ExceptionMapper +import javax.ws.rs.ext.Provider + +/** + * An [ExceptionMapper] for [MissingKotlinParameterException] thrown by Jackson. + */ +@Provider +class MissingKotlinParameterExceptionMapper : ExceptionMapper<MissingKotlinParameterException> { + override fun toResponse(exception: MissingKotlinParameterException): Response { + return Response.status(Response.Status.BAD_REQUEST) + .entity(ProtocolError(Response.Status.BAD_REQUEST.statusCode, "Field '${exception.parameter.name}' is missing from body.")) + .type(MediaType.APPLICATION_JSON) + .build() + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/runner/JobResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/runner/JobResource.kt new file mode 100644 index 00000000..7e31e2c5 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/runner/JobResource.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.rest.runner + +import org.opendc.web.api.service.JobService +import org.opendc.web.proto.runner.Job +import javax.annotation.security.RolesAllowed +import javax.inject.Inject +import javax.transaction.Transactional +import javax.validation.Valid +import javax.ws.rs.* + +/** + * A resource representing the available simulation jobs. + */ +@Path("/jobs") +@RolesAllowed("runner") +class JobResource @Inject constructor(private val jobService: JobService) { + /** + * Obtain all pending simulation jobs. + */ + @GET + fun queryPending(): List<Job> { + return jobService.queryPending() + } + + /** + * Get a job by identifier. + */ + @GET + @Path("{job}") + fun get(@PathParam("job") id: Long): Job { + return jobService.findById(id) ?: throw WebApplicationException("Job not found", 404) + } + + /** + * Atomically update the state of a job. + */ + @POST + @Path("{job}") + @Transactional + fun update(@PathParam("job") id: Long, @Valid update: Job.Update): Job { + return jobService.updateState(id, update.state, update.results) ?: throw WebApplicationException("Job not found", 404) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/PortfolioResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/PortfolioResource.kt new file mode 100644 index 00000000..e720de75 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/PortfolioResource.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.rest.user + +import io.quarkus.security.identity.SecurityIdentity +import org.opendc.web.api.service.PortfolioService +import org.opendc.web.proto.user.Portfolio +import javax.annotation.security.RolesAllowed +import javax.inject.Inject +import javax.transaction.Transactional +import javax.validation.Valid +import javax.ws.rs.* + +/** + * A resource representing the portfolios of a project. + */ +@Path("/projects/{project}/portfolios") +@RolesAllowed("openid") +class PortfolioResource @Inject constructor( + private val portfolioService: PortfolioService, + private val identity: SecurityIdentity, +) { + /** + * Get all portfolios that belong to the specified project. + */ + @GET + fun getAll(@PathParam("project") projectId: Long): List<Portfolio> { + return portfolioService.findAll(identity.principal.name, projectId) + } + + /** + * Create a portfolio for this project. + */ + @POST + @Transactional + fun create(@PathParam("project") projectId: Long, @Valid request: Portfolio.Create): Portfolio { + return portfolioService.create(identity.principal.name, projectId, request) ?: throw WebApplicationException("Project not found", 404) + } + + /** + * Obtain a portfolio by its identifier. + */ + @GET + @Path("{portfolio}") + fun get(@PathParam("project") projectId: Long, @PathParam("portfolio") number: Int): Portfolio { + return portfolioService.findOne(identity.principal.name, projectId, number) ?: throw WebApplicationException("Portfolio not found", 404) + } + + /** + * Delete a portfolio. + */ + @DELETE + @Path("{portfolio}") + fun delete(@PathParam("project") projectId: Long, @PathParam("portfolio") number: Int): Portfolio { + return portfolioService.delete(identity.principal.name, projectId, number) ?: throw WebApplicationException("Portfolio not found", 404) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/PortfolioScenarioResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/PortfolioScenarioResource.kt new file mode 100644 index 00000000..8d24b2eb --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/PortfolioScenarioResource.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.rest.user + +import io.quarkus.security.identity.SecurityIdentity +import org.opendc.web.api.service.ScenarioService +import org.opendc.web.proto.user.Scenario +import javax.annotation.security.RolesAllowed +import javax.inject.Inject +import javax.transaction.Transactional +import javax.validation.Valid +import javax.ws.rs.* + +/** + * A resource representing the scenarios of a portfolio. + */ +@Path("/projects/{project}/portfolios/{portfolio}/scenarios") +@RolesAllowed("openid") +class PortfolioScenarioResource @Inject constructor( + private val scenarioService: ScenarioService, + private val identity: SecurityIdentity, +) { + /** + * Get all scenarios that belong to the specified portfolio. + */ + @GET + fun get(@PathParam("project") projectId: Long, @PathParam("portfolio") portfolioNumber: Int): List<Scenario> { + return scenarioService.findAll(identity.principal.name, projectId, portfolioNumber) + } + + /** + * Create a scenario for this portfolio. + */ + @POST + @Transactional + fun create(@PathParam("project") projectId: Long, @PathParam("portfolio") portfolioNumber: Int, @Valid request: Scenario.Create): Scenario { + return scenarioService.create(identity.principal.name, projectId, portfolioNumber, request) ?: throw WebApplicationException("Portfolio not found", 404) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/ProjectResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/ProjectResource.kt new file mode 100644 index 00000000..a27d50e7 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/ProjectResource.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.rest.user + +import io.quarkus.security.identity.SecurityIdentity +import org.opendc.web.api.service.ProjectService +import org.opendc.web.proto.user.Project +import javax.annotation.security.RolesAllowed +import javax.inject.Inject +import javax.transaction.Transactional +import javax.validation.Valid +import javax.ws.rs.* + +/** + * A resource representing the created projects. + */ +@Path("/projects") +@RolesAllowed("openid") +class ProjectResource @Inject constructor( + private val projectService: ProjectService, + private val identity: SecurityIdentity +) { + /** + * Obtain all the projects of the current user. + */ + @GET + fun getAll(): List<Project> { + return projectService.findWithUser(identity.principal.name) + } + + /** + * Create a new project for the current user. + */ + @POST + @Transactional + fun create(@Valid request: Project.Create): Project { + return projectService.createForUser(identity.principal.name, request.name) + } + + /** + * Obtain a single project by its identifier. + */ + @GET + @Path("{project}") + fun get(@PathParam("project") id: Long): Project { + return projectService.findWithUser(identity.principal.name, id) ?: throw WebApplicationException("Project not found", 404) + } + + /** + * Delete a project. + */ + @DELETE + @Path("{project}") + @Transactional + fun delete(@PathParam("project") id: Long): Project { + try { + return projectService.deleteWithUser(identity.principal.name, id) ?: throw WebApplicationException("Project not found", 404) + } catch (e: IllegalArgumentException) { + throw WebApplicationException(e.message, 403) + } + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/ScenarioResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/ScenarioResource.kt new file mode 100644 index 00000000..3690f987 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/ScenarioResource.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.rest.user + +import io.quarkus.security.identity.SecurityIdentity +import org.opendc.web.api.service.ScenarioService +import org.opendc.web.proto.user.Scenario +import javax.annotation.security.RolesAllowed +import javax.inject.Inject +import javax.transaction.Transactional +import javax.ws.rs.* + +/** + * A resource representing the scenarios of a portfolio. + */ +@Path("/projects/{project}/scenarios") +@RolesAllowed("openid") +class ScenarioResource @Inject constructor( + private val scenarioService: ScenarioService, + private val identity: SecurityIdentity +) { + /** + * Obtain a scenario by its identifier. + */ + @GET + @Path("{scenario}") + fun get(@PathParam("project") projectId: Long, @PathParam("scenario") number: Int): Scenario { + return scenarioService.findOne(identity.principal.name, projectId, number) ?: throw WebApplicationException("Scenario not found", 404) + } + + /** + * Delete a scenario. + */ + @DELETE + @Path("{scenario}") + @Transactional + fun delete(@PathParam("project") projectId: Long, @PathParam("scenario") number: Int): Scenario { + return scenarioService.delete(identity.principal.name, projectId, number) ?: throw WebApplicationException("Scenario not found", 404) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/TopologyResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/TopologyResource.kt new file mode 100644 index 00000000..52c5eaaa --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/TopologyResource.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.rest.user + +import io.quarkus.security.identity.SecurityIdentity +import org.opendc.web.api.service.TopologyService +import org.opendc.web.proto.user.Topology +import javax.annotation.security.RolesAllowed +import javax.inject.Inject +import javax.transaction.Transactional +import javax.validation.Valid +import javax.ws.rs.* + +/** + * A resource representing the constructed datacenter topologies. + */ +@Path("/projects/{project}/topologies") +@RolesAllowed("openid") +class TopologyResource @Inject constructor( + private val topologyService: TopologyService, + private val identity: SecurityIdentity +) { + /** + * Get all topologies that belong to the specified project. + */ + @GET + fun getAll(@PathParam("project") projectId: Long): List<Topology> { + return topologyService.findAll(identity.principal.name, projectId) + } + + /** + * Create a topology for this project. + */ + @POST + @Transactional + fun create(@PathParam("project") projectId: Long, @Valid request: Topology.Create): Topology { + return topologyService.create(identity.principal.name, projectId, request) ?: throw WebApplicationException("Topology not found", 404) + } + + /** + * Obtain a topology by its number. + */ + @GET + @Path("{topology}") + fun get(@PathParam("project") projectId: Long, @PathParam("topology") number: Int): Topology { + return topologyService.findOne(identity.principal.name, projectId, number) ?: throw WebApplicationException("Topology not found", 404) + } + + /** + * Update the specified topology by its number. + */ + @PUT + @Path("{topology}") + @Transactional + fun update(@PathParam("project") projectId: Long, @PathParam("topology") number: Int, @Valid request: Topology.Update): Topology { + return topologyService.update(identity.principal.name, projectId, number, request) ?: throw WebApplicationException("Topology not found", 404) + } + + /** + * Delete the specified topology. + */ + @Path("{topology}") + @DELETE + @Transactional + fun delete(@PathParam("project") projectId: Long, @PathParam("topology") number: Int): Topology { + return topologyService.delete(identity.principal.name, projectId, number) ?: throw WebApplicationException("Topology not found", 404) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/JobService.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/JobService.kt new file mode 100644 index 00000000..1b33248d --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/JobService.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.service + +import org.opendc.web.api.repository.JobRepository +import org.opendc.web.proto.JobState +import org.opendc.web.proto.runner.Job +import java.time.Instant +import javax.enterprise.context.ApplicationScoped +import javax.inject.Inject + +/** + * Service for managing [Job]s. + */ +@ApplicationScoped +class JobService @Inject constructor(private val repository: JobRepository) { + /** + * Query the pending simulation jobs. + */ + fun queryPending(): List<Job> { + return repository.findAll(JobState.PENDING).map { it.toRunnerDto() } + } + + /** + * Find a job by its identifier. + */ + fun findById(id: Long): Job? { + return repository.findOne(id)?.toRunnerDto() + } + + /** + * Atomically update the state of a [Job]. + */ + fun updateState(id: Long, newState: JobState, results: Map<String, Any>?): Job? { + val entity = repository.findOne(id) ?: return null + val state = entity.state + if (!state.isTransitionLegal(newState)) { + throw IllegalArgumentException("Invalid transition from $state to $newState") + } + + val now = Instant.now() + if (!repository.updateOne(entity, newState, now, results)) { + throw IllegalStateException("Conflicting update") + } + + return entity.toRunnerDto() + } + + /** + * Determine whether the transition from [this] to [newState] is legal. + */ + private fun JobState.isTransitionLegal(newState: JobState): Boolean { + // Note that we always allow transitions from the state + return newState == this || when (this) { + JobState.PENDING -> newState == JobState.CLAIMED + JobState.CLAIMED -> newState == JobState.RUNNING || newState == JobState.FAILED + JobState.RUNNING -> newState == JobState.FINISHED || newState == JobState.FAILED + JobState.FINISHED, JobState.FAILED -> false + } + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/PortfolioService.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/PortfolioService.kt new file mode 100644 index 00000000..1f41c2d7 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/PortfolioService.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.service + +import org.opendc.web.api.model.* +import org.opendc.web.api.repository.PortfolioRepository +import org.opendc.web.api.repository.ProjectRepository +import org.opendc.web.proto.user.Portfolio +import java.time.Instant +import javax.enterprise.context.ApplicationScoped +import javax.inject.Inject +import org.opendc.web.api.model.Portfolio as PortfolioEntity + +/** + * Service for managing [Portfolio]s. + */ +@ApplicationScoped +class PortfolioService @Inject constructor( + private val projectRepository: ProjectRepository, + private val portfolioRepository: PortfolioRepository +) { + /** + * List all [Portfolio]s that belong a certain project. + */ + fun findAll(userId: String, projectId: Long): List<Portfolio> { + // User must have access to project + val auth = projectRepository.findOne(userId, projectId) ?: return emptyList() + val project = auth.toUserDto() + return portfolioRepository.findAll(projectId).map { it.toUserDto(project) } + } + + /** + * Find a [Portfolio] with the specified [number] belonging to [project][projectId]. + */ + fun findOne(userId: String, projectId: Long, number: Int): Portfolio? { + // User must have access to project + val auth = projectRepository.findOne(userId, projectId) ?: return null + return portfolioRepository.findOne(projectId, number)?.toUserDto(auth.toUserDto()) + } + + /** + * Delete the portfolio with the specified [number] belonging to [project][projectId]. + */ + fun delete(userId: String, projectId: Long, number: Int): Portfolio? { + // User must have access to project + val auth = projectRepository.findOne(userId, projectId) + + if (auth == null) { + return null + } else if (!auth.role.canEdit) { + throw IllegalStateException("Not permitted to edit project") + } + + val entity = portfolioRepository.findOne(projectId, number) ?: return null + val portfolio = entity.toUserDto(auth.toUserDto()) + portfolioRepository.delete(entity) + return portfolio + } + + /** + * Construct a new [Portfolio] with the specified name. + */ + fun create(userId: String, projectId: Long, request: Portfolio.Create): Portfolio? { + // User must have access to project + val auth = projectRepository.findOne(userId, projectId) + + if (auth == null) { + return null + } else if (!auth.role.canEdit) { + throw IllegalStateException("Not permitted to edit project") + } + + val now = Instant.now() + val project = auth.project + val number = projectRepository.allocatePortfolio(auth.project, now) + + val portfolio = PortfolioEntity(0, number, request.name, project, request.targets) + + project.portfolios.add(portfolio) + portfolioRepository.save(portfolio) + + return portfolio.toUserDto(auth.toUserDto()) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/ProjectService.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/ProjectService.kt new file mode 100644 index 00000000..c3e43395 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/ProjectService.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.service + +import org.opendc.web.api.model.* +import org.opendc.web.api.repository.ProjectRepository +import org.opendc.web.proto.user.Project +import org.opendc.web.proto.user.ProjectRole +import java.time.Instant +import javax.enterprise.context.ApplicationScoped +import javax.inject.Inject + +/** + * Service for managing [Project]s. + */ +@ApplicationScoped +class ProjectService @Inject constructor(private val repository: ProjectRepository) { + /** + * List all projects for the user with the specified [userId]. + */ + fun findWithUser(userId: String): List<Project> { + return repository.findAll(userId).map { it.toUserDto() } + } + + /** + * Obtain the project with the specified [id] for the user with the specified [userId]. + */ + fun findWithUser(userId: String, id: Long): Project? { + return repository.findOne(userId, id)?.toUserDto() + } + + /** + * Create a new [Project] for the user with the specified [userId]. + */ + fun createForUser(userId: String, name: String): Project { + val now = Instant.now() + val entity = Project(0, name, now) + repository.save(entity) + + val authorization = ProjectAuthorization(ProjectAuthorizationKey(userId, entity.id), entity, ProjectRole.OWNER) + + entity.authorizations.add(authorization) + repository.save(authorization) + + return authorization.toUserDto() + } + + /** + * Delete a project by its identifier. + * + * @param userId The user that invokes the action. + * @param id The identifier of the project. + */ + fun deleteWithUser(userId: String, id: Long): Project? { + val auth = repository.findOne(userId, id) ?: return null + + if (!auth.role.canDelete) { + throw IllegalArgumentException("Not allowed to delete project") + } + + val now = Instant.now() + val project = auth.toUserDto().copy(updatedAt = now) + repository.delete(auth.project) + return project + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/RunnerConversions.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/RunnerConversions.kt new file mode 100644 index 00000000..3722a641 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/RunnerConversions.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.service + +import org.opendc.web.api.model.Job +import org.opendc.web.api.model.Portfolio +import org.opendc.web.api.model.Scenario +import org.opendc.web.api.model.Topology + +/** + * Conversions into DTOs provided to OpenDC runners. + */ + +/** + * Convert a [Topology] into a runner-facing DTO. + */ +internal fun Topology.toRunnerDto(): org.opendc.web.proto.runner.Topology { + return org.opendc.web.proto.runner.Topology(id, number, name, rooms, createdAt, updatedAt) +} + +/** + * Convert a [Portfolio] into a runner-facing DTO. + */ +internal fun Portfolio.toRunnerDto(): org.opendc.web.proto.runner.Portfolio { + return org.opendc.web.proto.runner.Portfolio(id, number, name, targets) +} + +/** + * Convert a [Job] into a runner-facing DTO. + */ +internal fun Job.toRunnerDto(): org.opendc.web.proto.runner.Job { + return org.opendc.web.proto.runner.Job(id, scenario.toRunnerDto(), state, createdAt, updatedAt, results) +} + +/** + * Convert a [Job] into a runner-facing DTO. + */ +internal fun Scenario.toRunnerDto(): org.opendc.web.proto.runner.Scenario { + return org.opendc.web.proto.runner.Scenario( + id, + number, + portfolio.toRunnerDto(), + name, + workload.toDto(), + topology.toRunnerDto(), + phenomena, + schedulerName + ) +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/ScenarioService.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/ScenarioService.kt new file mode 100644 index 00000000..dd51a929 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/ScenarioService.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.service + +import org.opendc.web.api.model.* +import org.opendc.web.api.repository.* +import org.opendc.web.proto.user.Scenario +import java.time.Instant +import javax.enterprise.context.ApplicationScoped +import javax.inject.Inject + +/** + * Service for managing [Scenario]s. + */ +@ApplicationScoped +class ScenarioService @Inject constructor( + private val projectRepository: ProjectRepository, + private val portfolioRepository: PortfolioRepository, + private val topologyRepository: TopologyRepository, + private val traceRepository: TraceRepository, + private val scenarioRepository: ScenarioRepository, +) { + /** + * List all [Scenario]s that belong a certain portfolio. + */ + fun findAll(userId: String, projectId: Long, number: Int): List<Scenario> { + // User must have access to project + val auth = projectRepository.findOne(userId, projectId) ?: return emptyList() + val project = auth.toUserDto() + return scenarioRepository.findAll(projectId).map { it.toUserDto(project) } + } + + /** + * Obtain a [Scenario] by identifier. + */ + fun findOne(userId: String, projectId: Long, number: Int): Scenario? { + // User must have access to project + val auth = projectRepository.findOne(userId, projectId) ?: return null + val project = auth.toUserDto() + return scenarioRepository.findOne(projectId, number)?.toUserDto(project) + } + + /** + * Delete the specified scenario. + */ + fun delete(userId: String, projectId: Long, number: Int): Scenario? { + // User must have access to project + val auth = projectRepository.findOne(userId, projectId) + + if (auth == null) { + return null + } else if (!auth.role.canEdit) { + throw IllegalStateException("Not permitted to edit project") + } + + val entity = scenarioRepository.findOne(projectId, number) ?: return null + val scenario = entity.toUserDto(auth.toUserDto()) + scenarioRepository.delete(entity) + return scenario + } + + /** + * Construct a new [Scenario] with the specified data. + */ + fun create(userId: String, projectId: Long, portfolioNumber: Int, request: Scenario.Create): Scenario? { + // User must have access to project + val auth = projectRepository.findOne(userId, projectId) + + if (auth == null) { + return null + } else if (!auth.role.canEdit) { + throw IllegalStateException("Not permitted to edit project") + } + + val portfolio = portfolioRepository.findOne(projectId, portfolioNumber) ?: return null + val topology = requireNotNull( + topologyRepository.findOne( + projectId, + request.topology.toInt() + ) + ) { "Referred topology does not exist" } + val trace = + requireNotNull(traceRepository.findOne(request.workload.trace)) { "Referred trace does not exist" } + + val now = Instant.now() + val project = auth.project + val number = projectRepository.allocateScenario(auth.project, now) + + val scenario = Scenario( + 0, + number, + request.name, + project, + portfolio, + Workload(trace, request.workload.samplingFraction), + topology, + request.phenomena, + request.schedulerName + ) + val job = Job(0, scenario, now, portfolio.targets.repeats) + + scenario.job = job + portfolio.scenarios.add(scenario) + scenarioRepository.save(scenario) + + return scenario.toUserDto(auth.toUserDto()) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/TopologyService.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/TopologyService.kt new file mode 100644 index 00000000..f3460496 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/TopologyService.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.service + +import org.opendc.web.api.repository.ProjectRepository +import org.opendc.web.api.repository.TopologyRepository +import org.opendc.web.proto.user.Topology +import java.time.Instant +import javax.enterprise.context.ApplicationScoped +import javax.inject.Inject +import org.opendc.web.api.model.Topology as TopologyEntity + +/** + * Service for managing [Topology]s. + */ +@ApplicationScoped +class TopologyService @Inject constructor( + private val projectRepository: ProjectRepository, + private val topologyRepository: TopologyRepository +) { + /** + * List all [Topology]s that belong a certain project. + */ + fun findAll(userId: String, projectId: Long): List<Topology> { + // User must have access to project + val auth = projectRepository.findOne(userId, projectId) ?: return emptyList() + val project = auth.toUserDto() + return topologyRepository.findAll(projectId).map { it.toUserDto(project) } + } + + /** + * Find the [Topology] with the specified [number] belonging to [project][projectId]. + */ + fun findOne(userId: String, projectId: Long, number: Int): Topology? { + // User must have access to project + val auth = projectRepository.findOne(userId, projectId) ?: return null + return topologyRepository.findOne(projectId, number)?.toUserDto(auth.toUserDto()) + } + + /** + * Delete the [Topology] with the specified [number] belonging to [project][projectId]. + */ + fun delete(userId: String, projectId: Long, number: Int): Topology? { + // User must have access to project + val auth = projectRepository.findOne(userId, projectId) + + if (auth == null) { + return null + } else if (!auth.role.canEdit) { + throw IllegalStateException("Not permitted to edit project") + } + + val entity = topologyRepository.findOne(projectId, number) ?: return null + val now = Instant.now() + val topology = entity.toUserDto(auth.toUserDto()).copy(updatedAt = now) + topologyRepository.delete(entity) + + return topology + } + + /** + * Update a [Topology] with the specified [number] belonging to [project][projectId]. + */ + fun update(userId: String, projectId: Long, number: Int, request: Topology.Update): Topology? { + // User must have access to project + val auth = projectRepository.findOne(userId, projectId) + + if (auth == null) { + return null + } else if (!auth.role.canEdit) { + throw IllegalStateException("Not permitted to edit project") + } + + val entity = topologyRepository.findOne(projectId, number) ?: return null + val now = Instant.now() + + entity.updatedAt = now + entity.rooms = request.rooms + + return entity.toUserDto(auth.toUserDto()) + } + + /** + * Construct a new [Topology] with the specified name. + */ + fun create(userId: String, projectId: Long, request: Topology.Create): Topology? { + // User must have access to project + val auth = projectRepository.findOne(userId, projectId) + + if (auth == null) { + return null + } else if (!auth.role.canEdit) { + throw IllegalStateException("Not permitted to edit project") + } + + val now = Instant.now() + val project = auth.project + val number = projectRepository.allocateTopology(auth.project, now) + + val topology = TopologyEntity(0, number, request.name, project, now, request.rooms) + + project.topologies.add(topology) + topologyRepository.save(topology) + + return topology.toUserDto(auth.toUserDto()) + } +} diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/ApiResult.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/TraceService.kt index a3df01c5..a942696e 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/ApiResult.kt +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/TraceService.kt @@ -20,24 +20,29 @@ * SOFTWARE. */ -package org.opendc.web.client +package org.opendc.web.api.service -import com.fasterxml.jackson.annotation.JsonSubTypes -import com.fasterxml.jackson.annotation.JsonTypeInfo +import org.opendc.web.api.repository.TraceRepository +import org.opendc.web.proto.Trace +import javax.enterprise.context.ApplicationScoped +import javax.inject.Inject /** - * Generic response model for the OpenDC API. + * Service for managing [Trace]s. */ -@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) -@JsonSubTypes(JsonSubTypes.Type(ApiResult.Success::class), JsonSubTypes.Type(ApiResult.Failure::class)) -public sealed class ApiResult<out T> { +@ApplicationScoped +class TraceService @Inject constructor(private val repository: TraceRepository) { /** - * A response indicating everything is okay. + * Obtain all available workload traces. */ - public data class Success<out T>(val data: T) : ApiResult<T>() + fun findAll(): List<Trace> { + return repository.findAll().map { it.toUserDto() } + } /** - * A response indicating a failure. + * Obtain a workload trace by identifier. */ - public data class Failure<out T>(val message: String, val errors: List<String> = emptyList()) : ApiResult<T>() + fun findById(id: String): Trace? { + return repository.findOne(id)?.toUserDto() + } } diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/UserConversions.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/UserConversions.kt new file mode 100644 index 00000000..8612ee8c --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/UserConversions.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.service + +import org.opendc.web.api.model.* +import org.opendc.web.proto.user.Project + +/** + * Conversions into DTOs provided to users. + */ + +/** + * Convert a [Trace] entity into a [org.opendc.web.proto.Trace] DTO. + */ +internal fun Trace.toUserDto(): org.opendc.web.proto.Trace { + return org.opendc.web.proto.Trace(id, name, type) +} + +/** + * Convert a [ProjectAuthorization] entity into a [Project] DTO. + */ +internal fun ProjectAuthorization.toUserDto(): Project { + return Project(project.id, project.name, project.createdAt, project.updatedAt, role) +} + +/** + * Convert a [Topology] entity into a [org.opendc.web.proto.user.Topology] DTO. + */ +internal fun Topology.toUserDto(project: Project): org.opendc.web.proto.user.Topology { + return org.opendc.web.proto.user.Topology(id, number, project, name, rooms, createdAt, updatedAt) +} + +/** + * Convert a [Topology] entity into a [org.opendc.web.proto.user.Topology.Summary] DTO. + */ +private fun Topology.toSummaryDto(): org.opendc.web.proto.user.Topology.Summary { + return org.opendc.web.proto.user.Topology.Summary(id, number, name, createdAt, updatedAt) +} + +/** + * Convert a [Portfolio] entity into a [org.opendc.web.proto.user.Portfolio] DTO. + */ +internal fun Portfolio.toUserDto(project: Project): org.opendc.web.proto.user.Portfolio { + return org.opendc.web.proto.user.Portfolio(id, number, project, name, targets, scenarios.map { it.toSummaryDto() }) +} + +/** + * Convert a [Portfolio] entity into a [org.opendc.web.proto.user.Portfolio.Summary] DTO. + */ +private fun Portfolio.toSummaryDto(): org.opendc.web.proto.user.Portfolio.Summary { + return org.opendc.web.proto.user.Portfolio.Summary(id, number, name, targets) +} + +/** + * Convert a [Scenario] entity into a [org.opendc.web.proto.user.Scenario] DTO. + */ +internal fun Scenario.toUserDto(project: Project): org.opendc.web.proto.user.Scenario { + return org.opendc.web.proto.user.Scenario( + id, + number, + project, + portfolio.toSummaryDto(), + name, + workload.toDto(), + topology.toSummaryDto(), + phenomena, + schedulerName, + job.toUserDto() + ) +} + +/** + * Convert a [Scenario] entity into a [org.opendc.web.proto.user.Scenario.Summary] DTO. + */ +private fun Scenario.toSummaryDto(): org.opendc.web.proto.user.Scenario.Summary { + return org.opendc.web.proto.user.Scenario.Summary( + id, + number, + name, + workload.toDto(), + topology.toSummaryDto(), + phenomena, + schedulerName, + job.toUserDto() + ) +} + +/** + * Convert a [Job] entity into a [org.opendc.web.proto.user.Job] DTO. + */ +internal fun Job.toUserDto(): org.opendc.web.proto.user.Job { + return org.opendc.web.proto.user.Job(id, state, createdAt, updatedAt, results) +} + +/** + * Convert a [Workload] entity into a DTO. + */ +internal fun Workload.toDto(): org.opendc.web.proto.Workload { + return org.opendc.web.proto.Workload(trace.toUserDto(), samplingFraction) +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/Utils.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/Utils.kt new file mode 100644 index 00000000..254be8b7 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/Utils.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.service + +import org.opendc.web.proto.user.ProjectRole + +/** + * Flag to indicate that the user can edit a project. + */ +internal val ProjectRole.canEdit: Boolean + get() = when (this) { + ProjectRole.OWNER, ProjectRole.EDITOR -> true + ProjectRole.VIEWER -> false + } + +/** + * Flag to indicate that the user can delete a project. + */ +internal val ProjectRole.canDelete: Boolean + get() = this == ProjectRole.OWNER diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/KotlinModuleCustomizer.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/KotlinModuleCustomizer.kt new file mode 100644 index 00000000..8d91a00c --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/KotlinModuleCustomizer.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.util + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule +import io.quarkus.jackson.ObjectMapperCustomizer +import javax.inject.Singleton + +/** + * Helper class to register the Kotlin Jackson module. + */ +@Singleton +class KotlinModuleCustomizer : ObjectMapperCustomizer { + override fun customize(objectMapper: ObjectMapper) { + objectMapper.registerModule(KotlinModule.Builder().build()) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/AbstractJsonSqlTypeDescriptor.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/AbstractJsonSqlTypeDescriptor.kt new file mode 100644 index 00000000..134739c9 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/AbstractJsonSqlTypeDescriptor.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.util.hibernate.json + +import org.hibernate.type.descriptor.ValueExtractor +import org.hibernate.type.descriptor.WrapperOptions +import org.hibernate.type.descriptor.java.JavaTypeDescriptor +import org.hibernate.type.descriptor.sql.BasicExtractor +import org.hibernate.type.descriptor.sql.SqlTypeDescriptor +import java.sql.CallableStatement +import java.sql.ResultSet +import java.sql.Types + +/** + * Abstract implementation of a [SqlTypeDescriptor] for Hibernate JSON type. + */ +internal abstract class AbstractJsonSqlTypeDescriptor : SqlTypeDescriptor { + + override fun getSqlType(): Int { + return Types.OTHER + } + + override fun canBeRemapped(): Boolean { + return true + } + + override fun <X> getExtractor(typeDescriptor: JavaTypeDescriptor<X>): ValueExtractor<X> { + return object : BasicExtractor<X>(typeDescriptor, this) { + override fun doExtract(rs: ResultSet, name: String, options: WrapperOptions): X { + return typeDescriptor.wrap(extractJson(rs, name), options) + } + + override fun doExtract(statement: CallableStatement, index: Int, options: WrapperOptions): X { + return typeDescriptor.wrap(extractJson(statement, index), options) + } + + override fun doExtract(statement: CallableStatement, name: String, options: WrapperOptions): X { + return typeDescriptor.wrap(extractJson(statement, name), options) + } + } + } + + open fun extractJson(rs: ResultSet, name: String): Any? { + return rs.getObject(name) + } + + open fun extractJson(statement: CallableStatement, index: Int): Any? { + return statement.getObject(index) + } + + open fun extractJson(statement: CallableStatement, name: String): Any? { + return statement.getObject(name) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonBinarySqlTypeDescriptor.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonBinarySqlTypeDescriptor.kt new file mode 100644 index 00000000..32f69928 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonBinarySqlTypeDescriptor.kt @@ -0,0 +1,26 @@ +package org.opendc.web.api.util.hibernate.json + +import com.fasterxml.jackson.databind.JsonNode +import org.hibernate.type.descriptor.ValueBinder +import org.hibernate.type.descriptor.WrapperOptions +import org.hibernate.type.descriptor.java.JavaTypeDescriptor +import org.hibernate.type.descriptor.sql.BasicBinder +import java.sql.CallableStatement +import java.sql.PreparedStatement + +/** + * A [AbstractJsonSqlTypeDescriptor] that stores the JSON as binary (JSONB). + */ +internal object JsonBinarySqlTypeDescriptor : AbstractJsonSqlTypeDescriptor() { + override fun <X> getBinder(typeDescriptor: JavaTypeDescriptor<X>): ValueBinder<X> { + return object : BasicBinder<X>(typeDescriptor, this) { + override fun doBind(st: PreparedStatement, value: X, index: Int, options: WrapperOptions) { + st.setObject(index, typeDescriptor.unwrap(value, JsonNode::class.java, options), sqlType) + } + + override fun doBind(st: CallableStatement, value: X, name: String, options: WrapperOptions) { + st.setObject(name, typeDescriptor.unwrap(value, JsonNode::class.java, options), sqlType) + } + } + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonBytesSqlTypeDescriptor.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonBytesSqlTypeDescriptor.kt new file mode 100644 index 00000000..eaecc5b0 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonBytesSqlTypeDescriptor.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.util.hibernate.json + +import org.hibernate.type.descriptor.ValueBinder +import org.hibernate.type.descriptor.WrapperOptions +import org.hibernate.type.descriptor.java.JavaTypeDescriptor +import org.hibernate.type.descriptor.sql.BasicBinder +import java.io.UnsupportedEncodingException +import java.sql.* + +/** + * A [AbstractJsonSqlTypeDescriptor] that stores the JSON as UTF-8 encoded bytes. + */ +internal object JsonBytesSqlTypeDescriptor : AbstractJsonSqlTypeDescriptor() { + private val CHARSET = Charsets.UTF_8 + + override fun getSqlType(): Int { + return Types.BINARY + } + + override fun <X> getBinder(javaTypeDescriptor: JavaTypeDescriptor<X>): ValueBinder<X> { + return object : BasicBinder<X>(javaTypeDescriptor, this) { + override fun doBind(st: PreparedStatement, value: X, index: Int, options: WrapperOptions) { + st.setBytes(index, toJsonBytes(javaTypeDescriptor.unwrap(value, String::class.java, options))) + } + + override fun doBind(st: CallableStatement, value: X, name: String, options: WrapperOptions) { + st.setBytes(name, toJsonBytes(javaTypeDescriptor.unwrap(value, String::class.java, options))) + } + } + } + + override fun extractJson(rs: ResultSet, name: String): Any? { + return fromJsonBytes(rs.getBytes(name)) + } + + override fun extractJson(statement: CallableStatement, index: Int): Any? { + return fromJsonBytes(statement.getBytes(index)) + } + + override fun extractJson(statement: CallableStatement, name: String): Any? { + return fromJsonBytes(statement.getBytes(name)) + } + + private fun toJsonBytes(jsonValue: String): ByteArray? { + return try { + jsonValue.toByteArray(CHARSET) + } catch (e: UnsupportedEncodingException) { + throw IllegalStateException(e) + } + } + + private fun fromJsonBytes(jsonBytes: ByteArray?): String? { + return if (jsonBytes == null) { + null + } else try { + String(jsonBytes, CHARSET) + } catch (e: UnsupportedEncodingException) { + throw IllegalStateException(e) + } + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonSqlTypeDescriptor.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonSqlTypeDescriptor.kt new file mode 100644 index 00000000..e005f368 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonSqlTypeDescriptor.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.util.hibernate.json + +import org.hibernate.dialect.H2Dialect +import org.hibernate.dialect.PostgreSQL81Dialect +import org.hibernate.internal.SessionImpl +import org.hibernate.type.descriptor.ValueBinder +import org.hibernate.type.descriptor.ValueExtractor +import org.hibernate.type.descriptor.WrapperOptions +import org.hibernate.type.descriptor.java.JavaTypeDescriptor +import org.hibernate.type.descriptor.sql.BasicBinder +import org.hibernate.type.descriptor.sql.BasicExtractor +import org.hibernate.type.descriptor.sql.SqlTypeDescriptor +import java.sql.* + +/** + * A [SqlTypeDescriptor] that automatically selects the correct implementation for the database dialect. + */ +internal object JsonSqlTypeDescriptor : SqlTypeDescriptor { + + override fun getSqlType(): Int = Types.OTHER + + override fun canBeRemapped(): Boolean = true + + override fun <X> getExtractor(javaTypeDescriptor: JavaTypeDescriptor<X>): ValueExtractor<X> { + return object : BasicExtractor<X>(javaTypeDescriptor, this) { + private var delegate: AbstractJsonSqlTypeDescriptor? = null + + override fun doExtract(rs: ResultSet, name: String, options: WrapperOptions): X { + return javaTypeDescriptor.wrap(delegate(options).extractJson(rs, name), options) + } + + override fun doExtract(statement: CallableStatement, index: Int, options: WrapperOptions): X { + return javaTypeDescriptor.wrap(delegate(options).extractJson(statement, index), options) + } + + override fun doExtract(statement: CallableStatement, name: String, options: WrapperOptions): X { + return javaTypeDescriptor.wrap(delegate(options).extractJson(statement, name), options) + } + + private fun delegate(options: WrapperOptions): AbstractJsonSqlTypeDescriptor { + var delegate = delegate + if (delegate == null) { + delegate = resolveSqlTypeDescriptor(options) + this.delegate = delegate + } + return delegate + } + } + } + + override fun <X> getBinder(javaTypeDescriptor: JavaTypeDescriptor<X>): ValueBinder<X> { + return object : BasicBinder<X>(javaTypeDescriptor, this) { + private var delegate: ValueBinder<X>? = null + + override fun doBind(st: PreparedStatement, value: X, index: Int, options: WrapperOptions) { + delegate(options).bind(st, value, index, options) + } + + override fun doBind(st: CallableStatement, value: X, name: String, options: WrapperOptions) { + delegate(options).bind(st, value, name, options) + } + + private fun delegate(options: WrapperOptions): ValueBinder<X> { + var delegate = delegate + if (delegate == null) { + delegate = checkNotNull(resolveSqlTypeDescriptor(options).getBinder(javaTypeDescriptor)) + this.delegate = delegate + } + return delegate + } + } + } + + /** + * Helper method to resolve the appropriate [SqlTypeDescriptor] based on the [WrapperOptions]. + */ + private fun resolveSqlTypeDescriptor(options: WrapperOptions): AbstractJsonSqlTypeDescriptor { + val session = options as? SessionImpl + return when (session?.jdbcServices?.dialect) { + is PostgreSQL81Dialect -> JsonBinarySqlTypeDescriptor + is H2Dialect -> JsonBytesSqlTypeDescriptor + else -> JsonStringSqlTypeDescriptor + } + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonStringSqlTypeDescriptor.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonStringSqlTypeDescriptor.kt new file mode 100644 index 00000000..cf400c95 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonStringSqlTypeDescriptor.kt @@ -0,0 +1,38 @@ +package org.opendc.web.api.util.hibernate.json + +import org.hibernate.type.descriptor.ValueBinder +import org.hibernate.type.descriptor.WrapperOptions +import org.hibernate.type.descriptor.java.JavaTypeDescriptor +import org.hibernate.type.descriptor.sql.BasicBinder +import java.sql.* + +/** + * A [AbstractJsonSqlTypeDescriptor] that stores the JSON as string (VARCHAR). + */ +internal object JsonStringSqlTypeDescriptor : AbstractJsonSqlTypeDescriptor() { + override fun getSqlType(): Int = Types.VARCHAR + + override fun <X> getBinder(typeDescriptor: JavaTypeDescriptor<X>): ValueBinder<X> { + return object : BasicBinder<X>(typeDescriptor, this) { + override fun doBind(st: PreparedStatement, value: X, index: Int, options: WrapperOptions) { + st.setString(index, typeDescriptor.unwrap(value, String::class.java, options)) + } + + override fun doBind(st: CallableStatement, value: X, name: String, options: WrapperOptions) { + st.setString(name, typeDescriptor.unwrap(value, String::class.java, options)) + } + } + } + + override fun extractJson(rs: ResultSet, name: String): Any? { + return rs.getString(name) + } + + override fun extractJson(statement: CallableStatement, index: Int): Any? { + return statement.getString(index) + } + + override fun extractJson(statement: CallableStatement, name: String): Any? { + return statement.getString(name) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonType.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonType.kt new file mode 100644 index 00000000..2206e82f --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonType.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.util.hibernate.json + +import com.fasterxml.jackson.databind.ObjectMapper +import org.hibernate.type.AbstractSingleColumnStandardBasicType +import org.hibernate.type.BasicType +import org.hibernate.usertype.DynamicParameterizedType +import java.util.* +import javax.enterprise.inject.spi.CDI + +/** + * A [BasicType] that contains JSON. + */ +class JsonType(objectMapper: ObjectMapper) : AbstractSingleColumnStandardBasicType<Any>(JsonSqlTypeDescriptor, JsonTypeDescriptor(objectMapper)), DynamicParameterizedType { + /** + * No-arg constructor for Hibernate to instantiate. + */ + constructor() : this(CDI.current().select(ObjectMapper::class.java).get()) + + override fun getName(): String = "json" + + override fun registerUnderJavaType(): Boolean = true + + override fun setParameterValues(parameters: Properties) { + (javaTypeDescriptor as JsonTypeDescriptor).setParameterValues(parameters) + } +} diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonTypeDescriptor.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonTypeDescriptor.kt new file mode 100644 index 00000000..3386582e --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonTypeDescriptor.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.util.hibernate.json + +import com.fasterxml.jackson.databind.ObjectMapper +import org.hibernate.HibernateException +import org.hibernate.annotations.common.reflection.XProperty +import org.hibernate.annotations.common.reflection.java.JavaXMember +import org.hibernate.engine.jdbc.BinaryStream +import org.hibernate.engine.jdbc.internal.BinaryStreamImpl +import org.hibernate.type.descriptor.WrapperOptions +import org.hibernate.type.descriptor.java.AbstractTypeDescriptor +import org.hibernate.type.descriptor.java.BlobTypeDescriptor +import org.hibernate.type.descriptor.java.DataHelper +import org.hibernate.type.descriptor.java.MutableMutabilityPlan +import org.hibernate.usertype.DynamicParameterizedType +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream +import java.lang.reflect.Type +import java.sql.Blob +import java.sql.SQLException +import java.util.* + +/** + * An [AbstractTypeDescriptor] implementation for Hibernate JSON type. + */ +internal class JsonTypeDescriptor(private val objectMapper: ObjectMapper) : AbstractTypeDescriptor<Any>(Any::class.java, JsonMutabilityPlan(objectMapper)), DynamicParameterizedType { + private var type: Type? = null + + override fun setParameterValues(parameters: Properties) { + val xProperty = parameters[DynamicParameterizedType.XPROPERTY] as XProperty + type = if (xProperty is JavaXMember) { + val x = xProperty as JavaXMember + x.javaType + } else { + (parameters[DynamicParameterizedType.PARAMETER_TYPE] as DynamicParameterizedType.ParameterType).returnedClass + } + } + + override fun areEqual(one: Any?, another: Any?): Boolean { + return when { + one === another -> true + one == null || another == null -> false + one is String && another is String -> one == another + one is Collection<*> && another is Collection<*> -> Objects.equals(one, another) + else -> areJsonEqual(one, another) + } + } + + override fun toString(value: Any?): String { + return objectMapper.writeValueAsString(value) + } + + override fun fromString(string: String): Any? { + return objectMapper.readValue(string, objectMapper.typeFactory.constructType(type)) + } + + override fun <X> unwrap(value: Any?, type: Class<X>, options: WrapperOptions): X? { + if (value == null) { + return null + } + + @Suppress("UNCHECKED_CAST") + return when { + String::class.java.isAssignableFrom(type) -> toString(value) + BinaryStream::class.java.isAssignableFrom(type) || ByteArray::class.java.isAssignableFrom(type) -> { + val stringValue = if (value is String) value else toString(value) + BinaryStreamImpl(DataHelper.extractBytes(ByteArrayInputStream(stringValue.toByteArray()))) + } + Blob::class.java.isAssignableFrom(type) -> { + val stringValue = if (value is String) value else toString(value) + BlobTypeDescriptor.INSTANCE.fromString(stringValue) + } + Any::class.java.isAssignableFrom(type) -> toJsonType(value) + else -> throw unknownUnwrap(type) + } as X + } + + override fun <X> wrap(value: X?, options: WrapperOptions): Any? { + if (value == null) { + return null + } + + var blob: Blob? = null + if (Blob::class.java.isAssignableFrom(value.javaClass)) { + blob = options.lobCreator.wrap(value as Blob?) + } else if (ByteArray::class.java.isAssignableFrom(value.javaClass)) { + blob = options.lobCreator.createBlob(value as ByteArray?) + } else if (InputStream::class.java.isAssignableFrom(value.javaClass)) { + val inputStream = value as InputStream + blob = try { + options.lobCreator.createBlob(inputStream, inputStream.available().toLong()) + } catch (e: IOException) { + throw unknownWrap(value.javaClass) + } + } + + val stringValue: String = try { + if (blob != null) String(DataHelper.extractBytes(blob.binaryStream)) else value.toString() + } catch (e: SQLException) { + throw HibernateException("Unable to extract binary stream from Blob", e) + } + + return fromString(stringValue) + } + + private class JsonMutabilityPlan(private val objectMapper: ObjectMapper) : MutableMutabilityPlan<Any>() { + override fun deepCopyNotNull(value: Any): Any { + return objectMapper.treeToValue(objectMapper.valueToTree(value), value.javaClass) + } + } + + private fun readObject(value: String): Any { + return objectMapper.readTree(value) + } + + private fun areJsonEqual(one: Any, another: Any): Boolean { + return readObject(objectMapper.writeValueAsString(one)) == readObject(objectMapper.writeValueAsString(another)) + } + + private fun toJsonType(value: Any?): Any { + return try { + readObject(objectMapper.writeValueAsString(value)) + } catch (e: Exception) { + throw IllegalArgumentException(e) + } + } +} diff --git a/opendc-web/opendc-web-api/src/main/resources/META-INF/branding/logo.png b/opendc-web/opendc-web-api/src/main/resources/META-INF/branding/logo.png Binary files differnew file mode 100644 index 00000000..d743038b --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/resources/META-INF/branding/logo.png diff --git a/opendc-web/opendc-web-api/src/main/resources/application-dev.properties b/opendc-web/opendc-web-api/src/main/resources/application-dev.properties new file mode 100644 index 00000000..84da528f --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/resources/application-dev.properties @@ -0,0 +1,28 @@ +# Copyright (c) 2022 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. + +# Datasource (H2) +quarkus.datasource.db-kind = h2 +quarkus.datasource.jdbc.url=jdbc:h2:mem:default;DB_CLOSE_DELAY=-1;INIT=CREATE TYPE IF NOT EXISTS "JSONB" AS blob; + +# Hibernate +quarkus.hibernate-orm.dialect=org.hibernate.dialect.H2Dialect +quarkus.hibernate-orm.database.generation=drop-and-create +quarkus.hibernate-orm.sql-load-script=init-dev.sql diff --git a/opendc-web/opendc-web-api/src/main/resources/application-prod.properties b/opendc-web/opendc-web-api/src/main/resources/application-prod.properties new file mode 100644 index 00000000..3af1dfd9 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/resources/application-prod.properties @@ -0,0 +1,29 @@ +# Copyright (c) 2022 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. + +# Datasource +quarkus.datasource.db-kind=postgresql +quarkus.datasource.username=${OPENDC_DB_USERNAME} +quarkus.datasource.password=${OPENDC_DB_PASSWORD} +quarkus.datasource.jdbc.url=${OPENDC_DB_URL} + +# Hibernate +quarkus.hibernate-orm.dialect=org.hibernate.dialect.PostgreSQL95Dialect +quarkus.hibernate-orm.database.generation=validate diff --git a/opendc-web/opendc-web-api/src/main/resources/application-test.properties b/opendc-web/opendc-web-api/src/main/resources/application-test.properties new file mode 100644 index 00000000..0710f200 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/resources/application-test.properties @@ -0,0 +1,36 @@ +# Copyright (c) 2022 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. + +# Datasource configuration +quarkus.datasource.db-kind = h2 +quarkus.datasource.jdbc.url=jdbc:h2:mem:default;DB_CLOSE_DELAY=-1;INIT=CREATE TYPE "JSONB" AS blob; + +quarkus.hibernate-orm.dialect=org.hibernate.dialect.H2Dialect +quarkus.hibernate-orm.database.generation=drop-and-create + +# No OIDC for tests +quarkus.oidc.enabled=false +quarkus.oidc.auth-server-url= +quarkus.oidc.client-id= + +# Disable OpenAPI/Swagger +quarkus.smallrye-openapi.enable=false +quarkus.swagger-ui.enable=false +quarkus.smallrye-openapi.oidc-open-id-connect-url= diff --git a/opendc-web/opendc-web-api/src/main/resources/application.properties b/opendc-web/opendc-web-api/src/main/resources/application.properties new file mode 100644 index 00000000..fa134e7e --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/resources/application.properties @@ -0,0 +1,48 @@ +# Copyright (c) 2022 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. + +quarkus.http.cors=true + +# OpenID +quarkus.oidc.auth-server-url=https://${OPENDC_AUTH0_DOMAIN} +quarkus.oidc.client-id=${OPENDC_AUTH0_AUDIENCE} +quarkus.oidc.token.audience=${quarkus.oidc.client-id} +quarkus.oidc.roles.role-claim-path=scope + +# OpenAPI and Swagger +quarkus.smallrye-openapi.info-title=OpenDC REST API +%dev.quarkus.smallrye-openapi.info-title=OpenDC REST API (development) +quarkus.smallrye-openapi.info-version=2.1-rc1 +quarkus.smallrye-openapi.info-description=OpenDC is an open-source datacenter simulator for education, featuring real-time online collaboration, diverse simulation models, and detailed performance feedback statistics. +quarkus.smallrye-openapi.info-contact-email=opendc@atlarge-research.com +quarkus.smallrye-openapi.info-contact-name=OpenDC Support +quarkus.smallrye-openapi.info-contact-url=https://opendc.org +quarkus.smallrye-openapi.info-license-name=MIT +quarkus.smallrye-openapi.info-license-url=https://github.com/atlarge-research/opendc/blob/master/LICENSE.txt + +quarkus.swagger-ui.path=docs +quarkus.swagger-ui.always-include=true +quarkus.swagger-ui.oauth-client-id=${OPENDC_AUTH0_DOCS_CLIENT_ID:} +quarkus.swagger-ui.oauth-additional-query-string-params={"audience":"${OPENDC_AUTH0_AUDIENCE:https://api.opendc.org/}"} + +quarkus.smallrye-openapi.security-scheme=oidc +quarkus.smallrye-openapi.security-scheme-name=Auth0 +quarkus.smallrye-openapi.oidc-open-id-connect-url=https://${OPENDC_AUTH0_DOMAIN:opendc.eu.auth0.com}/.well-known/openid-configuration +quarkus.smallrye-openapi.servers=http://localhost:8080 diff --git a/opendc-web/opendc-web-api/src/main/resources/init-dev.sql b/opendc-web/opendc-web-api/src/main/resources/init-dev.sql new file mode 100644 index 00000000..756eff46 --- /dev/null +++ b/opendc-web/opendc-web-api/src/main/resources/init-dev.sql @@ -0,0 +1,3 @@ + +-- Add example traces +INSERT INTO traces (id, name, type) VALUES ('bitbrains-small', 'Bitbrains Small', 'vm'); diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/SchedulerResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/SchedulerResourceTest.kt new file mode 100644 index 00000000..e88e1c1c --- /dev/null +++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/SchedulerResourceTest.kt @@ -0,0 +1,48 @@ +/* + * 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. + */ + +package org.opendc.web.api.rest + +import io.quarkus.test.junit.QuarkusTest +import io.restassured.http.ContentType +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import org.junit.jupiter.api.Test + +/** + * Test suite for [SchedulerResource] + */ +@QuarkusTest +class SchedulerResourceTest { + /** + * Test to verify whether we can obtain all schedulers. + */ + @Test + fun testGetSchedulers() { + When { + get("/schedulers") + } Then { + statusCode(200) + contentType(ContentType.JSON) + } + } +} diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/TraceResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/TraceResourceTest.kt new file mode 100644 index 00000000..6fab9953 --- /dev/null +++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/TraceResourceTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.rest + +import io.mockk.every +import io.quarkiverse.test.junit.mockk.InjectMock +import io.quarkus.test.common.http.TestHTTPEndpoint +import io.quarkus.test.junit.QuarkusMock +import io.quarkus.test.junit.QuarkusTest +import io.restassured.http.ContentType +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import org.hamcrest.Matchers +import org.hamcrest.Matchers.equalTo +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.opendc.web.api.service.TraceService +import org.opendc.web.proto.Trace + +/** + * Test suite for [TraceResource]. + */ +@QuarkusTest +@TestHTTPEndpoint(TraceResource::class) +class TraceResourceTest { + @InjectMock + private lateinit var traceService: TraceService + + @BeforeEach + fun setUp() { + QuarkusMock.installMockForType(traceService, TraceService::class.java) + } + + /** + * Test that tries to obtain all traces (empty response). + */ + @Test + fun testGetAllEmpy() { + every { traceService.findAll() } returns emptyList() + + When { + get() + } Then { + statusCode(200) + contentType(ContentType.JSON) + body("", Matchers.empty<String>()) + } + } + + /** + * Test that tries to obtain a non-existent trace. + */ + @Test + fun testGetNonExisting() { + every { traceService.findById("bitbrains") } returns null + + When { + get("/bitbrains") + } Then { + statusCode(404) + contentType(ContentType.JSON) + } + } + + /** + * Test that tries to obtain an existing trace. + */ + @Test + fun testGetExisting() { + every { traceService.findById("bitbrains") } returns Trace("bitbrains", "Bitbrains", "VM") + + When { + get("/bitbrains") + } Then { + statusCode(200) + contentType(ContentType.JSON) + body("name", equalTo("Bitbrains")) + } + } +} diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/runner/JobResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/runner/JobResourceTest.kt new file mode 100644 index 00000000..b82c60e8 --- /dev/null +++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/runner/JobResourceTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.rest.runner + +import io.mockk.every +import io.quarkiverse.test.junit.mockk.InjectMock +import io.quarkus.test.common.http.TestHTTPEndpoint +import io.quarkus.test.junit.QuarkusMock +import io.quarkus.test.junit.QuarkusTest +import io.quarkus.test.security.TestSecurity +import io.restassured.http.ContentType +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import org.hamcrest.Matchers.equalTo +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.opendc.web.api.service.JobService +import org.opendc.web.proto.* +import org.opendc.web.proto.Targets +import org.opendc.web.proto.runner.Job +import org.opendc.web.proto.runner.Portfolio +import org.opendc.web.proto.runner.Scenario +import org.opendc.web.proto.runner.Topology +import java.time.Instant + +/** + * Test suite for [JobResource]. + */ +@QuarkusTest +@TestHTTPEndpoint(JobResource::class) +class JobResourceTest { + @InjectMock + private lateinit var jobService: JobService + + /** + * Dummy values + */ + private val dummyPortfolio = Portfolio(1, 1, "test", Targets(emptySet())) + private val dummyTopology = Topology(1, 1, "test", emptyList(), Instant.now(), Instant.now()) + private val dummyTrace = Trace("bitbrains", "Bitbrains", "vm") + private val dummyScenario = Scenario(1, 1, dummyPortfolio, "test", Workload(dummyTrace, 1.0), dummyTopology, OperationalPhenomena(false, false), "test",) + private val dummyJob = Job(1, dummyScenario, JobState.PENDING, Instant.now(), Instant.now()) + + @BeforeEach + fun setUp() { + QuarkusMock.installMockForType(jobService, JobService::class.java) + } + + /** + * Test that tries to query the pending jobs without token. + */ + @Test + fun testQueryWithoutToken() { + When { + get() + } Then { + statusCode(401) + } + } + + /** + * Test that tries to query the pending jobs for a user. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testQueryInvalidScope() { + When { + get() + } Then { + statusCode(403) + } + } + + /** + * Test that tries to query the pending jobs for a runner. + */ + @Test + @TestSecurity(user = "testUser", roles = ["runner"]) + fun testQuery() { + every { jobService.queryPending() } returns listOf(dummyJob) + + When { + get() + } Then { + statusCode(200) + contentType(ContentType.JSON) + body("get(0).id", equalTo(1)) + } + } + + /** + * Test that tries to obtain a non-existent job. + */ + @Test + @TestSecurity(user = "testUser", roles = ["runner"]) + fun testGetNonExisting() { + every { jobService.findById(1) } returns null + + When { + get("/1") + } Then { + statusCode(404) + contentType(ContentType.JSON) + } + } + + /** + * Test that tries to obtain a job. + */ + @Test + @TestSecurity(user = "testUser", roles = ["runner"]) + fun testGetExisting() { + every { jobService.findById(1) } returns dummyJob + + When { + get("/1") + } Then { + statusCode(200) + contentType(ContentType.JSON) + body("id", equalTo(1)) + } + } + + /** + * Test that tries to update a non-existent job. + */ + @Test + @TestSecurity(user = "testUser", roles = ["runner"]) + fun testUpdateNonExistent() { + every { jobService.updateState(1, any(), any()) } returns null + + Given { + body(Job.Update(JobState.PENDING)) + contentType(ContentType.JSON) + } When { + post("/1") + } Then { + statusCode(404) + contentType(ContentType.JSON) + } + } + + /** + * Test that tries to update a job. + */ + @Test + @TestSecurity(user = "testUser", roles = ["runner"]) + fun testUpdateState() { + every { jobService.updateState(1, any(), any()) } returns dummyJob.copy(state = JobState.CLAIMED) + + Given { + body(Job.Update(JobState.CLAIMED)) + contentType(ContentType.JSON) + } When { + post("/1") + } Then { + statusCode(200) + contentType(ContentType.JSON) + body("state", equalTo(JobState.CLAIMED.toString())) + } + } + + /** + * Test that tries to update a job with invalid input. + */ + @Test + @TestSecurity(user = "testUser", roles = ["runner"]) + fun testUpdateInvalidInput() { + Given { + body("""{ "test": "test" }""") + contentType(ContentType.JSON) + } When { + post("/1") + } Then { + statusCode(400) + contentType(ContentType.JSON) + } + } +} diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/PortfolioResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/PortfolioResourceTest.kt new file mode 100644 index 00000000..f74efbca --- /dev/null +++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/PortfolioResourceTest.kt @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.rest.user + +import io.mockk.every +import io.quarkiverse.test.junit.mockk.InjectMock +import io.quarkus.test.common.http.TestHTTPEndpoint +import io.quarkus.test.junit.QuarkusMock +import io.quarkus.test.junit.QuarkusTest +import io.quarkus.test.security.TestSecurity +import io.restassured.http.ContentType +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import org.hamcrest.Matchers +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.opendc.web.api.service.PortfolioService +import org.opendc.web.proto.Targets +import org.opendc.web.proto.user.Portfolio +import org.opendc.web.proto.user.Project +import org.opendc.web.proto.user.ProjectRole +import java.time.Instant + +/** + * Test suite for [PortfolioResource]. + */ +@QuarkusTest +@TestHTTPEndpoint(PortfolioResource::class) +class PortfolioResourceTest { + @InjectMock + private lateinit var portfolioService: PortfolioService + + /** + * Dummy project and portfolio + */ + private val dummyProject = Project(1, "test", Instant.now(), Instant.now(), ProjectRole.OWNER) + private val dummyPortfolio = Portfolio(1, 1, dummyProject, "test", Targets(emptySet(), 1), emptyList()) + + @BeforeEach + fun setUp() { + QuarkusMock.installMockForType(portfolioService, PortfolioService::class.java) + } + + /** + * Test that tries to obtain the list of portfolios belonging to a project. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testGetForProject() { + every { portfolioService.findAll("testUser", 1) } returns emptyList() + + Given { + pathParam("project", "1") + } When { + get() + } Then { + statusCode(200) + contentType(ContentType.JSON) + } + } + + /** + * Test that tries to create a topology for a project. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testCreateNonExistent() { + every { portfolioService.create("testUser", 1, any()) } returns null + + Given { + pathParam("project", "1") + + body(Portfolio.Create("test", Targets(emptySet(), 1))) + contentType(ContentType.JSON) + } When { + post() + } Then { + statusCode(404) + contentType(ContentType.JSON) + } + } + + /** + * Test that tries to create a portfolio for a scenario. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testCreate() { + every { portfolioService.create("testUser", 1, any()) } returns dummyPortfolio + + Given { + pathParam("project", "1") + + body(Portfolio.Create("test", Targets(emptySet(), 1))) + contentType(ContentType.JSON) + } When { + post() + } Then { + statusCode(200) + contentType(ContentType.JSON) + body("id", Matchers.equalTo(1)) + body("name", Matchers.equalTo("test")) + } + } + + /** + * Test to create a portfolio with an empty body. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testCreateEmpty() { + Given { + pathParam("project", "1") + + body("{}") + contentType(ContentType.JSON) + } When { + post() + } Then { + statusCode(400) + contentType(ContentType.JSON) + } + } + + /** + * Test to create a portfolio with a blank name. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testCreateBlankName() { + Given { + pathParam("project", "1") + + body(Portfolio.Create("", Targets(emptySet(), 1))) + contentType(ContentType.JSON) + } When { + post() + } Then { + statusCode(400) + contentType(ContentType.JSON) + } + } + + /** + * Test that tries to obtain a portfolio without token. + */ + @Test + fun testGetWithoutToken() { + Given { + pathParam("project", "1") + } When { + get("/1") + } Then { + statusCode(401) + } + } + + /** + * Test that tries to obtain a portfolio with an invalid scope. + */ + @Test + @TestSecurity(user = "testUser", roles = ["runner"]) + fun testGetInvalidToken() { + Given { + pathParam("project", "1") + } When { + get("/1") + } Then { + statusCode(403) + } + } + + /** + * Test that tries to obtain a non-existent portfolio. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testGetNonExisting() { + every { portfolioService.findOne("testUser", 1, 1) } returns null + + Given { + pathParam("project", "1") + } When { + get("/1") + } Then { + statusCode(404) + contentType(ContentType.JSON) + } + } + + /** + * Test that tries to obtain a portfolio. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testGetExisting() { + every { portfolioService.findOne("testUser", 1, 1) } returns dummyPortfolio + + Given { + pathParam("project", "1") + } When { + get("/1") + } Then { + statusCode(200) + contentType(ContentType.JSON) + body("id", Matchers.equalTo(1)) + } + } + + /** + * Test to delete a non-existent portfolio. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testDeleteNonExistent() { + every { portfolioService.delete("testUser", 1, 1) } returns null + + Given { + pathParam("project", "1") + } When { + delete("/1") + } Then { + statusCode(404) + } + } + + /** + * Test to delete a portfolio. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testDelete() { + every { portfolioService.delete("testUser", 1, 1) } returns dummyPortfolio + + Given { + pathParam("project", "1") + } When { + delete("/1") + } Then { + statusCode(200) + contentType(ContentType.JSON) + } + } +} diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/PortfolioScenarioResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/PortfolioScenarioResourceTest.kt new file mode 100644 index 00000000..dbafa8c0 --- /dev/null +++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/PortfolioScenarioResourceTest.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.rest.user + +import io.mockk.every +import io.quarkiverse.test.junit.mockk.InjectMock +import io.quarkus.test.common.http.TestHTTPEndpoint +import io.quarkus.test.junit.QuarkusMock +import io.quarkus.test.junit.QuarkusTest +import io.quarkus.test.security.TestSecurity +import io.restassured.http.ContentType +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import org.hamcrest.Matchers +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.opendc.web.api.service.ScenarioService +import org.opendc.web.proto.* +import org.opendc.web.proto.user.* +import java.time.Instant + +/** + * Test suite for [PortfolioScenarioResource]. + */ +@QuarkusTest +@TestHTTPEndpoint(PortfolioScenarioResource::class) +class PortfolioScenarioResourceTest { + @InjectMock + private lateinit var scenarioService: ScenarioService + + /** + * Dummy values + */ + private val dummyProject = Project(0, "test", Instant.now(), Instant.now(), ProjectRole.OWNER) + private val dummyPortfolio = Portfolio.Summary(1, 1, "test", Targets(emptySet())) + private val dummyJob = Job(1, JobState.PENDING, Instant.now(), Instant.now(), null) + private val dummyTrace = Trace("bitbrains", "Bitbrains", "vm") + private val dummyTopology = Topology.Summary(1, 1, "test", Instant.now(), Instant.now()) + private val dummyScenario = Scenario( + 1, + 1, + dummyProject, + dummyPortfolio, + "test", + Workload(dummyTrace, 1.0), + dummyTopology, + OperationalPhenomena(false, false), + "test", + dummyJob + ) + + @BeforeEach + fun setUp() { + QuarkusMock.installMockForType(scenarioService, ScenarioService::class.java) + } + + /** + * Test that tries to obtain a portfolio without token. + */ + @Test + fun testGetWithoutToken() { + Given { + pathParam("project", "1") + pathParam("portfolio", "1") + } When { + get() + } Then { + statusCode(401) + } + } + + /** + * Test that tries to obtain a portfolio with an invalid scope. + */ + @Test + @TestSecurity(user = "testUser", roles = ["runner"]) + fun testGetInvalidToken() { + Given { + pathParam("project", "1") + pathParam("portfolio", "1") + } When { + get() + } Then { + statusCode(403) + } + } + + /** + * Test that tries to obtain a non-existent portfolio. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testGet() { + every { scenarioService.findAll("testUser", 1, 1) } returns emptyList() + + Given { + pathParam("project", "1") + pathParam("portfolio", "1") + } When { + get() + } Then { + statusCode(200) + contentType(ContentType.JSON) + } + } + + /** + * Test that tries to create a scenario for a portfolio. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testCreateNonExistent() { + every { scenarioService.create("testUser", 1, any(), any()) } returns null + + Given { + pathParam("project", "1") + pathParam("portfolio", "1") + + body(Scenario.Create("test", Workload.Spec("test", 1.0), 1, OperationalPhenomena(false, false), "test")) + contentType(ContentType.JSON) + } When { + post() + } Then { + statusCode(404) + contentType(ContentType.JSON) + } + } + + /** + * Test that tries to create a scenario for a portfolio. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testCreate() { + every { scenarioService.create("testUser", 1, 1, any()) } returns dummyScenario + + Given { + pathParam("project", "1") + pathParam("portfolio", "1") + + body(Scenario.Create("test", Workload.Spec("test", 1.0), 1, OperationalPhenomena(false, false), "test")) + contentType(ContentType.JSON) + } When { + post() + } Then { + statusCode(200) + contentType(ContentType.JSON) + body("id", Matchers.equalTo(1)) + body("name", Matchers.equalTo("test")) + } + } + + /** + * Test to create a project with an empty body. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testCreateEmpty() { + Given { + pathParam("project", "1") + pathParam("portfolio", "1") + + body("{}") + contentType(ContentType.JSON) + } When { + post() + } Then { + statusCode(400) + contentType(ContentType.JSON) + } + } + + /** + * Test to create a project with a blank name. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testCreateBlankName() { + Given { + pathParam("project", "1") + pathParam("portfolio", "1") + + body(Scenario.Create("", Workload.Spec("test", 1.0), 1, OperationalPhenomena(false, false), "test")) + contentType(ContentType.JSON) + } When { + post() + } Then { + statusCode(400) + contentType(ContentType.JSON) + } + } +} diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/ProjectResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/ProjectResourceTest.kt new file mode 100644 index 00000000..bcbcbab1 --- /dev/null +++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/ProjectResourceTest.kt @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.rest.user + +import io.mockk.every +import io.quarkiverse.test.junit.mockk.InjectMock +import io.quarkus.test.common.http.TestHTTPEndpoint +import io.quarkus.test.junit.QuarkusMock +import io.quarkus.test.junit.QuarkusTest +import io.quarkus.test.security.TestSecurity +import io.restassured.http.ContentType +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import org.hamcrest.Matchers.equalTo +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.opendc.web.api.service.ProjectService +import org.opendc.web.proto.user.Project +import org.opendc.web.proto.user.ProjectRole +import java.time.Instant + +/** + * Test suite for [ProjectResource]. + */ +@QuarkusTest +@TestHTTPEndpoint(ProjectResource::class) +class ProjectResourceTest { + @InjectMock + private lateinit var projectService: ProjectService + + /** + * Dummy values. + */ + private val dummyProject = Project(0, "test", Instant.now(), Instant.now(), ProjectRole.OWNER) + + @BeforeEach + fun setUp() { + QuarkusMock.installMockForType(projectService, ProjectService::class.java) + } + + /** + * Test that tries to obtain all projects without token. + */ + @Test + fun testGetAllWithoutToken() { + When { + get() + } Then { + statusCode(401) + } + } + + /** + * Test that tries to obtain all projects with an invalid scope. + */ + @Test + @TestSecurity(user = "testUser", roles = ["runner"]) + fun testGetAllWithInvalidScope() { + When { + get() + } Then { + statusCode(403) + } + } + + /** + * Test that tries to obtain all project for a user. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testGetAll() { + val projects = listOf(dummyProject) + every { projectService.findWithUser("testUser") } returns projects + + When { + get() + } Then { + statusCode(200) + contentType(ContentType.JSON) + body("get(0).name", equalTo("test")) + } + } + + /** + * Test that tries to obtain a non-existent project. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testGetNonExisting() { + every { projectService.findWithUser("testUser", 1) } returns null + + When { + get("/1") + } Then { + statusCode(404) + contentType(ContentType.JSON) + } + } + + /** + * Test that tries to obtain a job. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testGetExisting() { + every { projectService.findWithUser("testUser", 1) } returns dummyProject + + When { + get("/1") + } Then { + statusCode(200) + contentType(ContentType.JSON) + body("id", equalTo(0)) + } + } + + /** + * Test that tries to create a project. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testCreate() { + every { projectService.createForUser("testUser", "test") } returns dummyProject + + Given { + body(Project.Create("test")) + contentType(ContentType.JSON) + } When { + post() + } Then { + statusCode(200) + contentType(ContentType.JSON) + body("id", equalTo(0)) + body("name", equalTo("test")) + } + } + + /** + * Test to create a project with an empty body. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testCreateEmpty() { + Given { + body("{}") + contentType(ContentType.JSON) + } When { + post() + } Then { + statusCode(400) + contentType(ContentType.JSON) + } + } + + /** + * Test to create a project with a blank name. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testCreateBlankName() { + Given { + body(Project.Create("")) + contentType(ContentType.JSON) + } When { + post() + } Then { + statusCode(400) + contentType(ContentType.JSON) + } + } + + /** + * Test to delete a non-existent project. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testDeleteNonExistent() { + every { projectService.deleteWithUser("testUser", 1) } returns null + + When { + delete("/1") + } Then { + statusCode(404) + contentType(ContentType.JSON) + } + } + + /** + * Test to delete a project. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testDelete() { + every { projectService.deleteWithUser("testUser", 1) } returns dummyProject + + When { + delete("/1") + } Then { + statusCode(200) + contentType(ContentType.JSON) + } + } + + /** + * Test to delete a project which the user does not own. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testDeleteNonOwner() { + every { projectService.deleteWithUser("testUser", 1) } throws IllegalArgumentException("User does not own project") + + When { + delete("/1") + } Then { + statusCode(403) + contentType(ContentType.JSON) + } + } +} diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/ScenarioResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/ScenarioResourceTest.kt new file mode 100644 index 00000000..65e6e9a1 --- /dev/null +++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/ScenarioResourceTest.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.rest.user + +import io.mockk.every +import io.quarkiverse.test.junit.mockk.InjectMock +import io.quarkus.test.common.http.TestHTTPEndpoint +import io.quarkus.test.junit.QuarkusMock +import io.quarkus.test.junit.QuarkusTest +import io.quarkus.test.security.TestSecurity +import io.restassured.http.ContentType +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import org.hamcrest.Matchers +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.opendc.web.api.service.ScenarioService +import org.opendc.web.proto.* +import org.opendc.web.proto.user.* +import java.time.Instant + +/** + * Test suite for [ScenarioResource]. + */ +@QuarkusTest +@TestHTTPEndpoint(ScenarioResource::class) +class ScenarioResourceTest { + @InjectMock + private lateinit var scenarioService: ScenarioService + + /** + * Dummy values + */ + private val dummyProject = Project(0, "test", Instant.now(), Instant.now(), ProjectRole.OWNER) + private val dummyPortfolio = Portfolio.Summary(1, 1, "test", Targets(emptySet())) + private val dummyJob = Job(1, JobState.PENDING, Instant.now(), Instant.now(), null) + private val dummyTrace = Trace("bitbrains", "Bitbrains", "vm") + private val dummyTopology = Topology.Summary(1, 1, "test", Instant.now(), Instant.now()) + private val dummyScenario = Scenario( + 1, + 1, + dummyProject, + dummyPortfolio, + "test", + Workload(dummyTrace, 1.0), + dummyTopology, + OperationalPhenomena(false, false), + "test", + dummyJob + ) + + @BeforeEach + fun setUp() { + QuarkusMock.installMockForType(scenarioService, ScenarioService::class.java) + } + + /** + * Test that tries to obtain a scenario without token. + */ + @Test + fun testGetWithoutToken() { + Given { + pathParam("project", "1") + } When { + get("/1") + } Then { + statusCode(401) + } + } + + /** + * Test that tries to obtain a scenario with an invalid scope. + */ + @Test + @TestSecurity(user = "testUser", roles = ["runner"]) + fun testGetInvalidToken() { + Given { + pathParam("project", "1") + } When { + get("/1") + } Then { + statusCode(403) + } + } + + /** + * Test that tries to obtain a non-existent scenario. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testGetNonExisting() { + every { scenarioService.findOne("testUser", 1, 1) } returns null + + Given { + pathParam("project", "1") + } When { + get("/1") + } Then { + statusCode(404) + contentType(ContentType.JSON) + } + } + + /** + * Test that tries to obtain a scenario. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testGetExisting() { + every { scenarioService.findOne("testUser", 1, 1) } returns dummyScenario + + Given { + pathParam("project", "1") + } When { + get("/1") + } Then { + statusCode(200) + contentType(ContentType.JSON) + body("id", Matchers.equalTo(1)) + } + } + + /** + * Test to delete a non-existent scenario. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testDeleteNonExistent() { + every { scenarioService.delete("testUser", 1, 1) } returns null + + Given { + pathParam("project", "1") + } When { + delete("/1") + } Then { + statusCode(404) + } + } + + /** + * Test to delete a scenario. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testDelete() { + every { scenarioService.delete("testUser", 1, 1) } returns dummyScenario + + Given { + pathParam("project", "1") + } When { + delete("/1") + } Then { + statusCode(200) + contentType(ContentType.JSON) + } + } +} diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/TopologyResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/TopologyResourceTest.kt new file mode 100644 index 00000000..ececeaca --- /dev/null +++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/TopologyResourceTest.kt @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.api.rest.user + +import io.mockk.every +import io.quarkiverse.test.junit.mockk.InjectMock +import io.quarkus.test.common.http.TestHTTPEndpoint +import io.quarkus.test.junit.QuarkusMock +import io.quarkus.test.junit.QuarkusTest +import io.quarkus.test.security.TestSecurity +import io.restassured.http.ContentType +import io.restassured.module.kotlin.extensions.Given +import io.restassured.module.kotlin.extensions.Then +import io.restassured.module.kotlin.extensions.When +import org.hamcrest.Matchers +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.opendc.web.api.service.TopologyService +import org.opendc.web.proto.user.Project +import org.opendc.web.proto.user.ProjectRole +import org.opendc.web.proto.user.Topology +import java.time.Instant + +/** + * Test suite for [TopologyResource]. + */ +@QuarkusTest +@TestHTTPEndpoint(TopologyResource::class) +class TopologyResourceTest { + @InjectMock + private lateinit var topologyService: TopologyService + + /** + * Dummy project and topology. + */ + private val dummyProject = Project(1, "test", Instant.now(), Instant.now(), ProjectRole.OWNER) + private val dummyTopology = Topology(1, 1, dummyProject, "test", emptyList(), Instant.now(), Instant.now()) + + @BeforeEach + fun setUp() { + QuarkusMock.installMockForType(topologyService, TopologyService::class.java) + } + + /** + * Test that tries to obtain the list of topologies belonging to a project. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testGetForProject() { + every { topologyService.findAll("testUser", 1) } returns emptyList() + + Given { + pathParam("project", "1") + } When { + get() + } Then { + statusCode(200) + contentType(ContentType.JSON) + } + } + + /** + * Test that tries to create a topology for a project. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testCreateNonExistent() { + every { topologyService.create("testUser", 1, any()) } returns null + + Given { + pathParam("project", "1") + + body(Topology.Create("test", emptyList())) + contentType(ContentType.JSON) + } When { + post() + } Then { + statusCode(404) + contentType(ContentType.JSON) + } + } + + /** + * Test that tries to create a topology for a project. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testCreate() { + every { topologyService.create("testUser", 1, any()) } returns dummyTopology + + Given { + pathParam("project", "1") + + body(Topology.Create("test", emptyList())) + contentType(ContentType.JSON) + } When { + post() + } Then { + statusCode(200) + contentType(ContentType.JSON) + body("id", Matchers.equalTo(1)) + body("name", Matchers.equalTo("test")) + } + } + + /** + * Test to create a topology with an empty body. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testCreateEmpty() { + Given { + pathParam("project", "1") + + body("{}") + contentType(ContentType.JSON) + } When { + post() + } Then { + statusCode(400) + contentType(ContentType.JSON) + } + } + + /** + * Test to create a topology with a blank name. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testCreateBlankName() { + Given { + pathParam("project", "1") + + body(Topology.Create("", emptyList())) + contentType(ContentType.JSON) + } When { + post() + } Then { + statusCode(400) + contentType(ContentType.JSON) + } + } + + /** + * Test that tries to obtain a topology without token. + */ + @Test + fun testGetWithoutToken() { + Given { + pathParam("project", "1") + } When { + get("/1") + } Then { + statusCode(401) + } + } + + /** + * Test that tries to obtain a topology with an invalid scope. + */ + @Test + @TestSecurity(user = "testUser", roles = ["runner"]) + fun testGetInvalidToken() { + Given { + pathParam("project", "1") + } When { + get("/1") + } Then { + statusCode(403) + } + } + + /** + * Test that tries to obtain a non-existent topology. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testGetNonExisting() { + every { topologyService.findOne("testUser", 1, 1) } returns null + + Given { + pathParam("project", "1") + } When { + get("/1") + } Then { + statusCode(404) + contentType(ContentType.JSON) + } + } + + /** + * Test that tries to obtain a topology. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testGetExisting() { + every { topologyService.findOne("testUser", 1, 1) } returns dummyTopology + + Given { + pathParam("project", "1") + } When { + get("/1") + } Then { + statusCode(200) + contentType(ContentType.JSON) + body("id", Matchers.equalTo(1)) + println(extract().asPrettyString()) + } + } + + /** + * Test to delete a non-existent topology. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testUpdateNonExistent() { + every { topologyService.update("testUser", any(), any(), any()) } returns null + + Given { + pathParam("project", "1") + body(Topology.Update(emptyList())) + contentType(ContentType.JSON) + } When { + put("/1") + } Then { + statusCode(404) + } + } + + /** + * Test to update a topology. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testUpdate() { + every { topologyService.update("testUser", any(), any(), any()) } returns dummyTopology + + Given { + pathParam("project", "1") + body(Topology.Update(emptyList())) + contentType(ContentType.JSON) + } When { + put("/1") + } Then { + statusCode(200) + contentType(ContentType.JSON) + } + } + + /** + * Test to delete a non-existent topology. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testDeleteNonExistent() { + every { topologyService.delete("testUser", 1, 1) } returns null + + Given { + pathParam("project", "1") + } When { + delete("/1") + } Then { + statusCode(404) + } + } + + /** + * Test to delete a topology. + */ + @Test + @TestSecurity(user = "testUser", roles = ["openid"]) + fun testDelete() { + every { topologyService.delete("testUser", 1, 1) } returns dummyTopology + + Given { + pathParam("project", "1") + } When { + delete("/1") + } Then { + statusCode(200) + contentType(ContentType.JSON) + } + } +} diff --git a/opendc-web/opendc-web-api/static/schema.yml b/opendc-web/opendc-web-api/static/schema.yml deleted file mode 100644 index 56cf58e7..00000000 --- a/opendc-web/opendc-web-api/static/schema.yml +++ /dev/null @@ -1,1631 +0,0 @@ -openapi: 3.0.0 -info: - version: 2.1.0 - title: OpenDC REST API v2 - description: OpenDC is an open-source datacenter simulator for education, featuring - real-time online collaboration, diverse simulation models, and detailed - performance feedback statistics. - license: - name: MIT - url: https://spdx.org/licenses/MIT - contact: - name: Support - url: https://opendc.org -servers: - - url: https://api.opendc.org/v2 -externalDocs: - description: OpenDC REST API v2 - url: https://api.opendc.com/v2/docs/ -security: - - auth0: - - openid -paths: - /projects: - get: - tags: - - projects - description: List Projects of the active user - responses: - "200": - description: Successfully - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - type: array - items: - $ref: "#/components/schemas/Project" - "401": - description: Unauthorized. - content: - "application/json": - schema: - $ref: "#/components/schemas/Unauthorized" - post: - tags: - - projects - description: Add a Project. - requestBody: - content: - application/json: - schema: - properties: - name: - type: string - description: The new Project. - required: true - responses: - "200": - description: Successfully added Project. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Project" - "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" - "/projects/{projectId}": - get: - tags: - - projects - description: Get this Project. - parameters: - - name: projectId - in: path - description: Project's ID. - required: true - schema: - type: string - responses: - "200": - description: Successfully retrieved Project. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Project" - "401": - description: Unauthorized. - content: - "application/json": - schema: - $ref: "#/components/schemas/Unauthorized" - "403": - description: Forbidden from retrieving Project. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Project not found - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - put: - tags: - - projects - description: Update this Project. - parameters: - - name: projectId - in: path - description: Project's ID. - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - properties: - project: - $ref: "#/components/schemas/Project" - description: Project's new properties. - required: true - responses: - "200": - description: Successfully updated Project. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Project" - "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 updating Project. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Project not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - delete: - tags: - - projects - description: Delete this project. - parameters: - - name: projectId - in: path - description: Project's ID. - required: true - schema: - type: string - responses: - "200": - description: Successfully deleted Project. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Project" - "401": - description: Unauthorized. - content: - "application/json": - schema: - $ref: "#/components/schemas/Unauthorized" - "403": - description: Forbidden from deleting Project. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Project not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - "/projects/{projectId}/topologies": - get: - tags: - - projects - description: Get Project Topologies. - parameters: - - name: projectId - in: path - description: Project's ID. - required: true - schema: - type: string - responses: - "200": - description: Successfully retrieved Project Topologies. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - type: array - items: - $ref: "#/components/schemas/Topology" - "401": - description: Unauthorized. - content: - "application/json": - schema: - $ref: "#/components/schemas/Unauthorized" - "403": - description: Forbidden from retrieving Project. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Project not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - post: - tags: - - projects - description: Add a Topology. - parameters: - - name: projectId - in: path - description: Project's ID. - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - properties: - topology: - $ref: "#/components/schemas/Topology" - description: The new Topology. - required: true - responses: - "200": - description: Successfully added Topology. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Topology" - "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" - "404": - description: Project not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - "/projects/{projectId}/portfolios": - get: - tags: - - projects - description: Get Project Portfolios. - parameters: - - name: projectId - in: path - description: Project's ID. - required: true - schema: - type: string - responses: - "200": - description: Successfully retrieved Project Portfolios. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - type: array - items: - $ref: "#/components/schemas/Portfolio" - "401": - description: Unauthorized. - content: - "application/json": - schema: - $ref: "#/components/schemas/Unauthorized" - "403": - description: Forbidden from retrieving Project. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Project not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - post: - tags: - - projects - description: Add a Portfolio. - parameters: - - name: projectId - in: path - description: Project's ID. - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - properties: - topology: - $ref: "#/components/schemas/Portfolio" - description: The new Portfolio. - required: true - responses: - "200": - description: Successfully added Portfolio. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Portfolio" - "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" - "404": - description: Project not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - "/topologies/{topologyId}": - get: - tags: - - topologies - description: Get this Topology. - parameters: - - name: topologyId - in: path - description: Topology's ID. - required: true - schema: - type: string - responses: - "200": - description: Successfully retrieved Topology. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Topology" - "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 Topology. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Topology not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - put: - tags: - - topologies - description: Update this Topology's name. - parameters: - - name: topologyId - in: path - description: Topology's ID. - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - properties: - topology: - $ref: "#/components/schemas/Topology" - description: Topology's new properties. - required: true - responses: - "200": - description: Successfully updated Topology. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Topology" - "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 Project. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Project not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - delete: - tags: - - topologies - description: Delete this Topology. - parameters: - - name: topologyId - in: path - description: Topology's ID. - required: true - schema: - type: string - responses: - "200": - description: Successfully deleted Topology. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Topology" - "401": - description: Unauthorized. - content: - "application/json": - schema: - $ref: "#/components/schemas/Unauthorized" - "403": - description: Forbidden from deleting Topology. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Topology not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - "/portfolios/{portfolioId}": - get: - tags: - - portfolios - description: Get this Portfolio. - parameters: - - name: portfolioId - in: path - description: Portfolio's ID. - required: true - schema: - type: string - responses: - "200": - description: Successfully retrieved Portfolio. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Portfolio" - "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 Portfolio. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Portfolio not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - put: - tags: - - portfolios - description: Update this Portfolio. - parameters: - - name: portfolioId - in: path - description: Portfolio's ID. - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/Portfolio" - description: Portfolio's new properties. - required: true - responses: - "200": - description: Successfully updated Portfolio. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Portfolio" - "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 Portfolio. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Portfolio not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - delete: - tags: - - portfolios - description: Delete this Portfolio. - parameters: - - name: portfolioId - in: path - description: Portfolio's ID. - required: true - schema: - type: string - responses: - "200": - description: Successfully deleted Portfolio. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Portfolio" - "401": - description: Unauthorized. - content: - "application/json": - schema: - $ref: "#/components/schemas/Unauthorized" - "403": - description: Forbidden from retrieving Portfolio. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Portfolio not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - "/portfolios/{portfolioId}/scenarios": - get: - tags: - - portfolios - description: Get Portfolio Scenarios. - parameters: - - name: portfolioId - in: path - description: Portfolio's ID. - required: true - schema: - type: string - responses: - "200": - description: Successfully retrieved Portfolio Scenarios. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - type: array - items: - $ref: "#/components/schemas/Scenario" - "401": - description: Unauthorized. - content: - "application/json": - schema: - $ref: "#/components/schemas/Unauthorized" - "403": - description: Forbidden from retrieving Portfolio. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Portfolio not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - post: - tags: - - portfolios - description: Add a Scenario. - parameters: - - name: portfolioId - in: path - description: Portfolio's ID. - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - properties: - topology: - $ref: "#/components/schemas/Scenario" - description: The new Scenario. - required: true - responses: - "200": - description: Successfully added Scenario. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Scenario" - "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" - "404": - description: Portfolio not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - "/scenarios/{scenarioId}": - get: - tags: - - scenarios - description: Get this Scenario. - parameters: - - name: scenarioId - in: path - description: Scenario's ID. - required: true - schema: - type: string - responses: - "200": - description: Successfully retrieved Scenario. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Scenario" - "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 Scenario. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Scenario not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - put: - tags: - - scenarios - description: Update this Scenario's name (other properties are read-only). - parameters: - - name: scenarioId - in: path - description: Scenario's ID. - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/Scenario" - description: Scenario with new name. - required: true - responses: - "200": - description: Successfully updated Scenario. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Scenario" - "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 Scenario. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Scenario not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - delete: - tags: - - scenarios - description: Delete this Scenario. - parameters: - - name: scenarioId - in: path - description: Scenario's ID. - required: true - schema: - type: string - responses: - "200": - description: Successfully deleted Scenario. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Scenario" - "401": - description: Unauthorized. - content: - "application/json": - schema: - $ref: "#/components/schemas/Unauthorized" - "403": - description: Forbidden from retrieving Scenario. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Scenario not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - /schedulers: - get: - tags: - - simulation - description: Get all available Schedulers - responses: - "200": - description: Successfully retrieved Schedulers. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - type: array - items: - $ref: "#/components/schemas/Scheduler" - "401": - description: Unauthorized. - content: - "application/json": - schema: - $ref: "#/components/schemas/Unauthorized" - /traces: - get: - tags: - - simulation - description: Get all available Traces - responses: - "200": - description: Successfully retrieved Traces. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - type: array - items: - type: object - properties: - _id: - type: string - name: - type: string - "401": - description: Unauthorized. - content: - "application/json": - schema: - $ref: "#/components/schemas/Unauthorized" - "/traces/{traceId}": - get: - tags: - - simulation - description: Get this Trace. - parameters: - - name: traceId - in: path - description: Trace's ID. - required: true - schema: - type: string - responses: - "200": - description: Successfully retrieved Trace. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Trace" - "401": - description: Unauthorized. - content: - "application/json": - schema: - $ref: "#/components/schemas/Unauthorized" - "404": - description: Trace not found - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - /prefabs: - get: - tags: - - prefabs - description: Get all Prefabs the user has rights to view. - responses: - "200": - description: Successfully retrieved prefabs the user is authorized on. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - type: array - items: - $ref: "#/components/schemas/Prefab" - "401": - description: Unauthorized. - content: - "application/json": - schema: - $ref: "#/components/schemas/Unauthorized" - post: - tags: - - prefabs - description: Add a Prefab. - requestBody: - content: - application/json: - schema: - properties: - name: - type: string - description: The new Prefab. - required: true - responses: - "200": - description: Successfully added Prefab. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Prefab" - "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" - "/prefabs/{prefabId}": - get: - tags: - - prefabs - description: Get this Prefab. - parameters: - - name: prefabId - in: path - description: Prefab's ID. - required: true - schema: - type: string - responses: - "200": - description: Successfully retrieved Prefab. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Prefab" - "401": - description: Unauthorized. - content: - "application/json": - schema: - $ref: "#/components/schemas/Unauthorized" - "403": - description: Forbidden from retrieving Prefab. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Prefab not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - put: - tags: - - prefabs - description: Update this Prefab. - parameters: - - name: prefabId - in: path - description: Prefab's ID. - required: true - schema: - type: string - requestBody: - content: - application/json: - schema: - properties: - prefab: - $ref: "#/components/schemas/Prefab" - description: Prefab's new properties. - required: true - responses: - "200": - description: Successfully updated Prefab. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Prefab" - "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 Prefab. - content: - "application/json": - schema: - $ref: "#/components/schemas/Forbidden" - "404": - description: Prefab not found. - content: - "application/json": - schema: - $ref: "#/components/schemas/NotFound" - delete: - tags: - - prefabs - description: Delete this prefab. - parameters: - - name: prefabId - in: path - description: Prefab's ID. - required: true - schema: - type: string - responses: - "200": - description: Successfully deleted Prefab. - content: - "application/json": - schema: - type: object - required: - - data - properties: - data: - $ref: "#/components/schemas/Prefab" - "401": - description: Unauthorized. - content: - "application/json": - schema: - $ref: "#/components/schemas/Unauthorized" - "404": - description: Prefab not found. - content: - "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: - type: oauth2 - x-token-validation-url: https://opendc.eu.auth0.com/userinfo - flows: - authorizationCode: - authorizationUrl: https://opendc.eu.auth0.com/authorize - 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 - required: - - message - properties: - message: - type: string - Invalid: - type: object - required: - - message - - errors - properties: - message: - type: string - errors: - type: array - items: - type: string - Forbidden: - type: object - required: - - message - properties: - message: - type: string - NotFound: - type: object - required: - - message - properties: - message: - type: string - Scheduler: - type: object - properties: - name: - type: string - Project: - type: object - properties: - _id: - type: string - name: - type: string - datetimeCreated: - type: string - format: dateTime - datetimeLastEdited: - type: string - format: dateTime - topologyIds: - type: array - items: - type: string - portfolioIds: - type: array - items: - type: string - authorizations: - type: array - items: - type: object - properties: - userId: - type: string - level: - type: string - enum: ['OWN', 'EDIT', 'VIEW'] - Topology: - type: object - properties: - _id: - type: string - projectId: - type: string - name: - type: string - rooms: - type: array - items: - type: object - properties: - _id: - type: string - name: - type: string - tiles: - type: array - items: - type: object - properties: - _id: - type: string - positionX: - type: integer - positionY: - type: integer - object: - type: object - properties: - _id: - type: string - name: - type: string - capacity: - type: integer - powerCapacityW: - type: integer - machines: - type: array - items: - type: object - properties: - _id: - type: string - position: - type: integer - cpus: - type: array - items: - type: object - properties: - _id: - type: string - name: - type: string - clockRateMhz: - type: integer - numberOfCores: - type: integer - energyConsumptionW: - type: integer - gpus: - type: array - items: - type: object - properties: - _id: - type: string - name: - type: string - clockRateMhz: - type: integer - numberOfCores: - type: integer - energyConsumptionW: - type: integer - memories: - type: array - items: - type: object - properties: - _id: - type: string - name: - type: string - speedMbPerS: - type: integer - sizeMb: - type: integer - energyConsumptionW: - type: integer - storages: - type: array - items: - type: object - properties: - _id: - type: string - name: - type: string - speedMbPerS: - type: integer - sizeMb: - type: integer - energyConsumptionW: - type: integer - Portfolio: - type: object - properties: - _id: - type: string - projectId: - type: string - name: - type: string - scenarioIds: - type: array - items: - type: string - targets: - type: object - properties: - enabledMetrics: - type: array - items: - type: string - repeatsPerScenario: - type: integer - Scenario: - type: object - properties: - _id: - type: string - portfolioId: - type: string - name: - type: string - trace: - type: object - properties: - traceId: - type: string - loadSamplingFraction: - type: number - topology: - type: object - properties: - topologyId: - type: string - operational: - type: object - properties: - failuresEnabled: - type: boolean - performanceInterferenceEnabled: - 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: - _id: - type: string - name: - type: string - path: - type: string - type: - type: string - Prefab: - type: object - properties: - _id: - type: string - name: - type: string - datetimeCreated: - type: string - format: dateTime - datetimeLastEdited: - type: string - format: dateTime diff --git a/opendc-web/opendc-web-api/tests/api/test_jobs.py b/opendc-web/opendc-web-api/tests/api/test_jobs.py deleted file mode 100644 index 2efe6933..00000000 --- a/opendc-web/opendc-web-api/tests/api/test_jobs.py +++ /dev/null @@ -1,139 +0,0 @@ -# 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() diff --git a/opendc-web/opendc-web-api/tests/api/test_portfolios.py b/opendc-web/opendc-web-api/tests/api/test_portfolios.py deleted file mode 100644 index 196fcb1c..00000000 --- a/opendc-web/opendc-web-api/tests/api/test_portfolios.py +++ /dev/null @@ -1,340 +0,0 @@ -# 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 opendc.exts import db - -test_id = 24 * '1' -test_id_2 = 24 * '2' - - -def test_get_portfolio_non_existing(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.get(f'/portfolios/{test_id}').status - - -def test_get_portfolio_no_authorizations(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value={'projectId': test_id, 'authorizations': []}) - res = client.get(f'/portfolios/{test_id}') - assert '403' in res.status - - -def test_get_portfolio_not_authorized(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - 'projectId': test_id, - '_id': test_id, - 'authorizations': [] - }) - res = client.get(f'/portfolios/{test_id}') - assert '403' in res.status - - -def test_get_portfolio(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - 'projectId': test_id, - '_id': test_id, - 'authorizations': [{ - 'userId': 'test', - 'level': 'EDIT' - }] - }) - res = client.get(f'/portfolios/{test_id}') - assert '200' in res.status - - -def test_update_portfolio_missing_parameter(client): - assert '400' in client.put(f'/portfolios/{test_id}').status - - -def test_update_portfolio_non_existing(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.put(f'/portfolios/{test_id}', json={ - 'portfolio': { - 'name': 'test', - 'targets': { - 'enabledMetrics': ['test'], - 'repeatsPerScenario': 2 - } - } - }).status - - -def test_update_portfolio_not_authorized(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'authorizations': [{ - 'userId': 'test', - 'level': 'VIEW' - }] - }) - mocker.patch.object(db, 'update', return_value={}) - assert '403' in client.put(f'/portfolios/{test_id}', json={ - 'portfolio': { - 'name': 'test', - 'targets': { - 'enabledMetrics': ['test'], - 'repeatsPerScenario': 2 - } - } - }).status - - -def test_update_portfolio(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'authorizations': [{ - 'userId': 'test', - 'level': 'OWN' - }], - 'targets': { - 'enabledMetrics': [], - 'repeatsPerScenario': 1 - } - }) - mocker.patch.object(db, 'update', return_value={}) - - res = client.put(f'/portfolios/{test_id}', json={'portfolio': { - 'name': 'test', - 'targets': { - 'enabledMetrics': ['test'], - 'repeatsPerScenario': 2 - } - }}) - assert '200' in res.status - - -def test_delete_project_non_existing(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.delete(f'/portfolios/{test_id}').status - - -def test_delete_project_different_user(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'googleId': 'other_test', - 'authorizations': [{ - 'userId': 'test', - 'level': 'VIEW' - }] - }) - mocker.patch.object(db, 'delete_one', return_value=None) - assert '403' in client.delete(f'/portfolios/{test_id}').status - - -def test_delete_project(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'googleId': 'test', - 'portfolioIds': [test_id], - 'authorizations': [{ - 'userId': 'test', - 'level': 'OWN' - }] - }) - mocker.patch.object(db, 'delete_one', return_value={}) - mocker.patch.object(db, 'update', return_value=None) - res = client.delete(f'/portfolios/{test_id}') - assert '200' in res.status - - -def test_add_topology_missing_parameter(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'googleId': 'test', - 'portfolioIds': [test_id], - 'authorizations': [{ - 'userId': 'test', - 'level': 'OWN' - }] - }) - assert '400' in client.post(f'/projects/{test_id}/topologies').status - - -def test_add_topology(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'authorizations': [{ - 'userId': 'test', - 'level': 'OWN' - }], - 'topologyIds': [] - }) - mocker.patch.object(db, - 'insert', - return_value={ - '_id': test_id, - 'datetimeCreated': '000', - 'datetimeLastEdited': '000', - 'topologyIds': [] - }) - mocker.patch.object(db, 'update', return_value={}) - res = client.post(f'/projects/{test_id}/topologies', json={'topology': {'name': 'test project', 'rooms': []}}) - assert 'rooms' in res.json['data'] - assert '200' in res.status - - -def test_add_topology_not_authorized(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'authorizations': [{ - 'userId': 'test', - 'level': 'VIEW' - }] - }) - assert '403' in client.post(f'/projects/{test_id}/topologies', - json={ - 'topology': { - 'name': 'test_topology', - 'rooms': [] - } - }).status - - -def test_add_portfolio_missing_parameter(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'googleId': 'test', - 'portfolioIds': [test_id], - 'authorizations': [{ - 'userId': 'test', - 'level': 'OWN' - }] - }) - assert '400' in client.post(f'/projects/{test_id}/portfolios').status - - -def test_add_portfolio_non_existing_project(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.post(f'/projects/{test_id}/portfolios', - json={ - 'portfolio': { - 'name': 'test', - 'targets': { - 'enabledMetrics': ['test'], - 'repeatsPerScenario': 2 - } - } - }).status - - -def test_add_portfolio_not_authorized(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'authorizations': [{ - 'userId': 'test', - 'level': 'VIEW' - }] - }) - assert '403' in client.post(f'/projects/{test_id}/portfolios', - json={ - 'portfolio': { - 'name': 'test', - 'targets': { - 'enabledMetrics': ['test'], - 'repeatsPerScenario': 2 - } - } - }).status - - -def test_add_portfolio(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'portfolioIds': [test_id], - 'authorizations': [{ - 'userId': 'test', - 'level': 'EDIT' - }] - }) - mocker.patch.object(db, - 'insert', - return_value={ - '_id': test_id, - 'name': 'test', - 'targets': { - 'enabledMetrics': ['test'], - 'repeatsPerScenario': 2 - }, - 'projectId': test_id, - 'scenarioIds': [], - }) - mocker.patch.object(db, 'update', return_value=None) - res = client.post( - f'/projects/{test_id}/portfolios', - json={ - 'portfolio': { - 'name': 'test', - 'targets': { - 'enabledMetrics': ['test'], - 'repeatsPerScenario': 2 - } - } - }) - assert 'projectId' in res.json['data'] - assert 'scenarioIds' in res.json['data'] - assert '200' in res.status - - -def test_get_portfolio_scenarios(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - 'projectId': test_id, - '_id': test_id, - 'authorizations': [{ - 'userId': 'test', - 'level': 'EDIT' - }] - }) - mocker.patch.object(db, 'fetch_all', return_value=[{'_id': test_id}]) - res = client.get(f'/portfolios/{test_id}/scenarios') - assert '200' in res.status diff --git a/opendc-web/opendc-web-api/tests/api/test_prefabs.py b/opendc-web/opendc-web-api/tests/api/test_prefabs.py deleted file mode 100644 index ea3d92d6..00000000 --- a/opendc-web/opendc-web-api/tests/api/test_prefabs.py +++ /dev/null @@ -1,252 +0,0 @@ -# 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 unittest.mock import Mock -from opendc.exts import db - -test_id = 24 * '1' -test_id_2 = 24 * '2' - - -def test_add_prefab_missing_parameter(client): - assert '400' in client.post('/prefabs/').status - - -def test_add_prefab(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id, 'authorizations': []}) - mocker.patch.object(db, - 'insert', - return_value={ - '_id': test_id, - 'datetimeCreated': '000', - 'datetimeLastEdited': '000', - 'authorId': test_id - }) - res = client.post('/prefabs/', json={'prefab': {'name': 'test prefab'}}) - assert 'datetimeCreated' in res.json['data'] - assert 'datetimeLastEdited' in res.json['data'] - assert 'authorId' in res.json['data'] - assert '200' in res.status - - -def test_get_prefabs(client, mocker): - db.fetch_all = Mock() - mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id}) - db.fetch_all.side_effect = [ - [{ - '_id': test_id, - 'datetimeCreated': '000', - 'datetimeLastEdited': '000', - 'authorId': test_id, - 'visibility' : 'private' - }, - { - '_id': '2' * 24, - 'datetimeCreated': '000', - 'datetimeLastEdited': '000', - 'authorId': test_id, - 'visibility' : 'private' - }, - { - '_id': '3' * 24, - 'datetimeCreated': '000', - 'datetimeLastEdited': '000', - 'authorId': test_id, - 'visibility' : 'public' - }, - { - '_id': '4' * 24, - 'datetimeCreated': '000', - 'datetimeLastEdited': '000', - 'authorId': test_id, - 'visibility' : 'public' - }], - [{ - '_id': '5' * 24, - 'datetimeCreated': '000', - 'datetimeLastEdited': '000', - 'authorId': '2' * 24, - 'visibility' : 'public' - }, - { - '_id': '6' * 24, - 'datetimeCreated': '000', - 'datetimeLastEdited': '000', - 'authorId': '2' * 24, - 'visibility' : 'public' - }, - { - '_id': '7' * 24, - 'datetimeCreated': '000', - 'datetimeLastEdited': '000', - 'authorId': '2' * 24, - 'visibility' : 'public' - }, - { - '_id': '8' * 24, - 'datetimeCreated': '000', - 'datetimeLastEdited': '000', - 'authorId': '2' * 24, - 'visibility' : 'public' - }] - ] - mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id}) - res = client.get('/prefabs/') - assert '200' in res.status - - -def test_get_prefab_non_existing(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.get(f'/prefabs/{test_id}').status - - -def test_get_private_prefab_not_authorized(client, mocker): - db.fetch_one = Mock() - db.fetch_one.side_effect = [{ - '_id': test_id, - 'name': 'test prefab', - 'authorId': test_id_2, - 'visibility': 'private', - 'rack': {} - }, - { - '_id': test_id - } - ] - res = client.get(f'/prefabs/{test_id}') - assert '403' in res.status - - -def test_get_private_prefab(client, mocker): - db.fetch_one = Mock() - db.fetch_one.side_effect = [{ - '_id': test_id, - 'name': 'test prefab', - 'authorId': 'test', - 'visibility': 'private', - 'rack': {} - }, - { - '_id': test_id - } - ] - res = client.get(f'/prefabs/{test_id}') - assert '200' in res.status - - -def test_get_public_prefab(client, mocker): - db.fetch_one = Mock() - db.fetch_one.side_effect = [{ - '_id': test_id, - 'name': 'test prefab', - 'authorId': test_id_2, - 'visibility': 'public', - 'rack': {} - }, - { - '_id': test_id - } - ] - res = client.get(f'/prefabs/{test_id}') - assert '200' in res.status - - -def test_update_prefab_missing_parameter(client): - assert '400' in client.put(f'/prefabs/{test_id}').status - - -def test_update_prefab_non_existing(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.put(f'/prefabs/{test_id}', json={'prefab': {'name': 'S'}}).status - - -def test_update_prefab_not_authorized(client, mocker): - db.fetch_one = Mock() - db.fetch_one.side_effect = [{ - '_id': test_id, - 'name': 'test prefab', - 'authorId': test_id_2, - 'visibility': 'private', - 'rack': {} - }, - { - '_id': test_id - } - ] - mocker.patch.object(db, 'update', return_value={}) - assert '403' in client.put(f'/prefabs/{test_id}', json={'prefab': {'name': 'test prefab', 'rack': {}}}).status - - -def test_update_prefab(client, mocker): - db.fetch_one = Mock() - db.fetch_one.side_effect = [{ - '_id': test_id, - 'name': 'test prefab', - 'authorId': 'test', - 'visibility': 'private', - 'rack': {} - }, - { - '_id': test_id - } - ] - mocker.patch.object(db, 'update', return_value={}) - res = client.put(f'/prefabs/{test_id}', json={'prefab': {'name': 'test prefab', 'rack': {}}}) - assert '200' in res.status - - -def test_delete_prefab_non_existing(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.delete(f'/prefabs/{test_id}').status - - -def test_delete_prefab_different_user(client, mocker): - db.fetch_one = Mock() - db.fetch_one.side_effect = [{ - '_id': test_id, - 'name': 'test prefab', - 'authorId': test_id_2, - 'visibility': 'private', - 'rack': {} - }, - { - '_id': test_id - } - ] - mocker.patch.object(db, 'delete_one', return_value=None) - assert '403' in client.delete(f'/prefabs/{test_id}').status - - -def test_delete_prefab(client, mocker): - db.fetch_one = Mock() - db.fetch_one.side_effect = [{ - '_id': test_id, - 'name': 'test prefab', - 'authorId': 'test', - 'visibility': 'private', - 'rack': {} - }, - { - '_id': test_id - } - ] - mocker.patch.object(db, 'delete_one', return_value={'prefab': {'name': 'name'}}) - res = client.delete(f'/prefabs/{test_id}') - assert '200' in res.status diff --git a/opendc-web/opendc-web-api/tests/api/test_projects.py b/opendc-web/opendc-web-api/tests/api/test_projects.py deleted file mode 100644 index 1cfe4c52..00000000 --- a/opendc-web/opendc-web-api/tests/api/test_projects.py +++ /dev/null @@ -1,197 +0,0 @@ -# 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 opendc.exts import db - -test_id = 24 * '1' - - -def test_get_user_projects(client, mocker): - mocker.patch.object(db, 'fetch_all', return_value={'_id': test_id, 'authorizations': [{'userId': 'test', - 'level': 'OWN'}]}) - res = client.get('/projects/') - assert '200' in res.status - - -def test_get_user_topologies(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'authorizations': [{ - 'userId': 'test', - 'level': 'EDIT' - }] - }) - mocker.patch.object(db, 'fetch_all', return_value=[{'_id': test_id}]) - res = client.get(f'/projects/{test_id}/topologies') - assert '200' in res.status - - -def test_get_user_portfolios(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'authorizations': [{ - 'userId': 'test', - 'level': 'EDIT' - }] - }) - mocker.patch.object(db, 'fetch_all', return_value=[{'_id': test_id}]) - res = client.get(f'/projects/{test_id}/portfolios') - assert '200' in res.status - - -def test_add_project_missing_parameter(client): - assert '400' in client.post('/projects/').status - - -def test_add_project(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id, 'authorizations': []}) - mocker.patch.object(db, - 'insert', - return_value={ - '_id': test_id, - 'datetimeCreated': '000', - 'datetimeLastEdited': '000', - 'topologyIds': [] - }) - mocker.patch.object(db, 'update', return_value={}) - res = client.post('/projects/', json={'project': {'name': 'test project'}}) - assert 'datetimeCreated' in res.json['data'] - assert 'datetimeLastEdited' in res.json['data'] - assert 'topologyIds' in res.json['data'] - assert '200' in res.status - - -def test_get_project_non_existing(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.get(f'/projects/{test_id}').status - - -def test_get_project_no_authorizations(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value={'authorizations': []}) - res = client.get(f'/projects/{test_id}') - assert '403' in res.status - - -def test_get_project_not_authorized(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'authorizations': [] - }) - res = client.get(f'/projects/{test_id}') - assert '403' in res.status - - -def test_get_project(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'authorizations': [{ - 'userId': 'test', - 'level': 'EDIT' - }] - }) - res = client.get(f'/projects/{test_id}') - assert '200' in res.status - - -def test_update_project_missing_parameter(client): - assert '400' in client.put(f'/projects/{test_id}').status - - -def test_update_project_non_existing(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.put(f'/projects/{test_id}', json={'project': {'name': 'S'}}).status - - -def test_update_project_not_authorized(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'authorizations': [{ - 'userId': 'test', - 'level': 'VIEW' - }] - }) - mocker.patch.object(db, 'update', return_value={}) - assert '403' in client.put(f'/projects/{test_id}', json={'project': {'name': 'S'}}).status - - -def test_update_project(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'authorizations': [{ - 'userId': 'test', - 'level': 'OWN' - }] - }) - mocker.patch.object(db, 'update', return_value={}) - - res = client.put(f'/projects/{test_id}', json={'project': {'name': 'S'}}) - assert '200' in res.status - - -def test_delete_project_non_existing(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.delete(f'/projects/{test_id}').status - - -def test_delete_project_different_user(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'googleId': 'other_test', - 'authorizations': [{ - 'userId': 'test', - 'level': 'VIEW' - }], - 'topologyIds': [] - }) - mocker.patch.object(db, 'delete_one', return_value=None) - assert '403' in client.delete(f'/projects/{test_id}').status - - -def test_delete_project(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'googleId': 'test', - 'authorizations': [{ - 'userId': 'test', - 'level': 'OWN' - }], - 'topologyIds': [], - 'portfolioIds': [], - }) - mocker.patch.object(db, 'update', return_value=None) - mocker.patch.object(db, 'delete_one', return_value={'googleId': 'test'}) - res = client.delete(f'/projects/{test_id}') - assert '200' in res.status diff --git a/opendc-web/opendc-web-api/tests/api/test_scenarios.py b/opendc-web/opendc-web-api/tests/api/test_scenarios.py deleted file mode 100644 index bdd5c4a3..00000000 --- a/opendc-web/opendc-web-api/tests/api/test_scenarios.py +++ /dev/null @@ -1,135 +0,0 @@ -# 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 opendc.exts import db - -test_id = 24 * '1' -test_id_2 = 24 * '2' - - -def test_get_scenario_non_existing(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.get(f'/scenarios/{test_id}').status - - -def test_get_scenario_no_authorizations(client, mocker): - m = mocker.MagicMock() - m.side_effect = ({'portfolioId': test_id}, {'projectId': test_id}, {'authorizations': []}) - mocker.patch.object(db, 'fetch_one', m) - res = client.get(f'/scenarios/{test_id}') - assert '403' in res.status - - -def test_get_scenario(client, mocker): - mocker.patch.object(db, - 'fetch_one', - side_effect=[ - {'portfolioId': test_id}, - {'projectId': test_id}, - {'authorizations': - [{'userId': 'test', 'level': 'OWN'}] - }]) - res = client.get(f'/scenarios/{test_id}') - assert '200' in res.status - - -def test_update_scenario_missing_parameter(client): - assert '400' in client.put(f'/scenarios/{test_id}').status - - -def test_update_scenario_non_existing(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.put(f'/scenarios/{test_id}', json={ - 'scenario': { - 'name': 'test', - } - }).status - - -def test_update_scenario_not_authorized(client, mocker): - mocker.patch.object(db, - 'fetch_one', - side_effect=[ - {'portfolioId': test_id}, - {'projectId': test_id}, - {'authorizations': - [{'userId': 'test', 'level': 'VIEW'}] - }]) - mocker.patch.object(db, 'update', return_value={}) - assert '403' in client.put(f'/scenarios/{test_id}', json={ - 'scenario': { - 'name': 'test', - } - }).status - - -def test_update_scenario(client, mocker): - mocker.patch.object(db, - 'fetch_one', - side_effect=[ - {'_id': test_id, 'portfolioId': test_id}, - {'projectId': test_id}, - {'authorizations': - [{'userId': 'test', 'level': 'OWN'}] - }]) - mocker.patch.object(db, 'update', return_value={}) - - res = client.put(f'/scenarios/{test_id}', json={'scenario': { - 'name': 'test', - }}) - assert '200' in res.status - - -def test_delete_project_non_existing(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.delete(f'/scenarios/{test_id}').status - - -def test_delete_project_different_user(client, mocker): - mocker.patch.object(db, - 'fetch_one', - side_effect=[ - {'_id': test_id, 'portfolioId': test_id}, - {'projectId': test_id}, - {'authorizations': - [{'userId': 'test', 'level': 'VIEW'}] - }]) - mocker.patch.object(db, 'delete_one', return_value=None) - assert '403' in client.delete(f'/scenarios/{test_id}').status - - -def test_delete_project(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'portfolioId': test_id, - 'googleId': 'test', - 'scenarioIds': [test_id], - 'authorizations': [{ - 'userId': 'test', - 'level': 'OWN' - }] - }) - mocker.patch.object(db, 'delete_one', return_value={}) - mocker.patch.object(db, 'update', return_value=None) - res = client.delete(f'/scenarios/{test_id}') - assert '200' in res.status diff --git a/opendc-web/opendc-web-api/tests/api/test_schedulers.py b/opendc-web/opendc-web-api/tests/api/test_schedulers.py deleted file mode 100644 index 5d9e6995..00000000 --- a/opendc-web/opendc-web-api/tests/api/test_schedulers.py +++ /dev/null @@ -1,22 +0,0 @@ -# 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. - -def test_get_schedulers(client): - assert '200' in client.get('/schedulers/').status diff --git a/opendc-web/opendc-web-api/tests/api/test_topologies.py b/opendc-web/opendc-web-api/tests/api/test_topologies.py deleted file mode 100644 index 6e7c54ef..00000000 --- a/opendc-web/opendc-web-api/tests/api/test_topologies.py +++ /dev/null @@ -1,140 +0,0 @@ -# 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 opendc.exts import db - -test_id = 24 * '1' -test_id_2 = 24 * '2' - - -def test_get_topology(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'authorizations': [{ - 'userId': 'test', - 'level': 'EDIT' - }] - }) - res = client.get(f'/topologies/{test_id}') - assert '200' in res.status - - -def test_get_topology_non_existing(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.get('/topologies/1').status - - -def test_get_topology_not_authorized(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'authorizations': [] - }) - res = client.get(f'/topologies/{test_id}') - assert '403' in res.status - - -def test_get_topology_no_authorizations(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value={'projectId': test_id, 'authorizations': []}) - res = client.get(f'/topologies/{test_id}') - assert '403' in res.status - - -def test_update_topology_missing_parameter(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'authorizations': [] - }) - assert '400' in client.put(f'/topologies/{test_id}').status - - -def test_update_topology_non_existent(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.put(f'/topologies/{test_id}', json={'topology': {'name': 'test_topology', 'rooms': []}}).status - - -def test_update_topology_not_authorized(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'authorizations': [] - }) - mocker.patch.object(db, 'update', return_value={}) - assert '403' in client.put(f'/topologies/{test_id}', json={ - 'topology': { - 'name': 'updated_topology', - 'rooms': [] - } - }).status - - -def test_update_topology(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'authorizations': [{ - 'userId': 'test', - 'level': 'OWN' - }] - }) - mocker.patch.object(db, 'update', return_value={}) - - assert '200' in client.put(f'/topologies/{test_id}', json={ - 'topology': { - 'name': 'updated_topology', - 'rooms': [] - } - }).status - - -def test_delete_topology(client, mocker): - mocker.patch.object(db, - 'fetch_one', - return_value={ - '_id': test_id, - 'projectId': test_id, - 'googleId': 'test', - 'topologyIds': [test_id], - 'authorizations': [{ - 'userId': 'test', - 'level': 'OWN' - }] - }) - mocker.patch.object(db, 'delete_one', return_value={}) - mocker.patch.object(db, 'update', return_value=None) - res = client.delete(f'/topologies/{test_id}') - assert '200' in res.status - - -def test_delete_nonexistent_topology(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.delete(f'/topologies/{test_id}').status diff --git a/opendc-web/opendc-web-api/tests/api/test_traces.py b/opendc-web/opendc-web-api/tests/api/test_traces.py deleted file mode 100644 index 0b252c2f..00000000 --- a/opendc-web/opendc-web-api/tests/api/test_traces.py +++ /dev/null @@ -1,40 +0,0 @@ -# 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 opendc.exts import db - -test_id = 24 * '1' - - -def test_get_traces(client, mocker): - mocker.patch.object(db, 'fetch_all', return_value=[]) - assert '200' in client.get('/traces/').status - - -def test_get_trace_non_existing(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value=None) - assert '404' in client.get(f'/traces/{test_id}').status - - -def test_get_trace(client, mocker): - mocker.patch.object(db, 'fetch_one', return_value={'name': 'test trace'}) - res = client.get(f'/traces/{test_id}') - assert 'name' in res.json['data'] - assert '200' in res.status diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Job.kt b/opendc-web/opendc-web-client/build.gradle.kts index eeb65e49..f53b29d8 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Job.kt +++ b/opendc-web/opendc-web-client/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 AtLarge Research + * Copyright (c) 2022 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 @@ -20,19 +20,18 @@ * SOFTWARE. */ -package org.opendc.web.client.model +description = "Client for the OpenDC web API" -import com.fasterxml.jackson.annotation.JsonProperty -import java.time.LocalDateTime +/* Build configuration */ +plugins { + `kotlin-library-conventions` + `testing-conventions` + `jacoco-conventions` +} -/** - * A description of a simulation job. - */ -public data class Job( - @JsonProperty("_id") - val id: String, - val scenarioId: String, - val state: SimulationState, - val heartbeat: LocalDateTime, - val results: Map<String, Any> -) +dependencies { + api(projects.opendcWeb.opendcWebProto) + implementation(libs.jackson.module.kotlin) + implementation(libs.jackson.datatype.jsr310) + implementation(libs.jakarta.validation) +} diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/OpenDCClient.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/OpenDCClient.kt new file mode 100644 index 00000000..33f2b41e --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/OpenDCClient.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client + +import org.opendc.web.client.auth.AuthController +import org.opendc.web.client.transport.HttpTransportClient +import org.opendc.web.client.transport.TransportClient +import java.net.URI + +/** + * Client implementation for the user-facing OpenDC REST API (version 2). + * + * @param client Low-level client for managing the underlying transport. + */ +public class OpenDCClient(client: TransportClient) { + /** + * Construct a new [OpenDCClient]. + * + * @param baseUrl The base url of the API. + * @param auth Helper class for managing authentication. + */ + public constructor(baseUrl: URI, auth: AuthController) : this(HttpTransportClient(baseUrl, auth)) + + /** + * A resource for the available projects. + */ + public val projects: ProjectResource = ProjectResource(client) + + /** + * A resource for the topologies available to the user. + */ + public val topologies: TopologyResource = TopologyResource(client) + + /** + * A resource for the portfolios available to the user. + */ + public val portfolios: PortfolioResource = PortfolioResource(client) + + /** + * A resource for the scenarios available to the user. + */ + public val scenarios: ScenarioResource = ScenarioResource(client) + + /** + * A resource for the available schedulers. + */ + public val schedulers: SchedulerResource = SchedulerResource(client) + + /** + * A resource for the available workload traces. + */ + public val traces: TraceResource = TraceResource(client) +} diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/PortfolioResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/PortfolioResource.kt new file mode 100644 index 00000000..399804e8 --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/PortfolioResource.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client + +import org.opendc.web.client.internal.delete +import org.opendc.web.client.internal.get +import org.opendc.web.client.internal.post +import org.opendc.web.client.transport.TransportClient +import org.opendc.web.proto.user.Portfolio + +/** + * A resource representing the portfolios available to the user. + */ +public class PortfolioResource internal constructor(private val client: TransportClient) { + /** + * List all portfolios that belong to the specified [project]. + */ + public fun getAll(project: Long): List<Portfolio> = client.get("projects/$project/portfolios") ?: emptyList() + + /** + * Obtain the portfolio for [project] with [number]. + */ + public fun get(project: Long, number: Int): Portfolio? = client.get("projects/$project/portfolios/$number") + + /** + * Create a new portfolio for [project] with the specified [request]. + */ + public fun create(project: Long, request: Portfolio.Create): Portfolio { + return checkNotNull(client.post("projects/$project/portfolios", request)) + } + + /** + * Delete the portfolio for [project] with [index]. + */ + public fun delete(project: Long, index: Int): Portfolio { + return requireNotNull(client.delete("projects/$project/portfolios/$index")) { "Unknown portfolio $index" } + } +} diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ProjectResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ProjectResource.kt new file mode 100644 index 00000000..12635b89 --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ProjectResource.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client + +import org.opendc.web.client.internal.* +import org.opendc.web.client.transport.TransportClient +import org.opendc.web.proto.user.Project + +/** + * A resource representing the projects available to the user. + */ +public class ProjectResource internal constructor(private val client: TransportClient) { + /** + * List all projects available to the user. + */ + public fun getAll(): List<Project> = client.get("projects") ?: emptyList() + + /** + * Obtain the project with [id]. + */ + public fun get(id: Long): Project? = client.get("projects/$id") + + /** + * Create a new project. + */ + public fun create(name: String): Project = checkNotNull(client.post("projects", Project.Create(name))) + + /** + * Delete the project with the specified [id]. + */ + public fun delete(id: Long): Project = requireNotNull(client.delete("projects/$id")) { "Unknown project $id" } +} diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ScenarioResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ScenarioResource.kt new file mode 100644 index 00000000..7055e752 --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ScenarioResource.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client + +import org.opendc.web.client.internal.delete +import org.opendc.web.client.internal.get +import org.opendc.web.client.internal.post +import org.opendc.web.client.transport.TransportClient +import org.opendc.web.proto.user.Scenario + +/** + * A resource representing the scenarios available to the user. + */ +public class ScenarioResource internal constructor(private val client: TransportClient) { + /** + * List all scenarios that belong to the specified [project]. + */ + public fun getAll(project: Long): List<Scenario> = client.get("projects/$project/scenarios") ?: emptyList() + + /** + * List all scenarios that belong to the specified [portfolioNumber]. + */ + public fun getAll(project: Long, portfolioNumber: Int): List<Scenario> = client.get("projects/$project/portfolios/$portfolioNumber/scenarios") ?: emptyList() + + /** + * Obtain the scenario for [project] with [index]. + */ + public fun get(project: Long, index: Int): Scenario? = client.get("projects/$project/scenarios/$index") + + /** + * Create a new scenario for [portfolio][portfolioNumber] with the specified [request]. + */ + public fun create(project: Long, portfolioNumber: Int, request: Scenario.Create): Scenario { + return checkNotNull(client.post("projects/$project/portfolios/$portfolioNumber", request)) + } + + /** + * Delete the scenario for [project] with [index]. + */ + public fun delete(project: Long, index: Int): Scenario { + return requireNotNull(client.delete("projects/$project/scenarios/$index")) { "Unknown scenario $index" } + } +} diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/AuthConfiguration.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/SchedulerResource.kt index 5dbf2f59..43b72d88 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/AuthConfiguration.kt +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/SchedulerResource.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 AtLarge Research + * Copyright (c) 2022 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 @@ -22,11 +22,15 @@ package org.opendc.web.client +import org.opendc.web.client.internal.get +import org.opendc.web.client.transport.TransportClient + /** - * The authentication configuration for the API client. + * A resource representing the schedulers available in the OpenDC instance. */ -public data class AuthConfiguration( - val domain: String, - val clientId: String, - val clientSecret: String -) +public class SchedulerResource internal constructor(private val client: TransportClient) { + /** + * List all schedulers available. + */ + public fun getAll(): List<String> = client.get("schedulers") ?: emptyList() +} diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TopologyResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TopologyResource.kt new file mode 100644 index 00000000..c37ae8da --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TopologyResource.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client + +import org.opendc.web.client.internal.delete +import org.opendc.web.client.internal.get +import org.opendc.web.client.internal.post +import org.opendc.web.client.internal.put +import org.opendc.web.client.transport.TransportClient +import org.opendc.web.proto.user.Topology + +/** + * A resource representing the topologies available to the user. + */ +public class TopologyResource internal constructor(private val client: TransportClient) { + /** + * List all topologies that belong to the specified [project]. + */ + public fun getAll(project: Long): List<Topology> = client.get("projects/$project/topologies") ?: emptyList() + + /** + * Obtain the topology for [project] with [index]. + */ + public fun get(project: Long, index: Int): Topology? = client.get("projects/$project/topologies/$index") + + /** + * Create a new topology for [project] with [request]. + */ + public fun create(project: Long, request: Topology.Create): Topology { + return checkNotNull(client.post("projects/$project/topologies", request)) + } + + /** + * Update the topology with [index] for [project] using the specified [request]. + */ + public fun update(project: Long, index: Int, request: Topology.Update): Topology? { + return client.put("projects/$project/topologies/$index", request) + } + + /** + * Delete the topology for [project] with [index]. + */ + public fun delete(project: Long, index: Long): Topology { + return requireNotNull(client.delete("projects/$project/topologies/$index")) { "Unknown topology $index" } + } +} diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TraceResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TraceResource.kt new file mode 100644 index 00000000..8201c432 --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TraceResource.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client + +import org.opendc.web.client.internal.* +import org.opendc.web.client.transport.TransportClient +import org.opendc.web.proto.Trace + +/** + * A resource representing the workload traces available in the OpenDC instance. + */ +public class TraceResource internal constructor(private val client: TransportClient) { + /** + * List all workload traces available. + */ + public fun getAll(): List<Trace> = client.get("traces") ?: emptyList() + + /** + * Obtain the workload trace with the specified [id]. + */ + public fun get(id: Long): Trace? = client.get("traces/$id") +} diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/AuthController.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/AuthController.kt new file mode 100644 index 00000000..a4c66f55 --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/AuthController.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client.auth + +import java.net.http.HttpRequest + +/** + * Helper interface for managing API authentication. + */ +public interface AuthController { + /** + * Inject the authorization token into the specified [request]. + */ + public fun injectToken(request: HttpRequest.Builder) + + /** + * Refresh the current auth token. + */ + public fun refreshToken() +} diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/OpenIdAuthController.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/OpenIdAuthController.kt new file mode 100644 index 00000000..7f9cbacd --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/OpenIdAuthController.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client.auth + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.opendc.web.client.internal.OAuthTokenRequest +import org.opendc.web.client.internal.OAuthTokenResponse +import org.opendc.web.client.internal.OpenIdConfiguration +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +/** + * An [AuthController] for OpenID Connect protected APIs. + */ +public class OpenIdAuthController( + private val domain: String, + private val clientId: String, + private val clientSecret: String, + private val audience: String = "https://api.opendc.org/v2/", + private val client: HttpClient = HttpClient.newHttpClient() +) : AuthController { + /** + * The Jackson object mapper to convert messages from/to JSON. + */ + private val mapper = jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + /** + * The cached [OpenIdConfiguration]. + */ + private val openidConfig: OpenIdConfiguration + get() { + var openidConfig = _openidConfig + if (openidConfig == null) { + openidConfig = requestConfig() + _openidConfig = openidConfig + } + + return openidConfig + } + private var _openidConfig: OpenIdConfiguration? = null + + /** + * The cached OAuth token. + */ + private var _token: OAuthTokenResponse? = null + + override fun injectToken(request: HttpRequest.Builder) { + var token = _token + if (token == null) { + token = requestToken() + _token = token + } + + request.header("Authorization", "Bearer ${token.accessToken}") + } + + /** + * Refresh the current access token. + */ + override fun refreshToken() { + val refreshToken = _token?.refreshToken + if (refreshToken == null) { + requestToken() + return + } + + _token = refreshToken(openidConfig, refreshToken) + } + + /** + * Request the OpenID configuration from the chosen auth domain + */ + private fun requestConfig(): OpenIdConfiguration { + val request = HttpRequest.newBuilder(URI("https://$domain/.well-known/openid-configuration")) + .GET() + .build() + val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()) + return mapper.readValue(response.body()) + } + + /** + * Request the auth token from the server. + */ + private fun requestToken(openidConfig: OpenIdConfiguration): OAuthTokenResponse { + val body = OAuthTokenRequest.ClientCredentials(audience, clientId, clientSecret) + val request = HttpRequest.newBuilder(openidConfig.tokenEndpoint) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(body))) + .build() + val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()) + return mapper.readValue(response.body()) + } + + /** + * Helper method to refresh the auth token. + */ + private fun refreshToken(openidConfig: OpenIdConfiguration, refreshToken: String): OAuthTokenResponse { + val body = OAuthTokenRequest.RefreshToken(refreshToken, clientId, clientSecret) + val request = HttpRequest.newBuilder(openidConfig.tokenEndpoint) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(body))) + .build() + val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()) + return mapper.readValue(response.body()) + } + + /** + * Fetch a new access token. + */ + private fun requestToken(): OAuthTokenResponse { + val token = requestToken(openidConfig) + _token = token + return token + } +} diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/ClientUtils.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/ClientUtils.kt new file mode 100644 index 00000000..29cf09dc --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/ClientUtils.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client.internal + +import com.fasterxml.jackson.core.type.TypeReference +import org.opendc.web.client.transport.TransportClient + +/** + * Perform a GET request for resource at [path] and convert to type [T]. + */ +internal inline fun <reified T> TransportClient.get(path: String): T? { + return get(path, object : TypeReference<T>() {}) +} + +/** + * Perform a POST request for resource at [path] and convert to type [T]. + */ +internal inline fun <B, reified T> TransportClient.post(path: String, body: B): T? { + return post(path, body, object : TypeReference<T>() {}) +} + +/** + * Perform a PUT request for resource at [path] and convert to type [T]. + */ +internal inline fun <B, reified T> TransportClient.put(path: String, body: B): T? { + return put(path, body, object : TypeReference<T>() {}) +} + +/** + * Perform a DELETE request for resource at [path] and convert to type [T]. + */ +internal inline fun <reified T> TransportClient.delete(path: String): T? { + return delete(path, object : TypeReference<T>() {}) +} diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenRequest.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenRequest.kt new file mode 100644 index 00000000..25341995 --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenRequest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client.internal + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo + +/** + * Token request sent to the OAuth server. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "grant_type") +@JsonSubTypes( + value = [ + JsonSubTypes.Type(value = OAuthTokenRequest.ClientCredentials::class, name = "client_credentials"), + JsonSubTypes.Type(value = OAuthTokenRequest.RefreshToken::class, name = "refresh_token") + ] +) +internal sealed class OAuthTokenRequest { + /** + * Client credentials grant for OAuth2 + */ + data class ClientCredentials( + val audience: String, + @JsonProperty("client_id") + val clientId: String, + @JsonProperty("client_secret") + val clientSecret: String + ) : OAuthTokenRequest() + + /** + * Refresh token grant for OAuth2. + */ + data class RefreshToken( + @JsonProperty("refresh_token") + val refreshToken: String, + @JsonProperty("client_id") + val clientId: String, + @JsonProperty("client_secret") + val clientSecret: String + ) : OAuthTokenRequest() +} diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenResponse.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenResponse.kt new file mode 100644 index 00000000..cd5ccab0 --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenResponse.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client.internal + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Token response from the OAuth server. + */ +internal data class OAuthTokenResponse( + @JsonProperty("access_token") + val accessToken: String, + @JsonProperty("refresh_token") + val refreshToken: String? = null, + @JsonProperty("token_type") + val tokenType: String, + val scope: String = "", + @JsonProperty("expires_in") + val expiresIn: Long +) diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OpenIdConfiguration.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OpenIdConfiguration.kt new file mode 100644 index 00000000..23fbf368 --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OpenIdConfiguration.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client.internal + +import com.fasterxml.jackson.annotation.JsonProperty +import java.net.URI + +/** + * OpenID configuration exposed by the auth server. + */ +internal data class OpenIdConfiguration( + val issuer: String, + @JsonProperty("authorization_endpoint") + val authorizationEndpoint: URI, + @JsonProperty("token_endpoint") + val tokenEndpoint: URI, + @JsonProperty("userinfo_endpoint") + val userInfoEndpoint: URI, + @JsonProperty("jwks_uri") + val jwksUri: URI, + @JsonProperty("scopes_supported") + val scopesSupported: Set<String> +) diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/JobResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/JobResource.kt new file mode 100644 index 00000000..372a92d7 --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/JobResource.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client.runner + +import org.opendc.web.client.internal.* +import org.opendc.web.client.transport.TransportClient +import org.opendc.web.proto.runner.Job + +/** + * A resource representing the available simulation jobs for the runner. + */ +public class JobResource internal constructor(private val client: TransportClient) { + /** + * Query the pending jobs. + */ + public fun queryPending(): List<Job> = client.get("jobs") ?: emptyList() + + /** + * Obtain the job with [id]. + */ + public fun get(id: Long): Job? = client.get("jobs/$id") + + /** + * Update the job with [id]. + */ + public fun update(id: Long, update: Job.Update): Job? = client.post("jobs/$id", update) +} diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/OpenDCRunnerClient.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/OpenDCRunnerClient.kt new file mode 100644 index 00000000..a3cff6c3 --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/OpenDCRunnerClient.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client.runner + +import org.opendc.web.client.* +import org.opendc.web.client.auth.AuthController +import org.opendc.web.client.transport.HttpTransportClient +import org.opendc.web.client.transport.TransportClient +import java.net.URI + +/** + * Client implementation for the runner-facing OpenDC REST API (version 2). + * + * @param client Low-level client for managing the underlying transport. + */ +public class OpenDCRunnerClient(client: TransportClient) { + /** + * Construct a new [OpenDCRunnerClient]. + * + * @param baseUrl The base url of the API. + * @param auth Helper class for managing authentication. + */ + public constructor(baseUrl: URI, auth: AuthController) : this(HttpTransportClient(baseUrl, auth)) + + /** + * A resource for the available simulation jobs. + */ + public val jobs: JobResource = JobResource(client) + + /** + * A resource for the available schedulers. + */ + public val schedulers: SchedulerResource = SchedulerResource(client) + + /** + * A resource for the available workload traces. + */ + public val traces: TraceResource = TraceResource(client) +} diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/HttpTransportClient.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/HttpTransportClient.kt new file mode 100644 index 00000000..03b3945f --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/HttpTransportClient.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client.transport + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.opendc.web.client.auth.AuthController +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.file.Paths + +/** + * A [TransportClient] that accesses the OpenDC API over HTTP. + * + * @param baseUrl The base url of the API. + * @param auth Helper class for managing authentication. + * @param client The HTTP client to use. + */ +public class HttpTransportClient( + private val baseUrl: URI, + private val auth: AuthController, + private val client: HttpClient = HttpClient.newHttpClient() +) : TransportClient { + /** + * The Jackson object mapper to convert messages from/to JSON. + */ + private val mapper = jacksonObjectMapper() + .registerModule(JavaTimeModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + /** + * Obtain a resource at [path] of [targetType]. + */ + override fun <T> get(path: String, targetType: TypeReference<T>): T? { + val request = HttpRequest.newBuilder(buildUri(path)) + .GET() + .also { auth.injectToken(it) } + .build() + val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()) + + return when (val code = response.statusCode()) { + in 200..299 -> mapper.readValue(response.body(), targetType) + 401 -> { + auth.refreshToken() + get(path, targetType) + } + 404 -> null + else -> throw IllegalStateException("Invalid response $code") + } + } + + /** + * Update a resource at [path] of [targetType]. + */ + override fun <B, T> post(path: String, body: B, targetType: TypeReference<T>): T? { + val request = HttpRequest.newBuilder(buildUri(path)) + .POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(body))) + .header("Content-Type", "application/json") + .also { auth.injectToken(it) } + .build() + val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()) + + return when (val code = response.statusCode()) { + in 200..299 -> mapper.readValue(response.body(), targetType) + 401 -> { + auth.refreshToken() + post(path, body, targetType) + } + 404 -> null + else -> throw IllegalStateException("Invalid response $code") + } + } + + /** + * Replace a resource at [path] of [targetType]. + */ + override fun <B, T> put(path: String, body: B, targetType: TypeReference<T>): T? { + val request = HttpRequest.newBuilder(buildUri(path)) + .PUT(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(body))) + .header("Content-Type", "application/json") + .also { auth.injectToken(it) } + .build() + val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()) + + return when (val code = response.statusCode()) { + in 200..299 -> mapper.readValue(response.body(), targetType) + 401 -> { + auth.refreshToken() + put(path, body, targetType) + } + 404 -> null + else -> throw IllegalStateException("Invalid response $code") + } + } + + /** + * Delete a resource at [path] of [targetType]. + */ + override fun <T> delete(path: String, targetType: TypeReference<T>): T? { + val request = HttpRequest.newBuilder(buildUri(path)) + .DELETE() + .also { auth.injectToken(it) } + .build() + val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream()) + + return when (val code = response.statusCode()) { + in 200..299 -> mapper.readValue(response.body(), targetType) + 401 -> { + auth.refreshToken() + delete(path, targetType) + } + 404 -> null + else -> throw IllegalStateException("Invalid response $code") + } + } + + /** + * Build the absolute [URI] to which the request should be sent. + */ + private fun buildUri(path: String): URI = baseUrl.resolve(Paths.get(baseUrl.path, path).toString()) +} diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/TransportClient.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/TransportClient.kt new file mode 100644 index 00000000..af727ca7 --- /dev/null +++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/TransportClient.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.client.transport + +import com.fasterxml.jackson.core.type.TypeReference + +/** + * Low-level interface for dealing with the transport layer of the API. + */ +public interface TransportClient { + /** + * Obtain a resource at [path] of [targetType]. + */ + public fun <T> get(path: String, targetType: TypeReference<T>): T? + + /** + * Update a resource at [path] of [targetType]. + */ + public fun <B, T> post(path: String, body: B, targetType: TypeReference<T>): T? + + /** + * Replace a resource at [path] of [targetType]. + */ + public fun <B, T> put(path: String, body: B, targetType: TypeReference<T>): T? + + /** + * Delete a resource at [path] of [targetType]. + */ + public fun <T> delete(path: String, targetType: TypeReference<T>): T? +} diff --git a/opendc-web/opendc-web-ui/src/api/prefabs.js b/opendc-web/opendc-web-proto/build.gradle.kts index eb9aa23c..4a566346 100644 --- a/opendc-web/opendc-web-ui/src/api/prefabs.js +++ b/opendc-web/opendc-web-proto/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 AtLarge Research + * Copyright (c) 2020 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 @@ -20,20 +20,20 @@ * SOFTWARE. */ -import { request } from './index' +description = "Web communication protocol for OpenDC" -export function getPrefab(auth, prefabId) { - return request(auth, `prefabs/${prefabId}`) +/* Build configuration */ +plugins { + `kotlin-library-conventions` + id("org.kordamp.gradle.jandex") // Necessary for Quarkus to process annotations } -export function addPrefab(auth, prefab) { - return request(auth, 'prefabs/', 'POST', { prefab }) +dependencies { + implementation(libs.jackson.annotations) + implementation(libs.jakarta.validation) + implementation(libs.microprofile.openapi.api) } -export function updatePrefab(auth, prefab) { - return request(auth, `prefabs/${prefab._id}`, 'PUT', { prefab }) -} - -export function deletePrefab(auth, prefabId) { - return request(auth, `prefabs/${prefabId}`, 'DELETE') +tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> { + kotlinOptions.javaParameters = true } diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/JobState.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/JobState.kt new file mode 100644 index 00000000..38b8ca42 --- /dev/null +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/JobState.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.proto + +/** + * State of a scenario for the simulator runner. + */ +public enum class JobState { + /** + * The job is pending to be claimed by a runner. + */ + PENDING, + + /** + * The job is claimed by a runner. + */ + CLAIMED, + + /** + * The job is currently running. + */ + RUNNING, + + /** + * The job has finished. + */ + FINISHED, + + /** + * The job has failed. + */ + FAILED; +} diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Machine.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Machine.kt index 86d2d46f..f5c50cc3 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Machine.kt +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Machine.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 AtLarge Research + * Copyright (c) 2022 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 @@ -20,17 +20,14 @@ * SOFTWARE. */ -package org.opendc.web.client.model +package org.opendc.web.proto -import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonProperty /** * A machine in a rack. */ -@JsonIgnoreProperties("id_legacy") public data class Machine( - @JsonProperty("_id") val id: String, val position: Int, val cpus: List<ProcessingUnit> = emptyList(), diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/MemoryUnit.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/MemoryUnit.kt index 11e794e8..1fc604fa 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/MemoryUnit.kt +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/MemoryUnit.kt @@ -20,15 +20,12 @@ * SOFTWARE. */ -package org.opendc.web.client.model - -import com.fasterxml.jackson.annotation.JsonProperty +package org.opendc.web.proto /** * A memory unit in a system. */ public data class MemoryUnit( - @JsonProperty("_id") val id: String, val name: String, val speedMbPerS: Double, diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/OperationalPhenomena.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/OperationalPhenomena.kt index ef5b4902..f3164f64 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/OperationalPhenomena.kt +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/OperationalPhenomena.kt @@ -20,13 +20,12 @@ * SOFTWARE. */ -package org.opendc.web.client.model +package org.opendc.web.proto /** * Object describing the enabled operational phenomena for a scenario. */ public data class OperationalPhenomena( - val failuresEnabled: Boolean, - val performanceInterferenceEnabled: Boolean, - val schedulerName: String + val failures: Boolean, + val interference: Boolean ) diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ProcessingUnit.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/ProcessingUnit.kt index 449b5c43..5f79d1bd 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ProcessingUnit.kt +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/ProcessingUnit.kt @@ -20,15 +20,12 @@ * SOFTWARE. */ -package org.opendc.web.client.model - -import com.fasterxml.jackson.annotation.JsonProperty +package org.opendc.web.proto /** * A CPU model. */ public data class ProcessingUnit( - @JsonProperty("_id") val id: String, val name: String, val clockRateMhz: Double, diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ScenarioTopology.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/ProtocolError.kt index 2b90f7ef..e7fe2702 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ScenarioTopology.kt +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/ProtocolError.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 AtLarge Research + * Copyright (c) 2022 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 @@ -20,9 +20,9 @@ * SOFTWARE. */ -package org.opendc.web.client.model +package org.opendc.web.proto /** - * The topology details for a scenario. + * Container for reporting errors. */ -public data class ScenarioTopology(val topologyId: String) +public data class ProtocolError(val code: Int, val message: String) diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Rack.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Rack.kt index a0464388..131aa184 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Rack.kt +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Rack.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 AtLarge Research + * Copyright (c) 2022 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 @@ -20,17 +20,12 @@ * SOFTWARE. */ -package org.opendc.web.client.model - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties -import com.fasterxml.jackson.annotation.JsonProperty +package org.opendc.web.proto /** * A rack in a datacenter. */ -@JsonIgnoreProperties("id_legacy") -public class Rack( - @JsonProperty("_id") +public data class Rack( val id: String, val name: String, val capacity: Int, diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Room.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Room.kt index f1b8f946..5b305168 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Room.kt +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Room.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 AtLarge Research + * Copyright (c) 2022 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 @@ -20,17 +20,12 @@ * SOFTWARE. */ -package org.opendc.web.client.model - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties -import com.fasterxml.jackson.annotation.JsonProperty +package org.opendc.web.proto /** * A room in a datacenter. */ -@JsonIgnoreProperties("id_legacy") public data class Room( - @JsonProperty("_id") val id: String, val name: String, val tiles: Set<RoomTile>, diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/RoomTile.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/RoomTile.kt index 0b956262..666d66ee 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/RoomTile.kt +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/RoomTile.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 AtLarge Research + * Copyright (c) 2022 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 @@ -20,17 +20,12 @@ * SOFTWARE. */ -package org.opendc.web.client.model - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties -import com.fasterxml.jackson.annotation.JsonProperty +package org.opendc.web.proto /** * A room tile. */ -@JsonIgnoreProperties("id_legacy") public data class RoomTile( - @JsonProperty("_id") val id: String, val positionX: Double, val positionY: Double, diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/PortfolioTargets.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Targets.kt index 07c11c19..a0100f72 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/PortfolioTargets.kt +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Targets.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 AtLarge Research + * Copyright (c) 2022 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 @@ -20,9 +20,18 @@ * SOFTWARE. */ -package org.opendc.web.client.model +package org.opendc.web.proto + +import javax.validation.constraints.Min /** * The targets of a portfolio. + * + * @param metrics The selected metrics to track during simulation. + * @param repeats The number of repetitions per scenario. */ -public data class PortfolioTargets(val enabledMetrics: Set<String>, val repeatsPerScenario: Int) +public data class Targets( + val metrics: Set<String>, + @field:Min(1) + val repeats: Int = 1 +) diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Portfolio.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Trace.kt index 6904920b..2952a273 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Portfolio.kt +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Trace.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 AtLarge Research + * Copyright (c) 2022 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 @@ -20,19 +20,17 @@ * SOFTWARE. */ -package org.opendc.web.client.model - -import com.fasterxml.jackson.annotation.JsonProperty +package org.opendc.web.proto /** - * A portfolio in OpenDC. + * A workload trace available for simulation. + * + * @param id The unique identifier of the trace. + * @param name The name of the trace. + * @param type The type of trace. */ -public data class Portfolio( - @JsonProperty("_id") +public data class Trace( val id: String, - val projectId: String, val name: String, - @JsonProperty("scenarioIds") - val scenarios: Set<String>, - val targets: PortfolioTargets + val type: String, ) diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Workload.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Workload.kt new file mode 100644 index 00000000..cc6e0ed8 --- /dev/null +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Workload.kt @@ -0,0 +1,44 @@ +/* + * 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. + */ + +package org.opendc.web.proto + +import javax.validation.constraints.DecimalMax +import javax.validation.constraints.DecimalMin + +/** + * The workload to simulate for a scenario. + */ +public data class Workload(val trace: Trace, val samplingFraction: Double) { + /** + * Specification for a workload. + * + * @param trace The unique identifier of the trace. + * @param samplingFraction The fraction of the workload to sample. + */ + public data class Spec( + val trace: String, + @DecimalMin(value = "0.001", message = "Sampling fraction must be non-zero") + @DecimalMax(value = "1", message = "Sampling fraction cannot exceed one") + val samplingFraction: Double + ) +} diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Job.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Job.kt new file mode 100644 index 00000000..dfaaa09e --- /dev/null +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Job.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.proto.runner + +import org.eclipse.microprofile.openapi.annotations.media.Schema +import org.opendc.web.proto.JobState +import java.time.Instant + +/** + * A simulation job to be simulated by a runner. + */ +@Schema(name = "Runner.Job") +public data class Job( + val id: Long, + val scenario: Scenario, + val state: JobState, + val createdAt: Instant, + val updatedAt: Instant, + val results: Map<String, Any>? = null +) { + /** + * A request to update the state of a job. + */ + @Schema(name = "Runner.Job.Update") + public data class Update(val state: JobState, val results: Map<String, Any>? = null) +} diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Portfolio.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Portfolio.kt new file mode 100644 index 00000000..916d8cf0 --- /dev/null +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Portfolio.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.proto.runner + +import org.eclipse.microprofile.openapi.annotations.media.Schema +import org.opendc.web.proto.Targets +import org.opendc.web.proto.user.Portfolio + +/** + * A [Portfolio] as seen from the runner's perspective. + * + * @param id The unique identifier of the portfolio. + * @param number The number of the portfolio for the project. + * @param name The name of the portfolio. + * @param targets The targets of the portfolio. + */ +@Schema(name = "Runner.Portfolio") +public data class Portfolio( + val id: Long, + val number: Int, + val name: String, + val targets: Targets, +) diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Scenario.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Scenario.kt index 851ff980..c5e609ec 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Scenario.kt +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Scenario.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 AtLarge Research + * Copyright (c) 2022 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 @@ -20,20 +20,22 @@ * SOFTWARE. */ -package org.opendc.web.client.model +package org.opendc.web.proto.runner -import com.fasterxml.jackson.annotation.JsonProperty +import org.eclipse.microprofile.openapi.annotations.media.Schema +import org.opendc.web.proto.* /** - * A simulation scenario. + * A [Scenario] that is exposed to an OpenDC runner. */ +@Schema(name = "Runner.Scenario") public data class Scenario( - @JsonProperty("_id") - val id: String, - val portfolioId: String, + val id: Long, + val number: Int, + val portfolio: Portfolio, val name: String, - val trace: ScenarioTrace, - val topology: ScenarioTopology, - @JsonProperty("operational") - val operationalPhenomena: OperationalPhenomena + val workload: Workload, + val topology: Topology, + val phenomena: OperationalPhenomena, + val schedulerName: String ) diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Topology.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Topology.kt index b59aba42..ea576e71 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Topology.kt +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Topology.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 AtLarge Research + * Copyright (c) 2022 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 @@ -20,19 +20,21 @@ * SOFTWARE. */ -package org.opendc.web.client.model +package org.opendc.web.proto.runner -import com.fasterxml.jackson.annotation.JsonIgnoreProperties -import com.fasterxml.jackson.annotation.JsonProperty +import org.eclipse.microprofile.openapi.annotations.media.Schema +import org.opendc.web.proto.* +import java.time.Instant /** - * Model for an OpenDC topology. + * A [Topology] that is exposed to an OpenDC runner. */ -@JsonIgnoreProperties("id_legacy", "datacenter_id_legacy", "datetimeLastUpdated", "datetimeLastEdited") +@Schema(name = "Runner.Topology") public data class Topology( - @JsonProperty("_id") - val id: String, - val projectId: String, + val id: Long, + val number: Int, val name: String, - val rooms: Set<Room>, + val rooms: List<Room>, + val createdAt: Instant, + val updatedAt: Instant, ) diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Job.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Job.kt new file mode 100644 index 00000000..de5f8de3 --- /dev/null +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Job.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.proto.user + +import org.opendc.web.proto.JobState +import org.opendc.web.proto.runner.Job +import java.time.Instant + +/** + * A simulation job that is associated with a [Scenario]. + * + * This entity is exposed in the runner-facing API via [Job]. + */ +public data class Job( + val id: Long, + val state: JobState, + val createdAt: Instant, + val updatedAt: Instant, + val results: Map<String, Any>? = null +) diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Portfolio.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Portfolio.kt new file mode 100644 index 00000000..6f468e79 --- /dev/null +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Portfolio.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.proto.user + +import org.eclipse.microprofile.openapi.annotations.media.Schema +import org.opendc.web.proto.Targets +import javax.validation.constraints.NotBlank + +/** + * A portfolio is the composition of multiple scenarios. + * + * @param id The unique identifier of the portfolio. + * @param number The number of the portfolio with respect to the project. + * @param project The project to which the portfolio belongs. + * @param name The name of the portfolio. + * @param targets The targets of the portfolio. + * @param scenarios The scenarios in the portfolio. + */ +public data class Portfolio( + val id: Long, + val number: Int, + val project: Project, + val name: String, + val targets: Targets, + val scenarios: List<Scenario.Summary> +) { + /** + * A request to create a new portfolio. + */ + @Schema(name = "Portfolio.Update") + public data class Create( + @field:NotBlank(message = "Name must not be empty") + val name: String, + val targets: Targets + ) + + /** + * A summary view of a [Portfolio] provided for nested relations. + * + * @param id The unique identifier of the portfolio. + * @param number The number of the portfolio for the project. + * @param name The name of the portfolio. + * @param targets The targets of the portfolio. + */ + @Schema(name = "Portfolio.Summary") + public data class Summary( + val id: Long, + val number: Int, + val name: String, + val targets: Targets, + ) +} diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Project.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Project.kt new file mode 100644 index 00000000..3a2807ca --- /dev/null +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Project.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.proto.user + +import org.eclipse.microprofile.openapi.annotations.media.Schema +import java.time.Instant +import javax.validation.constraints.NotBlank + +/** + * A project in OpenDC encapsulates all the datacenter designs and simulation runs for a set of users. + */ +public data class Project( + val id: Long, + val name: String, + val createdAt: Instant, + val updatedAt: Instant, + val role: ProjectRole +) { + /** + * A request to create a new project. + */ + @Schema(name = "Project.Create") + public data class Create(@field:NotBlank(message = "Name must not be empty") val name: String) +} diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/ProjectRole.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/ProjectRole.kt new file mode 100644 index 00000000..0f6de1fc --- /dev/null +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/ProjectRole.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.proto.user + +/** + * The role of a user in a project. + */ +public enum class ProjectRole { + /** + * The user is allowed to view the project. + */ + VIEWER, + + /** + * The user is allowed to edit the project. + */ + EDITOR, + + /** + * The user owns the project (so he can delete it). + */ + OWNER, +} diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Scenario.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Scenario.kt new file mode 100644 index 00000000..552a4912 --- /dev/null +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Scenario.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.proto.user + +import org.eclipse.microprofile.openapi.annotations.media.Schema +import org.opendc.web.proto.OperationalPhenomena +import org.opendc.web.proto.Workload +import javax.validation.constraints.NotBlank + +/** + * A single scenario to be explored by the simulator. + */ +public data class Scenario( + val id: Long, + val number: Int, + val project: Project, + val portfolio: Portfolio.Summary, + val name: String, + val workload: Workload, + val topology: Topology.Summary, + val phenomena: OperationalPhenomena, + val schedulerName: String, + val job: Job +) { + /** + * Create a new scenario. + * + * @param name The name of the scenario. + * @param workload The workload specification to use for the scenario. + * @param topology The number of the topology to use. + * @param phenomena The phenomena to model during simulation. + * @param schedulerName The name of the scheduler. + */ + @Schema(name = "Scenario.Create") + public data class Create( + @field:NotBlank(message = "Name must not be empty") + val name: String, + val workload: Workload.Spec, + val topology: Long, + val phenomena: OperationalPhenomena, + val schedulerName: String, + ) + + /** + * A summary view of a [Scenario] provided for nested relations. + * + * @param id The unique identifier of the scenario. + * @param number The number of the scenario for the project. + * @param name The name of the scenario. + * @param workload The workload to be modeled by the scenario. + * @param phenomena The phenomena simulated for this scenario. + * @param schedulerName The scheduler name to use for the experiment. + * @param job The simulation job associated with the scenario. + */ + @Schema(name = "Scenario.Summary") + public data class Summary( + val id: Long, + val number: Int, + val name: String, + val workload: Workload, + val topology: Topology.Summary, + val phenomena: OperationalPhenomena, + val schedulerName: String, + val job: Job + ) +} diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Topology.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Topology.kt new file mode 100644 index 00000000..a144a2e6 --- /dev/null +++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Topology.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2022 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. + */ + +package org.opendc.web.proto.user + +import org.eclipse.microprofile.openapi.annotations.media.Schema +import org.opendc.web.proto.Room +import java.time.Instant +import javax.validation.constraints.NotBlank + +/** + * Model for an OpenDC topology. + */ +public data class Topology( + val id: Long, + val number: Int, + val project: Project, + val name: String, + val rooms: List<Room>, + val createdAt: Instant, + val updatedAt: Instant, +) { + /** + * Create a new topology for a project. + */ + @Schema(name = "Topology.Create") + public data class Create( + @field:NotBlank(message = "Name must not be empty") + val name: String, + val rooms: List<Room> + ) + + /** + * Update an existing topology. + */ + @Schema(name = "Topology.Update") + public data class Update(val rooms: List<Room>) + + /** + * A summary view of a [Topology] provided for nested relations. + * + * @param id The unique identifier of the topology. + * @param number The number of the topology for the project. + * @param name The name of the topology. + * @param createdAt The instant at which the topology was created. + * @param updatedAt The instant at which the topology was updated. + */ + @Schema(name = "Topology.Summary") + public data class Summary( + val id: Long, + val number: Int, + val name: String, + val createdAt: Instant, + val updatedAt: Instant, + ) +} diff --git a/opendc-web/opendc-web-runner/Dockerfile b/opendc-web/opendc-web-runner/Dockerfile new file mode 100644 index 00000000..771ed2ed --- /dev/null +++ b/opendc-web/opendc-web-runner/Dockerfile @@ -0,0 +1,18 @@ +FROM openjdk:17-slim +MAINTAINER OpenDC Maintainers <opendc@atlarge-research.com> + +# Obtain (cache) Gradle wrapper +COPY gradlew /app/ +COPY gradle /app/gradle +WORKDIR /app +RUN ./gradlew --version + +# Build project +COPY ./ /app/ +RUN ./gradlew --no-daemon :installDist + +FROM openjdk:17-slim +COPY --from=0 /app/build/install /opt/ +COPY --from=0 /app/traces /opt/opendc/traces +WORKDIR /opt/opendc +CMD bin/opendc-web-runner diff --git a/opendc-web/opendc-web-runner/build.gradle.kts b/opendc-web/opendc-web-runner/build.gradle.kts index a73ca6b3..3c80f605 100644 --- a/opendc-web/opendc-web-runner/build.gradle.kts +++ b/opendc-web/opendc-web-runner/build.gradle.kts @@ -40,18 +40,13 @@ dependencies { implementation(projects.opendcTelemetry.opendcTelemetrySdk) implementation(projects.opendcTelemetry.opendcTelemetryCompute) implementation(projects.opendcTrace.opendcTraceApi) + implementation(projects.opendcWeb.opendcWebClient) implementation(libs.kotlin.logging) implementation(libs.clikt) implementation(libs.sentry.log4j2) - implementation(libs.ktor.client.cio) - implementation(libs.ktor.client.auth) - implementation(libs.ktor.client.jackson) - implementation(libs.jackson.datatype.jsr310) implementation(kotlin("reflect")) runtimeOnly(projects.opendcTrace.opendcTraceOpendc) runtimeOnly(libs.log4j.slf4j) - - testImplementation(libs.ktor.client.mock) } diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/ApiClient.kt b/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/ApiClient.kt deleted file mode 100644 index 9f2656c4..00000000 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/ApiClient.kt +++ /dev/null @@ -1,179 +0,0 @@ -/* - * 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. - */ - -package org.opendc.web.client - -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule -import io.ktor.client.* -import io.ktor.client.features.auth.* -import io.ktor.client.features.auth.providers.* -import io.ktor.client.features.json.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import org.opendc.web.client.model.* -import java.net.URI - -/** - * Client implementation for the OpenDC REST API (version 2). - * - * @param baseUrl The base url of the API. - * @param auth The authentication configuration for the client. - * @param client The HTTP client to use. - */ -public class ApiClient( - private val baseUrl: URI, - private val auth: AuthConfiguration, - private val audience: String = "https://api.opendc.org/v2/", - client: HttpClient = HttpClient {} -) : AutoCloseable { - /** - * The Ktor [HttpClient] that is used to communicate with the REST API. - */ - private val client = client.config { - install(JsonFeature) { - serializer = JacksonSerializer { - registerModule(JavaTimeModule()) - } - } - install(Auth) { - bearer { - loadTokens { requestToken() } - refreshTokens { requestToken() } - } - } - expectSuccess = false - } - - /** - * Retrieve the topology with the specified [id]. - */ - public suspend fun getPortfolio(id: String): Portfolio? { - val url = URLBuilder(Url(baseUrl)) - .path("portfolios", id) - .build() - return when (val result = client.get<ApiResult<Portfolio>>(url)) { - is ApiResult.Success -> result.data - else -> null - } - } - - /** - * Retrieve the scenario with the specified [id]. - */ - public suspend fun getScenario(id: String): Scenario? { - val url = URLBuilder(Url(baseUrl)) - .path("scenarios", id) - .build() - return when (val result = client.get<ApiResult<Scenario>>(url)) { - is ApiResult.Success -> result.data - else -> null - } - } - - /** - * Retrieve the topology with the specified [id]. - */ - public suspend fun getTopology(id: String): Topology? { - val url = URLBuilder(Url(baseUrl)) - .path("topologies", id) - .build() - return when (val result = client.get<ApiResult<Topology>>(url)) { - is ApiResult.Success -> result.data - else -> null - } - } - - /** - * Retrieve the available jobs. - */ - public suspend fun getJobs(): List<Job> { - val url = URLBuilder(Url(baseUrl)) - .path("jobs") - .build() - return when (val result = client.get<ApiResult<List<Job>>>(url)) { - is ApiResult.Success -> result.data - else -> emptyList() - } - } - - /** - * Update the specified job. - * - * @param id The identifier of the job. - * @param state The new state of the job. - * @param results The results of the job. - */ - public suspend fun updateJob(id: String, state: SimulationState, results: Map<String, Any> = emptyMap()): Boolean { - val url = URLBuilder(Url(baseUrl)) - .path("jobs", id) - .build() - - data class Request( - val state: SimulationState, - val results: Map<String, Any> - ) - - val res = client.post<HttpResponse> { - url(url) - contentType(ContentType.Application.Json) - body = Request(state, results) - } - return res.status.isSuccess() - } - - /** - * Request the auth token for the API. - */ - private suspend fun requestToken(): BearerTokens { - data class Request( - val audience: String, - @JsonProperty("grant_type") - val grantType: String, - @JsonProperty("client_id") - val clientId: String, - @JsonProperty("client_secret") - val clientSecret: String - ) - - data class Response( - @JsonProperty("access_token") - val accessToken: String, - @JsonProperty("token_type") - val tokenType: String, - val scope: String = "", - @JsonProperty("expires_in") - val expiresIn: Long - ) - - val result = client.post<Response> { - url(Url("https://${auth.domain}/oauth/token")) - contentType(ContentType.Application.Json) - body = Request(audience, "client_credentials", auth.clientId, auth.clientSecret) - } - - return BearerTokens(result.accessToken, "") - } - - override fun close() = client.close() -} diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ScenarioTrace.kt b/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ScenarioTrace.kt deleted file mode 100644 index adff6d97..00000000 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ScenarioTrace.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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. - */ - -package org.opendc.web.client.model - -/** - * The trace details of a scenario. - */ -public data class ScenarioTrace(val traceId: String, val loadSamplingFraction: Double) diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/Main.kt b/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/Main.kt index 94ef8f8e..561dcd59 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/Main.kt +++ b/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/Main.kt @@ -44,15 +44,15 @@ import org.opendc.simulator.compute.power.SimplePowerDriver import org.opendc.simulator.core.runBlockingSimulation import org.opendc.telemetry.compute.collectServiceMetrics import org.opendc.telemetry.sdk.metrics.export.CoroutineMetricReader -import org.opendc.web.client.ApiClient -import org.opendc.web.client.AuthConfiguration -import org.opendc.web.client.model.Scenario +import org.opendc.web.client.auth.OpenIdAuthController +import org.opendc.web.client.runner.OpenDCRunnerClient +import org.opendc.web.proto.runner.Job +import org.opendc.web.proto.runner.Scenario import java.io.File import java.net.URI import java.time.Duration import java.util.* -import org.opendc.web.client.model.Portfolio as ClientPortfolio -import org.opendc.web.client.model.Topology as ClientTopology +import org.opendc.web.proto.runner.Topology as ClientTopology private val logger = KotlinLogging.logger {} @@ -134,18 +134,18 @@ class RunnerCli : CliktCommand(name = "runner") { .default(60L * 3) // Experiment may run for a maximum of three minutes /** - * Converge a single scenario. + * Run a simulation job. */ - private suspend fun runScenario(portfolio: ClientPortfolio, scenario: Scenario, topology: Topology): List<WebComputeMetricExporter.Result> { - val id = scenario.id + private suspend fun runJob(job: Job, topology: Topology): List<WebComputeMetricExporter.Result> { + val id = job.id + val scenario = job.scenario logger.info { "Constructing performance interference model" } val workloadLoader = ComputeWorkloadLoader(tracePath) val interferenceModel = let { - val path = tracePath.resolve(scenario.trace.traceId).resolve("performance-interference-model.json") - val operational = scenario.operationalPhenomena - val enabled = operational.performanceInterferenceEnabled + val path = tracePath.resolve(scenario.workload.trace.id).resolve("performance-interference-model.json") + val enabled = scenario.phenomena.interference if (!enabled || !path.exists()) { return@let null @@ -154,8 +154,7 @@ class RunnerCli : CliktCommand(name = "runner") { VmInterferenceModelReader().read(path.inputStream()) } - val targets = portfolio.targets - val results = (0 until targets.repeatsPerScenario).map { repeat -> + val results = (0 until scenario.portfolio.targets.repeats).map { repeat -> logger.info { "Starting repeat $repeat" } withTimeout(runTimeout * 1000) { runRepeat(scenario, repeat, topology, workloadLoader, interferenceModel?.withSeed(repeat.toLong())) @@ -168,7 +167,7 @@ class RunnerCli : CliktCommand(name = "runner") { } /** - * Converge a single repeat. + * Run a single repeat. */ private suspend fun runRepeat( scenario: Scenario, @@ -181,17 +180,17 @@ class RunnerCli : CliktCommand(name = "runner") { try { runBlockingSimulation { - val workloadName = scenario.trace.traceId - val workloadFraction = scenario.trace.loadSamplingFraction + val workloadName = scenario.workload.trace.id + val workloadFraction = scenario.workload.samplingFraction val seeder = Random(repeat.toLong()) - val operational = scenario.operationalPhenomena - val computeScheduler = createComputeScheduler(operational.schedulerName, seeder) + val phenomena = scenario.phenomena + val computeScheduler = createComputeScheduler(scenario.schedulerName, seeder) val workload = trace(workloadName).sampleByLoad(workloadFraction) val failureModel = - if (operational.failuresEnabled) + if (phenomena.failures) grid5000(Duration.ofDays(7)) else null @@ -203,7 +202,7 @@ class RunnerCli : CliktCommand(name = "runner") { telemetry, computeScheduler, failureModel, - interferenceModel.takeIf { operational.performanceInterferenceEnabled } + interferenceModel ) telemetry.registerMetricReader(CoroutineMetricReader(this, exporter, exportInterval = Duration.ofHours(1))) @@ -241,20 +240,19 @@ class RunnerCli : CliktCommand(name = "runner") { override fun run(): Unit = runBlocking(Dispatchers.Default) { logger.info { "Starting OpenDC web runner" } - val client = ApiClient(baseUrl = apiUrl, AuthConfiguration(authDomain, authClientId, authClientSecret), authAudience) + val client = OpenDCRunnerClient(baseUrl = apiUrl, OpenIdAuthController(authDomain, authClientId, authClientSecret, authAudience)) val manager = ScenarioManager(client) logger.info { "Watching for queued scenarios" } while (true) { - val scenario = manager.findNext() - - if (scenario == null) { + val job = manager.findNext() + if (job == null) { delay(POLL_INTERVAL) continue } - val id = scenario.id + val id = job.id logger.info { "Found queued scenario $id: attempting to claim" } @@ -273,10 +271,8 @@ class RunnerCli : CliktCommand(name = "runner") { } try { - val scenarioModel = client.getScenario(id)!! - val portfolio = client.getPortfolio(scenarioModel.portfolioId)!! - val environment = convert(client.getTopology(scenarioModel.topology.topologyId)!!) - val results = runScenario(portfolio, scenarioModel, environment) + val environment = convert(job.scenario.topology) + val results = runJob(job, environment) logger.info { "Writing results to database" } @@ -306,7 +302,8 @@ class RunnerCli : CliktCommand(name = "runner") { val machines = topology.rooms.asSequence() .flatMap { room -> room.tiles.flatMap { tile -> - tile.rack?.machines?.map { machine -> tile.rack to machine } ?: emptyList() + val rack = tile.rack + rack?.machines?.map { machine -> rack to machine } ?: emptyList() } } for ((rack, machine) in machines) { diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/ScenarioManager.kt b/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/ScenarioManager.kt index 1ee835a6..7374f0c9 100644 --- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/ScenarioManager.kt +++ b/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/ScenarioManager.kt @@ -22,64 +22,68 @@ package org.opendc.web.runner -import org.opendc.web.client.ApiClient -import org.opendc.web.client.model.Job -import org.opendc.web.client.model.SimulationState +import org.opendc.web.client.runner.OpenDCRunnerClient +import org.opendc.web.proto.JobState +import org.opendc.web.proto.runner.Job /** * Manages the queue of scenarios that need to be processed. */ -public class ScenarioManager(private val client: ApiClient) { +class ScenarioManager(private val client: OpenDCRunnerClient) { /** * Find the next job that the simulator needs to process. */ - public suspend fun findNext(): Job? { - return client.getJobs().firstOrNull() + fun findNext(): Job? { + return client.jobs.queryPending().firstOrNull() } /** * Claim the simulation job with the specified id. */ - public suspend fun claim(id: String): Boolean { - return client.updateJob(id, SimulationState.CLAIMED) + fun claim(id: Long): Boolean { + client.jobs.update(id, Job.Update(JobState.CLAIMED)) // TODO Handle conflict + return true } /** * Update the heartbeat of the specified scenario. */ - public suspend fun heartbeat(id: String) { - client.updateJob(id, SimulationState.RUNNING) + fun heartbeat(id: Long) { + client.jobs.update(id, Job.Update(JobState.RUNNING)) } /** * Mark the scenario as failed. */ - public suspend fun fail(id: String) { - client.updateJob(id, SimulationState.FAILED) + fun fail(id: Long) { + client.jobs.update(id, Job.Update(JobState.FAILED)) } /** * Persist the specified results. */ - public suspend fun finish(id: String, results: List<WebComputeMetricExporter.Result>) { - client.updateJob( - id, SimulationState.FINISHED, - mapOf( - "total_requested_burst" to results.map { it.totalActiveTime + it.totalIdleTime }, - "total_granted_burst" to results.map { it.totalActiveTime }, - "total_overcommitted_burst" to results.map { it.totalStealTime }, - "total_interfered_burst" to results.map { it.totalLostTime }, - "mean_cpu_usage" to results.map { it.meanCpuUsage }, - "mean_cpu_demand" to results.map { it.meanCpuDemand }, - "mean_num_deployed_images" to results.map { it.meanNumDeployedImages }, - "max_num_deployed_images" to results.map { it.maxNumDeployedImages }, - "total_power_draw" to results.map { it.totalPowerDraw }, - "total_failure_slices" to results.map { it.totalFailureSlices }, - "total_failure_vm_slices" to results.map { it.totalFailureVmSlices }, - "total_vms_submitted" to results.map { it.totalVmsSubmitted }, - "total_vms_queued" to results.map { it.totalVmsQueued }, - "total_vms_finished" to results.map { it.totalVmsFinished }, - "total_vms_failed" to results.map { it.totalVmsFailed } + public fun finish(id: Long, results: List<WebComputeMetricExporter.Result>) { + client.jobs.update( + id, + Job.Update( + JobState.FINISHED, + mapOf( + "total_requested_burst" to results.map { it.totalActiveTime + it.totalIdleTime }, + "total_granted_burst" to results.map { it.totalActiveTime }, + "total_overcommitted_burst" to results.map { it.totalStealTime }, + "total_interfered_burst" to results.map { it.totalLostTime }, + "mean_cpu_usage" to results.map { it.meanCpuUsage }, + "mean_cpu_demand" to results.map { it.meanCpuDemand }, + "mean_num_deployed_images" to results.map { it.meanNumDeployedImages }, + "max_num_deployed_images" to results.map { it.maxNumDeployedImages }, + "total_power_draw" to results.map { it.totalPowerDraw }, + "total_failure_slices" to results.map { it.totalFailureSlices }, + "total_failure_vm_slices" to results.map { it.totalFailureVmSlices }, + "total_vms_submitted" to results.map { it.totalVmsSubmitted }, + "total_vms_queued" to results.map { it.totalVmsQueued }, + "total_vms_finished" to results.map { it.totalVmsFinished }, + "total_vms_failed" to results.map { it.totalVmsFailed } + ) ) ) } diff --git a/opendc-web/opendc-web-runner/src/test/kotlin/org/opendc/web/client/ApiClientTest.kt b/opendc-web/opendc-web-runner/src/test/kotlin/org/opendc/web/client/ApiClientTest.kt deleted file mode 100644 index 3a0730a6..00000000 --- a/opendc-web/opendc-web-runner/src/test/kotlin/org/opendc/web/client/ApiClientTest.kt +++ /dev/null @@ -1,264 +0,0 @@ -/* - * 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. - */ - -package org.opendc.web.client - -import io.ktor.client.* -import io.ktor.client.engine.mock.* -import io.ktor.http.* -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Test -import java.net.URI - -/** - * Test suite for the [ApiClient] class. - */ -class ApiClientTest { - /** - * The Ktor [HttpClient] instance. - */ - private val ktor = HttpClient(MockEngine) { - engine { - addHandler { request -> - when (request.url.fullPath) { - "/oauth/token" -> { - val responseHeaders = headersOf("Content-Type" to listOf(ContentType.Application.Json.toString())) - respond( - """ - { - "access_token": "eyJz93a...k4laUWw", - "token_type": "Bearer", - "expires_in": 86400 - } - """.trimIndent(), - headers = responseHeaders - ) - } - "/portfolios/5fda5daa97dca438e7cb0a4c" -> { - val responseHeaders = headersOf("Content-Type" to listOf(ContentType.Application.Json.toString())) - respond( - """ - { - "data": { - "_id": "string", - "projectId": "string", - "name": "string", - "scenarioIds": [ - "string" - ], - "targets": { - "enabledMetrics": [ - "string" - ], - "repeatsPerScenario": 0 - } - } - } - """.trimIndent(), - headers = responseHeaders - ) - } - "/portfolios/x" -> { - val responseHeaders = headersOf("Content-Type" to listOf(ContentType.Application.Json.toString())) - respond( - """ - { - "message": "Not Found" - } - """.trimIndent(), - headers = responseHeaders, status = HttpStatusCode.NotFound - ) - } - "/scenarios/5fda5db297dca438e7cb0a4d" -> { - val responseHeaders = headersOf("Content-Type" to listOf(ContentType.Application.Json.toString())) - respond( - """ - { - "data": { - "_id": "string", - "portfolioId": "string", - "name": "string", - "trace": { - "traceId": "string", - "loadSamplingFraction": 0 - }, - "topology": { - "topologyId": "string" - }, - "operational": { - "failuresEnabled": true, - "performanceInterferenceEnabled": true, - "schedulerName": "string" - } - } - } - """.trimIndent(), - headers = responseHeaders - ) - } - "/scenarios/x" -> { - val responseHeaders = headersOf("Content-Type" to listOf(ContentType.Application.Json.toString())) - respond( - """ - { - "message": "Not Found" - } - """.trimIndent(), - headers = responseHeaders, status = HttpStatusCode.NotFound - ) - } - "/topologies/5f9825a6cf6e4c24e380b86f" -> { - val responseHeaders = headersOf("Content-Type" to listOf(ContentType.Application.Json.toString())) - respond( - """ - { - "data": { - "_id": "string", - "projectId": "string", - "name": "string", - "rooms": [ - { - "_id": "string", - "name": "string", - "tiles": [ - { - "_id": "string", - "positionX": 0, - "positionY": 0, - "rack": { - "_id": "string", - "name": "string", - "capacity": 0, - "powerCapacityW": 0, - "machines": [ - { - "_id": "string", - "position": 0, - "cpus": [ - { - "_id": "string", - "name": "string", - "clockRateMhz": 0, - "numberOfCores": 0 - } - ], - "gpus": [ - { - "_id": "string", - "name": "string", - "clockRateMhz": 0, - "numberOfCores": 0 - } - ], - "memories": [ - { - "_id": "string", - "name": "string", - "speedMbPerS": 0, - "sizeMb": 0 - } - ], - "storages": [ - { - "_id": "string", - "name": "string", - "speedMbPerS": 0, - "sizeMb": 0 - } - ] - } - ] - } - } - ] - } - ] - } - } - """.trimIndent(), - headers = responseHeaders - ) - } - "/topologies/x" -> { - val responseHeaders = - headersOf("Content-Type" to listOf(ContentType.Application.Json.toString())) - respond( - """ - { - "message": "Not Found" - } - """.trimIndent(), - headers = responseHeaders, status = HttpStatusCode.NotFound - ) - } - else -> error("Unhandled ${request.url}") - } - } - } - } - - private val auth = AuthConfiguration("auth.opendc.org", "a", "b") - - @Test - fun testPortfolioExists(): Unit = runBlocking { - val client = ApiClient(URI("http://localhost:8081"), auth, client = ktor) - val portfolio = client.getPortfolio("5fda5daa97dca438e7cb0a4c") - assertNotNull(portfolio) - } - - @Test - fun testPortfolioDoesNotExists(): Unit = runBlocking { - val client = ApiClient(URI("http://localhost:8081"), auth, client = ktor) - val portfolio = client.getPortfolio("x") - assertNull(portfolio) - } - - @Test - fun testScenarioExists(): Unit = runBlocking { - val client = ApiClient(URI("http://localhost:8081"), auth, client = ktor) - val scenario = client.getScenario("5fda5db297dca438e7cb0a4d") - assertNotNull(scenario) - } - - @Test - fun testScenarioDoesNotExists(): Unit = runBlocking { - val client = ApiClient(URI("http://localhost:8081"), auth, client = ktor) - val scenario = client.getScenario("x") - assertNull(scenario) - } - - @Test - fun testTopologyExists(): Unit = runBlocking { - val client = ApiClient(URI("http://localhost:8081"), auth, client = ktor) - val topology = client.getTopology("5f9825a6cf6e4c24e380b86f") - assertNotNull(topology) - } - - @Test - fun testTopologyDoesNotExists(): Unit = runBlocking { - val client = ApiClient(URI("http://localhost:8081"), auth, client = ktor) - val topology = client.getTopology("x") - assertNull(topology) - } -} diff --git a/opendc-web/opendc-web-ui/src/api/index.js b/opendc-web/opendc-web-ui/src/api/index.js index 680d49ce..1a9877d0 100644 --- a/opendc-web/opendc-web-ui/src/api/index.js +++ b/opendc-web/opendc-web-ui/src/api/index.js @@ -47,5 +47,5 @@ export async function request(auth, path, method = 'GET', body) { throw response.message } - return json.data + return json } diff --git a/opendc-web/opendc-web-ui/src/api/portfolios.js b/opendc-web/opendc-web-ui/src/api/portfolios.js index 82ac0ced..d818876f 100644 --- a/opendc-web/opendc-web-ui/src/api/portfolios.js +++ b/opendc-web/opendc-web-ui/src/api/portfolios.js @@ -22,22 +22,18 @@ import { request } from './index' -export function fetchPortfolio(auth, portfolioId) { - return request(auth, `portfolios/${portfolioId}`) +export function fetchPortfolio(auth, projectId, number) { + return request(auth, `projects/${projectId}/portfolios/${number}`) } -export function fetchPortfoliosOfProject(auth, projectId) { +export function fetchPortfolios(auth, projectId) { return request(auth, `projects/${projectId}/portfolios`) } -export function addPortfolio(auth, portfolio) { - return request(auth, `projects/${portfolio.projectId}/portfolios`, 'POST', { portfolio }) +export function addPortfolio(auth, projectId, portfolio) { + return request(auth, `projects/${projectId}/portfolios`, 'POST', portfolio) } -export function updatePortfolio(auth, portfolioId, portfolio) { - return request(auth, `portfolios/${portfolioId}`, 'PUT', { portfolio }) -} - -export function deletePortfolio(auth, portfolioId) { - return request(auth, `portfolios/${portfolioId}`, 'DELETE') +export function deletePortfolio(auth, projectId, number) { + return request(auth, `projects/${projectId}/portfolios/${number}`, 'DELETE') } diff --git a/opendc-web/opendc-web-ui/src/api/projects.js b/opendc-web/opendc-web-ui/src/api/projects.js index 4123b371..e7e095da 100644 --- a/opendc-web/opendc-web-ui/src/api/projects.js +++ b/opendc-web/opendc-web-ui/src/api/projects.js @@ -31,11 +31,7 @@ export function fetchProject(auth, projectId) { } export function addProject(auth, project) { - return request(auth, 'projects/', 'POST', { project }) -} - -export function updateProject(auth, project) { - return request(auth, `projects/${project._id}`, 'PUT', { project }) + return request(auth, 'projects/', 'POST', project) } export function deleteProject(auth, projectId) { diff --git a/opendc-web/opendc-web-ui/src/api/scenarios.js b/opendc-web/opendc-web-ui/src/api/scenarios.js index 88516caa..7eeb8f28 100644 --- a/opendc-web/opendc-web-ui/src/api/scenarios.js +++ b/opendc-web/opendc-web-ui/src/api/scenarios.js @@ -22,22 +22,18 @@ import { request } from './index' -export function fetchScenario(auth, scenarioId) { - return request(auth, `scenarios/${scenarioId}`) +export function fetchScenario(auth, projectId, scenarioId) { + return request(auth, `projects/${projectId}/scenarios/${scenarioId}`) } -export function fetchScenariosOfPortfolio(auth, portfolioId) { - return request(auth, `portfolios/${portfolioId}/scenarios`) +export function fetchScenariosOfPortfolio(auth, projectId, portfolioId) { + return request(auth, `projects/${projectId}/portfolios/${portfolioId}/scenarios`) } -export function addScenario(auth, scenario) { - return request(auth, `portfolios/${scenario.portfolioId}/scenarios`, 'POST', { scenario }) +export function addScenario(auth, projectId, portfolioId, scenario) { + return request(auth, `projects/${projectId}/portfolios/${portfolioId}/scenarios`, 'POST', scenario) } -export function updateScenario(auth, scenarioId, scenario) { - return request(auth, `scenarios/${scenarioId}`, 'PUT', { scenario }) -} - -export function deleteScenario(auth, scenarioId) { - return request(auth, `scenarios/${scenarioId}`, 'DELETE') +export function deleteScenario(auth, projectId, scenarioId) { + return request(auth, `projects/${projectId}/scenarios/${scenarioId}`, 'DELETE') } diff --git a/opendc-web/opendc-web-ui/src/api/topologies.js b/opendc-web/opendc-web-ui/src/api/topologies.js index bd4e3bc4..0509c6d0 100644 --- a/opendc-web/opendc-web-ui/src/api/topologies.js +++ b/opendc-web/opendc-web-ui/src/api/topologies.js @@ -22,24 +22,23 @@ import { request } from './index' -export function fetchTopology(auth, topologyId) { - return request(auth, `topologies/${topologyId}`) +export function fetchTopology(auth, projectId, number) { + return request(auth, `projects/${projectId}/topologies/${number}`) } -export function fetchTopologiesOfProject(auth, projectId) { +export function fetchTopologies(auth, projectId) { return request(auth, `projects/${projectId}/topologies`) } -export function addTopology(auth, topology) { - return request(auth, `projects/${topology.projectId}/topologies`, 'POST', { topology }) +export function addTopology(auth, projectId, topology) { + return request(auth, `projects/${projectId}/topologies`, 'POST', topology) } export function updateTopology(auth, topology) { - // eslint-disable-next-line no-unused-vars - const { _id, ...data } = topology - return request(auth, `topologies/${topology._id}`, 'PUT', { topology: data }) + const { project, number, rooms } = topology + return request(auth, `projects/${project.id}/topologies/${number}`, 'PUT', { rooms }) } -export function deleteTopology(auth, topologyId) { - return request(auth, `topologies/${topologyId}`, 'DELETE') +export function deleteTopology(auth, projectId, number) { + return request(auth, `projects/${projectId}/topologies/${number}`, 'DELETE') } diff --git a/opendc-web/opendc-web-ui/src/components/AppNavigation.js b/opendc-web/opendc-web-ui/src/components/AppNavigation.js index 178c3ec0..77c683a2 100644 --- a/opendc-web/opendc-web-ui/src/components/AppNavigation.js +++ b/opendc-web/opendc-web-ui/src/components/AppNavigation.js @@ -23,15 +23,9 @@ import { Nav, NavItem, NavList } from '@patternfly/react-core' import { useRouter } from 'next/router' import NavItemLink from './util/NavItemLink' -import { useProject } from '../data/project' export function AppNavigation() { - const { pathname, query } = useRouter() - const { project: projectId } = query - const { data: project } = useProject(projectId) - - const nextTopologyId = project?.topologyIds?.[0] - const nextPortfolioId = project?.portfolioIds?.[0] + const { pathname } = useRouter() return ( <Nav variant="horizontal"> @@ -45,28 +39,6 @@ export function AppNavigation() { > Projects </NavItem> - {pathname.startsWith('/projects/[project]') && ( - <> - <NavItem - id="topologies" - to={nextTopologyId ? `/projects/${projectId}/topologies/${nextTopologyId}` : '/projects'} - itemId={1} - component={NavItemLink} - isActive={pathname === '/projects/[project]/topologies/[topology]'} - > - Topologies - </NavItem> - <NavItem - id="portfolios" - to={nextPortfolioId ? `/projects/${projectId}/portfolios/${nextPortfolioId}` : '/projects'} - itemId={2} - component={NavItemLink} - isActive={pathname === '/projects/[project]/portfolios/[portfolio]'} - > - Portfolios - </NavItem> - </> - )} </NavList> </Nav> ) diff --git a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js index 3712cfa0..a99b60c0 100644 --- a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js +++ b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js @@ -22,13 +22,11 @@ import PropTypes from 'prop-types' import { ContextSelector as PFContextSelector, ContextSelectorItem } from '@patternfly/react-core' -import { useMemo, useState, useReducer } from 'react' +import { useMemo, useState } from 'react' import { contextSelector } from './ContextSelector.module.scss' -function ContextSelector({ activeItem, items, onSelect, label }) { - const [isOpen, toggle] = useReducer((isOpen) => !isOpen, false) +function ContextSelector({ activeItem, items, onSelect, onToggle, isOpen, label }) { const [searchValue, setSearchValue] = useState('') - const filteredItems = useMemo( () => items.filter(({ name }) => name.toLowerCase().indexOf(searchValue.toLowerCase()) !== -1) || items, [items, searchValue] @@ -36,23 +34,22 @@ function ContextSelector({ activeItem, items, onSelect, label }) { return ( <PFContextSelector - menuAppendTo={global.document?.body} className={contextSelector} toggleText={activeItem ? `${label}: ${activeItem.name}` : label} onSearchInputChange={(value) => setSearchValue(value)} searchInputValue={searchValue} isOpen={isOpen} - onToggle={toggle} + onToggle={(_, isOpen) => onToggle(isOpen)} onSelect={(event) => { - const targetId = event.target.value - const target = items.find((item) => item._id === targetId) + const targetId = +event.target.value + const target = items.find((item) => item.id === targetId) - toggle() onSelect(target) + onToggle(!isOpen) }} > {filteredItems.map((item) => ( - <ContextSelectorItem key={item._id} value={item._id}> + <ContextSelectorItem key={item.id} value={item.id}> {item.name} </ContextSelectorItem> ))} @@ -61,7 +58,7 @@ function ContextSelector({ activeItem, items, onSelect, label }) { } const Item = PropTypes.shape({ - _id: PropTypes.string.isRequired, + id: PropTypes.any.isRequired, name: PropTypes.string.isRequired, }) @@ -69,6 +66,8 @@ ContextSelector.propTypes = { activeItem: Item, items: PropTypes.arrayOf(Item).isRequired, onSelect: PropTypes.func.isRequired, + onToggle: PropTypes.func.isRequired, + isOpen: PropTypes.bool, label: PropTypes.string, } diff --git a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss index fefba41f..0aa63ee6 100644 --- a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss +++ b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss @@ -21,7 +21,6 @@ */ .contextSelector { - width: auto; margin-right: 20px; --pf-c-context-selector__toggle--PaddingTop: var(--pf-global--spacer--sm); diff --git a/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js b/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js index 694681ac..c4f2d50e 100644 --- a/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js +++ b/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js @@ -21,27 +21,31 @@ */ import { useRouter } from 'next/router' -import { useMemo } from 'react' -import { useProjectPortfolios } from '../../data/project' +import { useState } from 'react' +import { usePortfolios } from '../../data/project' +import { Portfolio } from '../../shapes' import ContextSelector from './ContextSelector' -function PortfolioSelector() { +function PortfolioSelector({ activePortfolio }) { const router = useRouter() - const { project, portfolio: activePortfolioId } = router.query - const { data: portfolios = [] } = useProjectPortfolios(project) - const activePortfolio = useMemo(() => portfolios.find((portfolio) => portfolio._id === activePortfolioId), [ - activePortfolioId, - portfolios, - ]) + + const [isOpen, setOpen] = useState(false) + const { data: portfolios = [] } = usePortfolios(activePortfolio?.project?.id, { enabled: isOpen }) return ( <ContextSelector label="Portfolio" activeItem={activePortfolio} items={portfolios} - onSelect={(portfolio) => router.push(`/projects/${portfolio.projectId}/portfolios/${portfolio._id}`)} + onSelect={(portfolio) => router.push(`/projects/${portfolio.project.id}/portfolios/${portfolio.number}`)} + onToggle={setOpen} + isOpen={isOpen} /> ) } +PortfolioSelector.propTypes = { + activePortfolio: Portfolio, +} + export default PortfolioSelector diff --git a/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js b/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js index 753632ab..7721e04c 100644 --- a/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js +++ b/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js @@ -20,29 +20,32 @@ * SOFTWARE. */ -import PropTypes from 'prop-types' import { useRouter } from 'next/router' -import { useMemo } from 'react' +import { useState } from 'react' import { useProjects } from '../../data/project' +import { Project } from '../../shapes' import ContextSelector from './ContextSelector' -function ProjectSelector({ projectId }) { +function ProjectSelector({ activeProject }) { const router = useRouter() - const { data: projects = [] } = useProjects() - const activeProject = useMemo(() => projects.find((project) => project._id === projectId), [projectId, projects]) + + const [isOpen, setOpen] = useState(false) + const { data: projects = [] } = useProjects({ enabled: isOpen }) return ( <ContextSelector label="Project" activeItem={activeProject} items={projects} - onSelect={(project) => router.push(`/projects/${project._id}`)} + onSelect={(project) => router.push(`/projects/${project.id}`)} + onToggle={setOpen} + isOpen={isOpen} /> ) } ProjectSelector.propTypes = { - projectId: PropTypes.string, + activeProject: Project, } export default ProjectSelector diff --git a/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js b/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js index d5e51c6c..9cae4cbf 100644 --- a/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js +++ b/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js @@ -20,33 +20,32 @@ * SOFTWARE. */ -import PropTypes from 'prop-types' import { useRouter } from 'next/router' -import { useMemo } from 'react' -import { useProjectTopologies } from '../../data/topology' +import { useState } from 'react' +import { useTopologies } from '../../data/topology' +import { Topology } from '../../shapes' import ContextSelector from './ContextSelector' -function TopologySelector({ projectId, topologyId }) { +function TopologySelector({ activeTopology }) { const router = useRouter() - const { data: topologies = [] } = useProjectTopologies(projectId) - const activeTopology = useMemo(() => topologies.find((topology) => topology._id === topologyId), [ - topologyId, - topologies, - ]) + + const [isOpen, setOpen] = useState(false) + const { data: topologies = [] } = useTopologies(activeTopology?.project?.id, { enabled: isOpen }) return ( <ContextSelector label="Topology" activeItem={activeTopology} items={topologies} - onSelect={(topology) => router.push(`/projects/${topology.projectId}/topologies/${topology._id}`)} + onSelect={(topology) => router.push(`/projects/${topology.project.id}/topologies/${topology.number}`)} + onToggle={setOpen} + isOpen={isOpen} /> ) } TopologySelector.propTypes = { - projectId: PropTypes.string, - topologyId: PropTypes.string, + activeTopology: Topology, } export default TopologySelector diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/NewScenario.js b/opendc-web/opendc-web-ui/src/components/portfolios/NewScenario.js index 856282a7..fd9a72d2 100644 --- a/opendc-web/opendc-web-ui/src/components/portfolios/NewScenario.js +++ b/opendc-web/opendc-web-ui/src/components/portfolios/NewScenario.js @@ -24,21 +24,15 @@ import PropTypes from 'prop-types' import { PlusIcon } from '@patternfly/react-icons' import { Button } from '@patternfly/react-core' import { useState } from 'react' -import { useMutation } from 'react-query' +import { useNewScenario } from '../../data/project' import NewScenarioModal from './NewScenarioModal' -function NewScenario({ portfolioId }) { +function NewScenario({ projectId, portfolioId }) { const [isVisible, setVisible] = useState(false) - const { mutate: addScenario } = useMutation('addScenario') + const { mutate: addScenario } = useNewScenario() - const onSubmit = (name, portfolioId, trace, topology, operational) => { - addScenario({ - portfolioId, - name, - trace, - topology, - operational, - }) + const onSubmit = (projectId, portfolioNumber, data) => { + addScenario({ projectId, portfolioNumber, data }) setVisible(false) } @@ -48,6 +42,7 @@ function NewScenario({ portfolioId }) { New Scenario </Button> <NewScenarioModal + projectId={projectId} portfolioId={portfolioId} isOpen={isVisible} onSubmit={onSubmit} @@ -58,7 +53,8 @@ function NewScenario({ portfolioId }) { } NewScenario.propTypes = { - portfolioId: PropTypes.string, + projectId: PropTypes.number, + portfolioId: PropTypes.number, } export default NewScenario diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/NewScenarioModal.js b/opendc-web/opendc-web-ui/src/components/portfolios/NewScenarioModal.js index 7f620c8c..ed35c163 100644 --- a/opendc-web/opendc-web-ui/src/components/portfolios/NewScenarioModal.js +++ b/opendc-web/opendc-web-ui/src/components/portfolios/NewScenarioModal.js @@ -12,14 +12,14 @@ import { TextInput, } from '@patternfly/react-core' import { useSchedulers, useTraces } from '../../data/experiments' -import { useProjectTopologies } from '../../data/topology' +import { useTopologies } from '../../data/topology' import { usePortfolio } from '../../data/project' -const NewScenarioModal = ({ portfolioId, isOpen, onSubmit: onSubmitUpstream, onCancel: onCancelUpstream }) => { - const { data: portfolio } = usePortfolio(portfolioId) - const { data: topologies = [] } = useProjectTopologies(portfolio?.projectId) - const { data: traces = [] } = useTraces() - const { data: schedulers = [] } = useSchedulers() +function NewScenarioModal({ projectId, portfolioId, isOpen, onSubmit: onSubmitUpstream, onCancel: onCancelUpstream }) { + const { data: portfolio } = usePortfolio(projectId, portfolioId) + const { data: topologies = [] } = useTopologies(projectId, { enabled: isOpen }) + const { data: traces = [] } = useTraces({ enabled: isOpen }) + const { data: schedulers = [] } = useSchedulers({ enabled: isOpen }) // eslint-disable-next-line no-unused-vars const [isSubmitted, setSubmitted] = useState(false) @@ -51,22 +51,19 @@ const NewScenarioModal = ({ portfolioId, isOpen, onSubmit: onSubmitUpstream, onC const name = nameInput.current.value - onSubmitUpstream( + onSubmitUpstream(portfolio.project.id, portfolio.number, { name, - portfolio._id, - { - traceId: trace || traces[0]._id, - loadSamplingFraction: traceLoad / 100, + workload: { + trace: trace || traces[0].id, + samplingFraction: traceLoad / 100, }, - { - topologyId: topology || topologies[0]._id, + topology: topology || topologies[0].number, + phenomena: { + failures: failuresEnabled, + interference: opPhenEnabled, }, - { - failuresEnabled, - performanceInterferenceEnabled: opPhenEnabled, - schedulerName: scheduler || schedulers[0].name, - } - ) + schedulerName: scheduler || schedulers[0], + }) resetState() return true @@ -84,8 +81,8 @@ const NewScenarioModal = ({ portfolioId, isOpen, onSubmit: onSubmitUpstream, onC id="name" name="name" type="text" - isDisabled={portfolio?.scenarioIds?.length === 0} - defaultValue={portfolio?.scenarioIds?.length === 0 ? 'Base scenario' : ''} + isDisabled={portfolio?.scenarios?.length === 0} + defaultValue={portfolio?.scenarios?.length === 0 ? 'Base scenario' : ''} ref={nameInput} /> </FormGroup> @@ -93,7 +90,7 @@ const NewScenarioModal = ({ portfolioId, isOpen, onSubmit: onSubmitUpstream, onC <FormGroup label="Trace" fieldId="trace" isRequired> <FormSelect id="trace" name="trace" value={trace} onChange={setTrace}> {traces.map((trace) => ( - <FormSelectOption value={trace._id} key={trace._id} label={trace.name} /> + <FormSelectOption value={trace.id} key={trace.id} label={trace.name} /> ))} </FormSelect> </FormGroup> @@ -115,7 +112,7 @@ const NewScenarioModal = ({ portfolioId, isOpen, onSubmit: onSubmitUpstream, onC <FormGroup label="Topology" fieldId="topology" isRequired> <FormSelect id="topology" name="topology" value={topology} onChange={setTopology}> {topologies.map((topology) => ( - <FormSelectOption value={topology._id} key={topology._id} label={topology.name} /> + <FormSelectOption value={topology.number} key={topology.number} label={topology.name} /> ))} </FormSelect> </FormGroup> @@ -123,7 +120,7 @@ const NewScenarioModal = ({ portfolioId, isOpen, onSubmit: onSubmitUpstream, onC <FormGroup label="Scheduler" fieldId="scheduler" isRequired> <FormSelect id="scheduler" name="scheduler" value={scheduler} onChange={setScheduler}> {schedulers.map((scheduler) => ( - <FormSelectOption value={scheduler.name} key={scheduler.name} label={scheduler.name} /> + <FormSelectOption value={scheduler} key={scheduler} label={scheduler} /> ))} </FormSelect> </FormGroup> @@ -150,7 +147,8 @@ const NewScenarioModal = ({ portfolioId, isOpen, onSubmit: onSubmitUpstream, onC } NewScenarioModal.propTypes = { - portfolioId: PropTypes.string, + projectId: PropTypes.number, + portfolioId: PropTypes.number, isOpen: PropTypes.bool.isRequired, onSubmit: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired, diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioOverview.js b/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioOverview.js index 580b0a29..e561b655 100644 --- a/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioOverview.js +++ b/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioOverview.js @@ -43,8 +43,8 @@ import { METRIC_NAMES } from '../../util/available-metrics' import NewScenario from './NewScenario' import ScenarioTable from './ScenarioTable' -function PortfolioOverview({ portfolioId }) { - const { data: portfolio } = usePortfolio(portfolioId) +function PortfolioOverview({ projectId, portfolioId }) { + const { status, data: portfolio } = usePortfolio(projectId, portfolioId) return ( <Grid hasGutter> @@ -62,16 +62,16 @@ function PortfolioOverview({ portfolioId }) { <DescriptionListGroup> <DescriptionListTerm>Scenarios</DescriptionListTerm> <DescriptionListDescription> - {portfolio?.scenarioIds.length ?? <Skeleton screenreaderText="Loading portfolio" />} + {portfolio?.scenarios?.length ?? <Skeleton screenreaderText="Loading portfolio" />} </DescriptionListDescription> </DescriptionListGroup> <DescriptionListGroup> <DescriptionListTerm>Metrics</DescriptionListTerm> <DescriptionListDescription> - {portfolio?.targets?.enabledMetrics ? ( - portfolio.targets.enabledMetrics.length > 0 ? ( + {portfolio ? ( + portfolio.targets.metrics.length > 0 ? ( <ChipGroup> - {portfolio.targets.enabledMetrics.map((metric) => ( + {portfolio.targets.metrics.map((metric) => ( <Chip isReadOnly key={metric}> {METRIC_NAMES[metric]} </Chip> @@ -88,9 +88,7 @@ function PortfolioOverview({ portfolioId }) { <DescriptionListGroup> <DescriptionListTerm>Repeats per Scenario</DescriptionListTerm> <DescriptionListDescription> - {portfolio?.targets?.repeatsPerScenario ?? ( - <Skeleton screenreaderText="Loading portfolio" /> - )} + {portfolio?.targets?.repeats ?? <Skeleton screenreaderText="Loading portfolio" />} </DescriptionListDescription> </DescriptionListGroup> </DescriptionList> @@ -101,12 +99,12 @@ function PortfolioOverview({ portfolioId }) { <Card> <CardHeader> <CardActions> - <NewScenario portfolioId={portfolioId} /> + <NewScenario projectId={projectId} portfolioId={portfolioId} /> </CardActions> <CardTitle>Scenarios</CardTitle> </CardHeader> <CardBody> - <ScenarioTable portfolioId={portfolioId} /> + <ScenarioTable portfolio={portfolio} status={status} /> </CardBody> </Card> </GridItem> @@ -115,7 +113,8 @@ function PortfolioOverview({ portfolioId }) { } PortfolioOverview.propTypes = { - portfolioId: PropTypes.string, + projectId: PropTypes.number, + portfolioId: PropTypes.number, } export default PortfolioOverview diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js b/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js index 00023d9e..f63f0c7f 100644 --- a/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js +++ b/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js @@ -42,12 +42,14 @@ import { Title, } from '@patternfly/react-core' import { ErrorCircleOIcon, CubesIcon } from '@patternfly/react-icons' -import { usePortfolioScenarios } from '../../data/project' +import { usePortfolio } from '../../data/project' import PortfolioResultInfo from './PortfolioResultInfo' import NewScenario from './NewScenario' -const PortfolioResults = ({ portfolioId }) => { - const { status, data: scenarios = [] } = usePortfolioScenarios(portfolioId) +const PortfolioResults = ({ projectId, portfolioId }) => { + const { status, data: scenarios = [] } = usePortfolio(projectId, portfolioId, { + select: (portfolio) => portfolio.scenarios, + }) if (status === 'loading') { return ( @@ -86,7 +88,7 @@ const PortfolioResults = ({ portfolioId }) => { No results are currently available for this portfolio. Run a scenario to obtain simulation results. </EmptyStateBody> - <NewScenario portfolioId={portfolioId} /> + <NewScenario projectId={projectId} portfolioId={portfolioId} /> </EmptyState> </Bullseye> ) @@ -96,11 +98,11 @@ const PortfolioResults = ({ portfolioId }) => { AVAILABLE_METRICS.forEach((metric) => { dataPerMetric[metric] = scenarios - .filter((scenario) => scenario.results) + .filter((scenario) => scenario.job?.results) .map((scenario) => ({ name: scenario.name, - value: mean(scenario.results[metric]), - errorX: std(scenario.results[metric]), + value: mean(scenario.job.results[metric]), + errorX: std(scenario.job.results[metric]), })) }) @@ -150,7 +152,8 @@ const PortfolioResults = ({ portfolioId }) => { } PortfolioResults.propTypes = { - portfolioId: PropTypes.string, + projectId: PropTypes.number, + portfolioId: PropTypes.number, } export default PortfolioResults diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioState.js b/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioState.js index 66691580..99d83f64 100644 --- a/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioState.js +++ b/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioState.js @@ -20,13 +20,13 @@ * SOFTWARE. */ -import PropTypes from 'prop-types' import { ClockIcon, CheckCircleIcon, ErrorCircleOIcon } from '@patternfly/react-icons' +import { JobState } from '../../shapes' function ScenarioState({ state }) { switch (state) { + case 'PENDING': case 'CLAIMED': - case 'QUEUED': return ( <span> <ClockIcon color="blue" /> Queued @@ -56,7 +56,7 @@ function ScenarioState({ state }) { } ScenarioState.propTypes = { - state: PropTypes.oneOf(['QUEUED', 'CLAIMED', 'RUNNING', 'FINISHED', 'FAILED']), + state: JobState.isRequired, } export default ScenarioState diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js b/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js index 9966e3ba..68647957 100644 --- a/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js +++ b/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js @@ -20,44 +20,38 @@ * SOFTWARE. */ -import PropTypes from 'prop-types' import Link from 'next/link' import { Table, TableBody, TableHeader } from '@patternfly/react-table' import React from 'react' +import { Portfolio, Status } from '../../shapes' import TableEmptyState from '../util/TableEmptyState' import ScenarioState from './ScenarioState' -import { usePortfolio, usePortfolioScenarios } from '../../data/project' -import { useProjectTopologies } from '../../data/topology' -import { useMutation } from 'react-query' +import { useDeleteScenario } from '../../data/project' -const ScenarioTable = ({ portfolioId }) => { - const { data: portfolio } = usePortfolio(portfolioId) - const { status, data: scenarios = [] } = usePortfolioScenarios(portfolioId) - const { data: topologies } = useProjectTopologies(portfolio?.projectId, { - select: (topologies) => new Map(topologies.map((topology) => [topology._id, topology])), - }) - - const { mutate: deleteScenario } = useMutation('deleteScenario') +function ScenarioTable({ portfolio, status }) { + const { mutate: deleteScenario } = useDeleteScenario() + const projectId = portfolio?.project?.id + const scenarios = portfolio?.scenarios ?? [] const columns = ['Name', 'Topology', 'Trace', 'State'] const rows = scenarios.length > 0 ? scenarios.map((scenario) => { - const topology = topologies?.get(scenario.topology.topologyId) + const topology = scenario.topology return [ scenario.name, { title: topology ? ( - <Link href={`/projects/${topology.projectId}/topologies/${topology._id}`}> + <Link href={`/projects/${projectId}/topologies/${topology.number}`}> <a>{topology.name}</a> </Link> ) : ( 'Unknown Topology' ), }, - scenario.trace.traceId, - { title: <ScenarioState state={scenario.simulation.state} /> }, + `${scenario.workload.trace.name} (${scenario.workload.samplingFraction * 100}%)`, + { title: <ScenarioState state={scenario.job.state} /> }, ] }) : [ @@ -82,7 +76,7 @@ const ScenarioTable = ({ portfolioId }) => { const actionResolver = (_, { rowIndex }) => [ { title: 'Delete Scenario', - onClick: (_, rowId) => deleteScenario(scenarios[rowId]._id), + onClick: (_, rowId) => deleteScenario({ projectId: projectId, number: scenarios[rowId].number }), isDisabled: rowIndex === 0, }, ] @@ -102,7 +96,8 @@ const ScenarioTable = ({ portfolioId }) => { } ScenarioTable.propTypes = { - portfolioId: PropTypes.string, + portfolio: Portfolio, + status: Status.isRequired, } export default ScenarioTable diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewPortfolio.js b/opendc-web/opendc-web-ui/src/components/projects/NewPortfolio.js index 87ea059d..aebcc3c9 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/NewPortfolio.js +++ b/opendc-web/opendc-web-ui/src/components/projects/NewPortfolio.js @@ -24,12 +24,12 @@ import PropTypes from 'prop-types' import { PlusIcon } from '@patternfly/react-icons' import { Button } from '@patternfly/react-core' import { useState } from 'react' -import { useMutation } from 'react-query' +import { useNewPortfolio } from '../../data/project' import NewPortfolioModal from './NewPortfolioModal' function NewPortfolio({ projectId }) { const [isVisible, setVisible] = useState(false) - const { mutate: addPortfolio } = useMutation('addPortfolio') + const { mutate: addPortfolio } = useNewPortfolio() const onSubmit = (name, targets) => { addPortfolio({ projectId, name, targets }) @@ -47,7 +47,7 @@ function NewPortfolio({ projectId }) { } NewPortfolio.propTypes = { - projectId: PropTypes.string, + projectId: PropTypes.number, } export default NewPortfolio diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewPortfolioModal.js b/opendc-web/opendc-web-ui/src/components/projects/NewPortfolioModal.js index 4276d7d4..ba4bc819 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/NewPortfolioModal.js +++ b/opendc-web/opendc-web-ui/src/components/projects/NewPortfolioModal.js @@ -67,7 +67,7 @@ const NewPortfolioModal = ({ isOpen, onSubmit: onSubmitUpstream, onCancel: onUps setErrors({ name: true }) return false } else { - onSubmitUpstream(name, { enabledMetrics: selectedMetrics, repeatsPerScenario: repeats }) + onSubmitUpstream(name, { metrics: selectedMetrics, repeats }) } clearState() diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewProject.js b/opendc-web/opendc-web-ui/src/components/projects/NewProject.js index 984264dc..bfa7c01a 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/NewProject.js +++ b/opendc-web/opendc-web-ui/src/components/projects/NewProject.js @@ -1,7 +1,7 @@ import React, { useState } from 'react' import { Button } from '@patternfly/react-core' -import { useMutation } from 'react-query' import { PlusIcon } from '@patternfly/react-icons' +import { useNewProject } from '../../data/project' import { buttonContainer } from './NewProject.module.scss' import TextInputModal from '../util/modals/TextInputModal' @@ -10,7 +10,7 @@ import TextInputModal from '../util/modals/TextInputModal' */ const NewProject = () => { const [isVisible, setVisible] = useState(false) - const { mutate: addProject } = useMutation('addProject') + const { mutate: addProject } = useNewProject() const onSubmit = (name) => { if (name) { diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewTopology.js b/opendc-web/opendc-web-ui/src/components/projects/NewTopology.js index 77c57d26..4c569c56 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/NewTopology.js +++ b/opendc-web/opendc-web-ui/src/components/projects/NewTopology.js @@ -21,25 +21,17 @@ */ import PropTypes from 'prop-types' -import produce from 'immer' import { PlusIcon } from '@patternfly/react-icons' import { Button } from '@patternfly/react-core' import { useState } from 'react' -import { useMutation } from "react-query"; -import { useProjectTopologies } from "../../data/topology"; +import { useNewTopology } from '../../data/topology' import NewTopologyModal from './NewTopologyModal' function NewTopology({ projectId }) { const [isVisible, setVisible] = useState(false) - const { data: topologies = [] } = useProjectTopologies(projectId) - const { mutate: addTopology } = useMutation('addTopology') + const { mutate: addTopology } = useNewTopology() - const onSubmit = (name, duplicateId) => { - const candidate = topologies.find((topology) => topology._id === duplicateId) || { projectId, rooms: [] } - const topology = produce(candidate, (draft) => { - delete draft._id - draft.name = name - }) + const onSubmit = (topology) => { addTopology(topology) setVisible(false) } @@ -59,7 +51,7 @@ function NewTopology({ projectId }) { } NewTopology.propTypes = { - projectId: PropTypes.string, + projectId: PropTypes.number, } export default NewTopology diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js b/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js index a495f73e..be4256e3 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js +++ b/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js @@ -20,10 +20,11 @@ * SOFTWARE. */ +import produce from 'immer' import PropTypes from 'prop-types' import React, { useRef, useState } from 'react' import { Form, FormGroup, FormSelect, FormSelectOption, TextInput } from '@patternfly/react-core' -import { useProjectTopologies } from '../../data/topology' +import { useTopologies } from '../../data/topology' import Modal from '../util/modals/Modal' const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCancel: onCancelUpstream }) => { @@ -32,10 +33,12 @@ const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCan const [originTopology, setOriginTopology] = useState(-1) const [errors, setErrors] = useState({}) - const { data: topologies = [] } = useProjectTopologies(projectId) + const { data: topologies = [] } = useTopologies(projectId, { enabled: isOpen }) const clearState = () => { - nameInput.current.value = '' + if (nameInput.current) { + nameInput.current.value = '' + } setSubmitted(false) setOriginTopology(-1) setErrors({}) @@ -53,10 +56,13 @@ const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCan if (!name) { setErrors({ name: true }) return false - } else if (originTopology === -1) { - onSubmitUpstream(name) } else { - onSubmitUpstream(name, originTopology) + const candidate = topologies.find((topology) => topology.id === originTopology) || { projectId, rooms: [] } + const topology = produce(candidate, (draft) => { + delete draft.id + draft.name = name + }) + onSubmitUpstream(topology) } clearState() @@ -84,7 +90,7 @@ const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCan <FormSelect id="origin" name="origin" value={originTopology} onChange={setOriginTopology}> <FormSelectOption value={-1} key={-1} label="None - start from scratch" /> {topologies.map((topology) => ( - <FormSelectOption value={topology._id} key={topology._id} label={topology.name} /> + <FormSelectOption value={topology.id} key={topology.id} label={topology.name} /> ))} </FormSelect> </FormGroup> @@ -94,7 +100,7 @@ const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCan } NewTopologyModal.propTypes = { - projectId: PropTypes.string, + projectId: PropTypes.number, isOpen: PropTypes.bool.isRequired, onSubmit: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired, diff --git a/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js b/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js index 45e399ed..aa679843 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js +++ b/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js @@ -25,12 +25,11 @@ import Link from 'next/link' import { Table, TableBody, TableHeader } from '@patternfly/react-table' import React from 'react' import TableEmptyState from '../util/TableEmptyState' -import { useProjectPortfolios } from '../../data/project' -import { useMutation } from 'react-query' +import { usePortfolios, useDeletePortfolio } from '../../data/project' const PortfolioTable = ({ projectId }) => { - const { status, data: portfolios = [] } = useProjectPortfolios(projectId) - const { mutate: deletePortfolio } = useMutation('deletePortfolio') + const { status, data: portfolios = [] } = usePortfolios(projectId) + const { mutate: deletePortfolio } = useDeletePortfolio() const columns = ['Name', 'Scenarios', 'Metrics', 'Repeats'] const rows = @@ -38,20 +37,12 @@ const PortfolioTable = ({ projectId }) => { ? portfolios.map((portfolio) => [ { title: ( - <Link href={`/projects/${portfolio.projectId}/portfolios/${portfolio._id}`}> - {portfolio.name} - </Link> + <Link href={`/projects/${projectId}/portfolios/${portfolio.number}`}>{portfolio.name}</Link> ), }, - - portfolio.scenarioIds.length === 1 ? '1 scenario' : `${portfolio.scenarioIds.length} scenarios`, - - portfolio.targets.enabledMetrics.length === 1 - ? '1 metric' - : `${portfolio.targets.enabledMetrics.length} metrics`, - portfolio.targets.repeatsPerScenario === 1 - ? '1 repeat' - : `${portfolio.targets.repeatsPerScenario} repeats`, + portfolio.scenarios.length === 1 ? '1 scenario' : `${portfolio.scenarios.length} scenarios`, + portfolio.targets.metrics.length === 1 ? '1 metric' : `${portfolio.targets.metrics.length} metrics`, + portfolio.targets.repeats === 1 ? '1 repeat' : `${portfolio.targets.repeats} repeats`, ]) : [ { @@ -77,7 +68,7 @@ const PortfolioTable = ({ projectId }) => { ? [ { title: 'Delete Portfolio', - onClick: (_, rowId) => deletePortfolio(portfolios[rowId]._id), + onClick: (_, rowId) => deletePortfolio({ projectId, number: portfolios[rowId].number }), }, ] : [] @@ -91,7 +82,7 @@ const PortfolioTable = ({ projectId }) => { } PortfolioTable.propTypes = { - projectId: PropTypes.string, + projectId: PropTypes.number, } export default PortfolioTable diff --git a/opendc-web/opendc-web-ui/src/components/projects/ProjectOverview.js b/opendc-web/opendc-web-ui/src/components/projects/ProjectOverview.js index 65b8f5a0..3e1656f6 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/ProjectOverview.js +++ b/opendc-web/opendc-web-ui/src/components/projects/ProjectOverview.js @@ -92,7 +92,7 @@ function ProjectOverview({ projectId }) { } ProjectOverview.propTypes = { - projectId: PropTypes.string, + projectId: PropTypes.number, } export default ProjectOverview diff --git a/opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js b/opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js index a7290259..6921578c 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js +++ b/opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js @@ -5,26 +5,23 @@ import { Project, Status } from '../../shapes' import { Table, TableBody, TableHeader } from '@patternfly/react-table' import { parseAndFormatDateTime } from '../../util/date-time' import { AUTH_DESCRIPTION_MAP, AUTH_ICON_MAP } from '../../util/authorizations' -import { useAuth } from '../../auth' import TableEmptyState from '../util/TableEmptyState' const ProjectTable = ({ status, projects, onDelete, isFiltering }) => { - const { user } = useAuth() const columns = ['Project name', 'Last edited', 'Access Rights'] const rows = projects.length > 0 ? projects.map((project) => { - const { level } = project.authorizations.find((auth) => auth.userId === user.sub) - const Icon = AUTH_ICON_MAP[level] + const Icon = AUTH_ICON_MAP[project.role] return [ { - title: <Link href={`/projects/${project._id}`}>{project.name}</Link>, + title: <Link href={`/projects/${project.id}`}>{project.name}</Link>, }, - parseAndFormatDateTime(project.datetimeLastEdited), + parseAndFormatDateTime(project.updatedAt), { title: ( <> - <Icon className="pf-u-mr-md" key="auth" /> {AUTH_DESCRIPTION_MAP[level]} + <Icon className="pf-u-mr-md" key="auth" /> {AUTH_DESCRIPTION_MAP[project.role]} </> ), }, diff --git a/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js b/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js index 80099ece..ced5304a 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js +++ b/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js @@ -26,26 +26,21 @@ import { Table, TableBody, TableHeader } from '@patternfly/react-table' import React from 'react' import TableEmptyState from '../util/TableEmptyState' import { parseAndFormatDateTime } from '../../util/date-time' -import { useMutation } from 'react-query' -import { useProjectTopologies } from '../../data/topology' +import { useTopologies, useDeleteTopology } from '../../data/topology' const TopologyTable = ({ projectId }) => { - const { status, data: topologies = [] } = useProjectTopologies(projectId) - const { mutate: deleteTopology } = useMutation('deleteTopology') + const { status, data: topologies = [] } = useTopologies(projectId) + const { mutate: deleteTopology } = useDeleteTopology() const columns = ['Name', 'Rooms', 'Last Edited'] const rows = topologies.length > 0 ? topologies.map((topology) => [ { - title: ( - <Link href={`/projects/${topology.projectId}/topologies/${topology._id}`}> - {topology.name} - </Link> - ), + title: <Link href={`/projects/${projectId}/topologies/${topology.number}`}>{topology.name}</Link>, }, topology.rooms.length === 1 ? '1 room' : `${topology.rooms.length} rooms`, - parseAndFormatDateTime(topology.datetimeLastEdited), + parseAndFormatDateTime(topology.updatedAt), ]) : [ { @@ -69,7 +64,7 @@ const TopologyTable = ({ projectId }) => { const actionResolver = (_, { rowIndex }) => [ { title: 'Delete Topology', - onClick: (_, rowId) => deleteTopology(topologies[rowId]._id), + onClick: (_, rowId) => deleteTopology({ projectId, number: topologies[rowId].number }), isDisabled: rowIndex === 0, }, ] @@ -89,7 +84,7 @@ const TopologyTable = ({ projectId }) => { } TopologyTable.propTypes = { - projectId: PropTypes.string, + projectId: PropTypes.number, } export default TopologyTable diff --git a/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js b/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js index 9bf369e9..49e5f095 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js @@ -7,11 +7,11 @@ import { Table, TableBody, TableHeader } from '@patternfly/react-table' import { deleteRoom } from '../../redux/actions/topology/room' import TableEmptyState from '../util/TableEmptyState' -function RoomTable({ topologyId, onSelect }) { +function RoomTable({ projectId, topologyId, onSelect }) { const dispatch = useDispatch() - const { status, data: topology } = useTopology(topologyId) + const { status, data: topology } = useTopology(projectId, topologyId) - const onDelete = (room) => dispatch(deleteRoom(room._id)) + const onDelete = (room) => dispatch(deleteRoom(room.id)) const columns = ['Name', 'Tiles', 'Racks'] const rows = @@ -62,7 +62,8 @@ function RoomTable({ topologyId, onSelect }) { } RoomTable.propTypes = { - topologyId: PropTypes.string, + projectId: PropTypes.number, + topologyId: PropTypes.number, onSelect: PropTypes.func, } diff --git a/opendc-web/opendc-web-ui/src/components/topologies/TopologyOverview.js b/opendc-web/opendc-web-ui/src/components/topologies/TopologyOverview.js index 213a4868..f8ee4990 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/TopologyOverview.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/TopologyOverview.js @@ -38,8 +38,8 @@ import { useTopology } from '../../data/topology' import { parseAndFormatDateTime } from '../../util/date-time' import RoomTable from './RoomTable' -function TopologyOverview({ topologyId, onSelect }) { - const { data: topology } = useTopology(topologyId) +function TopologyOverview({ projectId, topologyNumber, onSelect }) { + const { data: topology } = useTopology(projectId, topologyNumber) return ( <Grid hasGutter> <GridItem md={2}> @@ -57,7 +57,7 @@ function TopologyOverview({ topologyId, onSelect }) { <DescriptionListTerm>Last edited</DescriptionListTerm> <DescriptionListDescription> {topology ? ( - parseAndFormatDateTime(topology.datetimeLastEdited) + parseAndFormatDateTime(topology.updatedAt) ) : ( <Skeleton screenreaderText="Loading topology" /> )} @@ -71,7 +71,11 @@ function TopologyOverview({ topologyId, onSelect }) { <Card> <CardTitle>Rooms</CardTitle> <CardBody> - <RoomTable topologyId={topologyId} onSelect={(room) => onSelect('room', room)} /> + <RoomTable + projectId={projectId} + topologyId={topologyNumber} + onSelect={(room) => onSelect('room', room)} + /> </CardBody> </Card> </GridItem> @@ -80,7 +84,8 @@ function TopologyOverview({ topologyId, onSelect }) { } TopologyOverview.propTypes = { - topologyId: PropTypes.string, + projectId: PropTypes.number, + topologyNumber: PropTypes.number, onSelect: PropTypes.func, } diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js index 411a5ca7..21be3c79 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js @@ -33,7 +33,7 @@ function TileContainer({ tileId, ...props }) { const dispatch = useDispatch() const onClick = (tile) => { if (tile.rack) { - dispatch(goFromRoomToRack(tile._id)) + dispatch(goFromRoomToRack(tile.id)) } } return <TileGroup {...props} onClick={onClick} tile={tile} interactionLevel={interactionLevel} /> diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/elements/ImageComponent.js b/opendc-web/opendc-web-ui/src/components/topologies/map/elements/ImageComponent.js index 7d304b6b..fdae53f2 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/map/elements/ImageComponent.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/map/elements/ImageComponent.js @@ -21,6 +21,7 @@ function ImageComponent({ src, x, y, width, height, opacity }) { } }, [src]) + // eslint-disable-next-line jsx-a11y/alt-text return <Image image={image} x={x} y={y} width={width} height={height} opacity={opacity} /> } diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js b/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js index 46030135..dad2d62d 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js @@ -11,8 +11,8 @@ function RackGroup({ tile }) { <Group> <TileObject positionX={tile.positionX} positionY={tile.positionY} color={RACK_BACKGROUND_COLOR} /> <Group> - <RackSpaceFillContainer tileId={tile._id} positionX={tile.positionX} positionY={tile.positionY} /> - <RackEnergyFillContainer tileId={tile._id} positionX={tile.positionX} positionY={tile.positionY} /> + <RackSpaceFillContainer tileId={tile.id} positionX={tile.positionX} positionY={tile.positionY} /> + <RackEnergyFillContainer tileId={tile.id} positionX={tile.positionX} positionY={tile.positionY} /> </Group> </Group> ) diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RoomGroup.js b/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RoomGroup.js index a42e7bb7..3f8b3089 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RoomGroup.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RoomGroup.js @@ -7,7 +7,7 @@ import TileContainer from '../TileContainer' import WallContainer from '../WallContainer' function RoomGroup({ room, interactionLevel, currentRoomInConstruction, onClick }) { - if (currentRoomInConstruction === room._id) { + if (currentRoomInConstruction === room.id) { return ( <Group onClick={onClick}> {room.tiles.map((tileId) => ( @@ -22,7 +22,7 @@ function RoomGroup({ room, interactionLevel, currentRoomInConstruction, onClick {(() => { if ( (interactionLevel.mode === 'RACK' || interactionLevel.mode === 'MACHINE') && - interactionLevel.roomId === room._id + interactionLevel.roomId === room.id ) { return [ room.tiles @@ -37,7 +37,7 @@ function RoomGroup({ room, interactionLevel, currentRoomInConstruction, onClick return room.tiles.map((tileId) => <TileContainer key={tileId} tileId={tileId} />) } })()} - <WallContainer roomId={room._id} /> + <WallContainer roomId={room.id} /> </Group> ) } diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/layers/RoomHoverLayer.js b/opendc-web/opendc-web-ui/src/components/topologies/map/layers/RoomHoverLayer.js index 5e351691..727f4e25 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/map/layers/RoomHoverLayer.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/map/layers/RoomHoverLayer.js @@ -40,8 +40,8 @@ function RoomHoverLayer() { .map((id) => ({ ...state.topology.rooms[id] })) .filter( (room) => - state.topology.root.rooms.indexOf(room._id) !== -1 && - room._id !== state.construction.currentRoomInConstruction + state.topology.root.rooms.indexOf(room.id) !== -1 && + room.id !== state.construction.currentRoomInConstruction ) ;[...oldRooms, newRoom].forEach((room) => { diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/MachineSidebar.js b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/MachineSidebar.js index 9268f615..6f89e10b 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/MachineSidebar.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/MachineSidebar.js @@ -17,7 +17,7 @@ function MachineSidebar({ tileId, position }) { const rack = topology.racks[topology.tiles[tileId].rack] return topology.machines[rack.machines[position - 1]] }) - const machineId = machine._id + const machineId = machine.id return ( <div> <TextContent> diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitAddComponent.js b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitAddComponent.js index 88591208..4507b409 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitAddComponent.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitAddComponent.js @@ -22,7 +22,7 @@ function UnitAddComponent({ units, onAdd }) { selections={selected} > {units.map((unit) => ( - <SelectOption value={unit._id} key={unit._id}> + <SelectOption value={unit.id} key={unit.id}> {unit.name} </SelectOption> ))} diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitListContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitListContainer.js index 6dcc414f..25e750c4 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitListContainer.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitListContainer.js @@ -33,7 +33,7 @@ function UnitListContainer({ machineId, unitType }) { return machine[unitType].map((id) => state.topology[unitType][id]) }) - const onDelete = (unit) => dispatch(deleteUnit(machineId, unitType, unit._id)) + const onDelete = (unit) => dispatch(deleteUnit(machineId, unitType, unit.id)) return <UnitListComponent units={units} unitType={unitType} onDelete={onDelete} /> } diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/AddPrefab.js b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/AddPrefab.js index e944c2e8..6a0c3ff3 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/AddPrefab.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/AddPrefab.js @@ -22,14 +22,11 @@ import PropTypes from 'prop-types' import React from 'react' -import { useDispatch } from 'react-redux' import { Button } from '@patternfly/react-core' import { SaveIcon } from '@patternfly/react-icons' -import { addPrefab } from '../../../../api/prefabs' -function AddPrefab({ tileId }) { - const dispatch = useDispatch() - const onClick = () => dispatch(addPrefab('name', tileId)) +function AddPrefab() { + const onClick = () => {} // TODO return ( <Button variant="primary" icon={<SaveIcon />} isBlock onClick={onClick} className="pf-u-mb-sm"> Save this rack to a prefab diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/MachineListContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/MachineListContainer.js index 619bb4e2..e1914730 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/MachineListContainer.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/MachineListContainer.js @@ -43,7 +43,7 @@ function MachineListContainer({ tileId, ...props }) { <MachineListComponent {...props} machines={machinesNull} - onAdd={(index) => dispatch(addMachine(rack._id, index))} + onAdd={(index) => dispatch(addMachine(rack.id, index))} onSelect={(index) => dispatch(goFromRackToMachine(index))} /> ) diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackNameContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackNameContainer.js index 30f38cce..c3422318 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackNameContainer.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackNameContainer.js @@ -5,11 +5,11 @@ import NameComponent from '../NameComponent' import { editRackName } from '../../../../redux/actions/topology/rack' const RackNameContainer = ({ tileId }) => { - const { name: rackName, _id } = useSelector((state) => state.topology.racks[state.topology.tiles[tileId].rack]) + const { name: rackName, id } = useSelector((state) => state.topology.racks[state.topology.tiles[tileId].rack]) const dispatch = useDispatch() const callback = (name) => { if (name) { - dispatch(editRackName(_id, name)) + dispatch(editRackName(id, name)) } } return <NameComponent name={rackName} onEdit={callback} /> diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/room/RoomName.js b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/room/RoomName.js index fb52d826..72d45bea 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/room/RoomName.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/room/RoomName.js @@ -27,11 +27,11 @@ import NameComponent from '../NameComponent' import { editRoomName } from '../../../../redux/actions/topology/room' function RoomName({ roomId }) { - const { name: roomName, _id } = useSelector((state) => state.topology.rooms[roomId]) + const { name: roomName, id } = useSelector((state) => state.topology.rooms[roomId]) const dispatch = useDispatch() const callback = (name) => { if (name) { - dispatch(editRoomName(_id, name)) + dispatch(editRoomName(id, name)) } } return <NameComponent name={roomName} onEdit={callback} /> diff --git a/opendc-web/opendc-web-ui/src/data/experiments.js b/opendc-web/opendc-web-ui/src/data/experiments.js index a76ea53f..ca8912a2 100644 --- a/opendc-web/opendc-web-ui/src/data/experiments.js +++ b/opendc-web/opendc-web-ui/src/data/experiments.js @@ -35,13 +35,13 @@ export function configureExperimentClient(queryClient, auth) { /** * Return the available traces to experiment with. */ -export function useTraces() { - return useQuery('traces') +export function useTraces(options) { + return useQuery('traces', options) } /** * Return the available schedulers to experiment with. */ -export function useSchedulers() { - return useQuery('schedulers') +export function useSchedulers(options) { + return useQuery('schedulers', options) } diff --git a/opendc-web/opendc-web-ui/src/data/project.js b/opendc-web/opendc-web-ui/src/data/project.js index 9dcd8532..b1db3da5 100644 --- a/opendc-web/opendc-web-ui/src/data/project.js +++ b/opendc-web/opendc-web-ui/src/data/project.js @@ -20,9 +20,9 @@ * SOFTWARE. */ -import { useQuery } from 'react-query' +import { useQuery, useMutation } from 'react-query' import { addProject, deleteProject, fetchProject, fetchProjects } from '../api/projects' -import { addPortfolio, deletePortfolio, fetchPortfolio, fetchPortfoliosOfProject } from '../api/portfolios' +import { addPortfolio, deletePortfolio, fetchPortfolio, fetchPortfolios } from '../api/portfolios' import { addScenario, deleteScenario, fetchScenario, fetchScenariosOfPortfolio } from '../api/scenarios' /** @@ -37,79 +37,60 @@ export function configureProjectClient(queryClient, auth) { mutationFn: (data) => addProject(auth, data), onSuccess: async (result) => { queryClient.setQueryData('projects', (old = []) => [...old, result]) - queryClient.setQueryData(['projects', result._id], result) + queryClient.setQueryData(['projects', result.id], result) }, }) queryClient.setMutationDefaults('deleteProject', { mutationFn: (id) => deleteProject(auth, id), onSuccess: async (result) => { - queryClient.setQueryData('projects', (old = []) => old.filter((project) => project._id !== result._id)) - queryClient.removeQueries(['projects', result._id]) + queryClient.setQueryData('projects', (old = []) => old.filter((project) => project.id !== result.id)) + queryClient.removeQueries(['projects', result.id]) }, }) queryClient.setQueryDefaults('portfolios', { - queryFn: ({ queryKey }) => fetchPortfolio(auth, queryKey[1]), - }) - queryClient.setQueryDefaults('project-portfolios', { - queryFn: ({ queryKey }) => fetchPortfoliosOfProject(auth, queryKey[1]), + queryFn: ({ queryKey }) => + queryKey.length === 2 ? fetchPortfolios(auth, queryKey[1]) : fetchPortfolio(auth, queryKey[1], queryKey[2]), }) queryClient.setMutationDefaults('addPortfolio', { - mutationFn: (data) => addPortfolio(auth, data), + mutationFn: ({ projectId, ...data }) => addPortfolio(auth, projectId, data), onSuccess: async (result) => { - queryClient.setQueryData(['projects', result.projectId], (old) => ({ - ...old, - portfolioIds: [...old.portfolioIds, result._id], - })) - queryClient.setQueryData(['project-portfolios', result.projectId], (old = []) => [...old, result]) - queryClient.setQueryData(['portfolios', result._id], result) + queryClient.setQueryData(['portfolios', result.project.id], (old = []) => [...old, result]) + queryClient.setQueryData(['portfolios', result.project.id, result.number], result) }, }) queryClient.setMutationDefaults('deletePortfolio', { - mutationFn: (id) => deletePortfolio(auth, id), + mutationFn: ({ projectId, number }) => deletePortfolio(auth, projectId, number), onSuccess: async (result) => { - queryClient.setQueryData(['projects', result.projectId], (old) => ({ - ...old, - portfolioIds: old.portfolioIds.filter((id) => id !== result._id), - })) - queryClient.setQueryData(['project-portfolios', result.projectId], (old = []) => - old.filter((portfolio) => portfolio._id !== result._id) + queryClient.setQueryData(['portfolios', result.project.id], (old = []) => + old.filter((portfolio) => portfolio.id !== result.id) ) - queryClient.removeQueries(['portfolios', result._id]) + queryClient.removeQueries(['portfolios', result.project.id, result.number]) }, }) queryClient.setQueryDefaults('scenarios', { - queryFn: ({ queryKey }) => fetchScenario(auth, queryKey[1]), - }) - queryClient.setQueryDefaults('portfolio-scenarios', { - queryFn: ({ queryKey }) => fetchScenariosOfPortfolio(auth, queryKey[1]), + queryFn: ({ queryKey }) => fetchScenario(auth, queryKey[1], queryKey[2]), }) queryClient.setMutationDefaults('addScenario', { - mutationFn: (data) => addScenario(auth, data), + mutationFn: ({ projectId, portfolioNumber, data }) => addScenario(auth, projectId, portfolioNumber, data), onSuccess: async (result) => { // Register updated scenario in cache - queryClient.setQueryData(['scenarios', result._id], result) - queryClient.setQueryData(['portfolio-scenarios', result.portfolioId], (old = []) => [...old, result]) - - // Add scenario id to portfolio - queryClient.setQueryData(['portfolios', result.portfolioId], (old) => ({ + queryClient.setQueryData(['scenarios', result.project.id, result.id], result) + queryClient.setQueryData(['portfolios', result.project.id, result.portfolio.number], (old) => ({ ...old, - scenarioIds: [...old.scenarioIds, result._id], + scenarios: [...old.scenarios, result], })) }, }) queryClient.setMutationDefaults('deleteScenario', { - mutationFn: (id) => deleteScenario(auth, id), + mutationFn: ({ projectId, number }) => deleteScenario(auth, projectId, number), onSuccess: async (result) => { - queryClient.setQueryData(['portfolios', result.portfolioId], (old) => ({ + queryClient.removeQueries(['scenarios', result.project.id, result.id]) + queryClient.setQueryData(['portfolios', result.project.id, result.portfolio.number], (old) => ({ ...old, - scenarioIds: old.scenarioIds.filter((id) => id !== result._id), + scenarios: old?.scenarios?.filter((scenario) => scenario.id !== result.id), })) - queryClient.setQueryData(['portfolio-scenarios', result.portfolioId], (old = []) => - old.filter((scenario) => scenario._id !== result._id) - ) - queryClient.removeQueries(['scenarios', result._id]) }, }) } @@ -129,22 +110,57 @@ export function useProject(projectId, options = {}) { } /** + * Create a mutation for a new project. + */ +export function useNewProject() { + return useMutation('addProject') +} + +/** + * Create a mutation for deleting a project. + */ +export function useDeleteProject() { + return useMutation('deleteProject') +} + +/** * Return the portfolio with the specified identifier. */ -export function usePortfolio(portfolioId, options = {}) { - return useQuery(['portfolios', portfolioId], { enabled: !!portfolioId, ...options }) +export function usePortfolio(projectId, portfolioId, options = {}) { + return useQuery(['portfolios', projectId, portfolioId], { enabled: !!(projectId && portfolioId), ...options }) } /** * Return the portfolios of the specified project. */ -export function useProjectPortfolios(projectId, options = {}) { - return useQuery(['project-portfolios', projectId], { enabled: !!projectId, ...options }) +export function usePortfolios(projectId, options = {}) { + return useQuery(['portfolios', projectId], { enabled: !!projectId, ...options }) +} + +/** + * Create a mutation for a new portfolio. + */ +export function useNewPortfolio() { + return useMutation('addPortfolio') +} + +/** + * Create a mutation for deleting a portfolio. + */ +export function useDeletePortfolio() { + return useMutation('deletePortfolio') +} + +/** + * Create a mutation for a new scenario. + */ +export function useNewScenario() { + return useMutation('addScenario') } /** - * Return the scenarios of the specified portfolio. + * Create a mutation for deleting a scenario. */ -export function usePortfolioScenarios(portfolioId, options = {}) { - return useQuery(['portfolio-scenarios', portfolioId], { enabled: !!portfolioId, ...options }) +export function useDeleteScenario() { + return useMutation('deleteScenario') } diff --git a/opendc-web/opendc-web-ui/src/data/topology.js b/opendc-web/opendc-web-ui/src/data/topology.js index e068ed8e..cf098c56 100644 --- a/opendc-web/opendc-web-ui/src/data/topology.js +++ b/opendc-web/opendc-web-ui/src/data/topology.js @@ -20,58 +20,69 @@ * SOFTWARE. */ -import { useQuery } from 'react-query' -import { addTopology, deleteTopology, fetchTopologiesOfProject, fetchTopology, updateTopology } from '../api/topologies' +import { useQuery, useMutation } from 'react-query' +import { addTopology, deleteTopology, fetchTopologies, fetchTopology, updateTopology } from '../api/topologies' /** * Configure the query defaults for the topology endpoints. */ export function configureTopologyClient(queryClient, auth) { - queryClient.setQueryDefaults('topologies', { queryFn: ({ queryKey }) => fetchTopology(auth, queryKey[1]) }) - queryClient.setQueryDefaults('project-topologies', { - queryFn: ({ queryKey }) => fetchTopologiesOfProject(auth, queryKey[1]), + queryClient.setQueryDefaults('topologies', { + queryFn: ({ queryKey }) => + queryKey.length === 2 ? fetchTopologies(auth, queryKey[1]) : fetchTopology(auth, queryKey[1], queryKey[2]), }) queryClient.setMutationDefaults('addTopology', { - mutationFn: (data) => addTopology(auth, data), - onSuccess: async (result) => { - queryClient.setQueryData(['projects', result.projectId], (old) => ({ - ...old, - topologyIds: [...old.topologyIds, result._id], - })) - queryClient.setQueryData(['project-topologies', result.projectId], (old = []) => [...old, result]) - queryClient.setQueryData(['topologies', result._id], result) + mutationFn: ({ projectId, ...data }) => addTopology(auth, projectId, data), + onSuccess: (result) => { + queryClient.setQueryData(['topologies', result.project.id], (old = []) => [...old, result]) + queryClient.setQueryData(['topologies', result.project.id, result.number], result) }, }) queryClient.setMutationDefaults('updateTopology', { mutationFn: (data) => updateTopology(auth, data), - onSuccess: (result) => queryClient.setQueryData(['topologies', result._id], result), + onSuccess: (result) => { + queryClient.setQueryData(['topologies', result.project.id], (old = []) => + old.map((topology) => (topology.id === result.id ? result : topology)) + ) + queryClient.setQueryData(['topologies', result.project.id, result.number], result) + }, }) queryClient.setMutationDefaults('deleteTopology', { - mutationFn: (id) => deleteTopology(auth, id), - onSuccess: async (result) => { - queryClient.setQueryData(['projects', result.projectId], (old) => ({ - ...old, - topologyIds: old.topologyIds.filter((id) => id !== result._id), - })) - queryClient.setQueryData(['project-topologies', result.projectId], (old = []) => - old.filter((topology) => topology._id !== result._id) + mutationFn: ({ projectId, id }) => deleteTopology(auth, projectId, id), + onSuccess: (result) => { + queryClient.setQueryData(['topologies', result.project.id], (old = []) => + old.filter((topology) => topology.id !== result.id) ) - queryClient.removeQueries(['topologies', result._id]) + queryClient.removeQueries(['topologies', result.project.id, result.number]) }, }) } /** - * Return the current active topology. + * Fetch the topology with the specified identifier for the specified project. + */ +export function useTopology(projectId, topologyId, options = {}) { + return useQuery(['topologies', projectId, topologyId], { enabled: !!(projectId && topologyId), ...options }) +} + +/** + * Fetch all topologies of the specified project. + */ +export function useTopologies(projectId, options = {}) { + return useQuery(['topologies', projectId], { enabled: !!projectId, ...options }) +} + +/** + * Create a mutation for a new topology. */ -export function useTopology(topologyId, options = {}) { - return useQuery(['topologies', topologyId], { enabled: !!topologyId, ...options }) +export function useNewTopology() { + return useMutation('addTopology') } /** - * Return the topologies of the specified project. + * Create a mutation for deleting a topology. */ -export function useProjectTopologies(projectId, options = {}) { - return useQuery(['project-topologies', projectId], { enabled: !!projectId, ...options }) +export function useDeleteTopology() { + return useMutation('deleteTopology') } diff --git a/opendc-web/opendc-web-ui/src/pages/_app.js b/opendc-web/opendc-web-ui/src/pages/_app.js index 900ff405..4861f5c1 100644 --- a/opendc-web/opendc-web-ui/src/pages/_app.js +++ b/opendc-web/opendc-web-ui/src/pages/_app.js @@ -22,6 +22,7 @@ import PropTypes from 'prop-types' import Head from 'next/head' +import Script from 'next/script' import { Provider } from 'react-redux' import { useNewQueryClient } from '../data/query' import { useStore } from '../redux' @@ -91,6 +92,19 @@ export default function App(props) { <Inner {...props} /> </AuthProvider> </Sentry.ErrorBoundary> + {/* Google Analytics */} + <Script async src="https://www.googletagmanager.com/gtag/js?id=UA-84285092-3" /> + <Script + id="gtag" + dangerouslySetInnerHTML={{ + __html: ` + window.dataLayer = window.dataLayer || []; + function gtag(){dataLayer.push(arguments);} + gtag('js', new Date()); + gtag('config', 'UA-84285092-3'); + `, + }} + /> </> ) } diff --git a/opendc-web/opendc-web-ui/src/pages/_document.js b/opendc-web/opendc-web-ui/src/pages/_document.js index 51d8d3e0..011bf4da 100644 --- a/opendc-web/opendc-web-ui/src/pages/_document.js +++ b/opendc-web/opendc-web-ui/src/pages/_document.js @@ -69,19 +69,6 @@ class OpenDCDocument extends Document { href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet" /> - - {/* Google Analytics */} - <script async src="https://www.googletagmanager.com/gtag/js?id=UA-84285092-3" /> - <script - dangerouslySetInnerHTML={{ - __html: ` - window.dataLayer = window.dataLayer || []; - function gtag(){dataLayer.push(arguments);} - gtag('js', new Date()); - gtag('config', 'UA-84285092-3'); - `, - }} - /> </Head> <body> <Main /> diff --git a/opendc-web/opendc-web-ui/src/pages/projects/[project]/index.js b/opendc-web/opendc-web-ui/src/pages/projects/[project]/index.js index c07a2c31..39fcb4f3 100644 --- a/opendc-web/opendc-web-ui/src/pages/projects/[project]/index.js +++ b/opendc-web/opendc-web-ui/src/pages/projects/[project]/index.js @@ -40,9 +40,9 @@ import BreadcrumbLink from '../../../components/util/BreadcrumbLink' function Project() { const router = useRouter() - const { project: projectId } = router.query + const projectId = +router.query['project'] - const { data: project } = useProject(projectId) + const { data: project } = useProject(+projectId) const breadcrumb = ( <Breadcrumb> @@ -57,7 +57,7 @@ function Project() { const contextSelectors = ( <ContextSelectionSection> - <ProjectSelector projectId={projectId} /> + <ProjectSelector activeProject={project} /> </ContextSelectionSection> ) diff --git a/opendc-web/opendc-web-ui/src/pages/projects/[project]/portfolios/[portfolio].js b/opendc-web/opendc-web-ui/src/pages/projects/[project]/portfolios/[portfolio].js index d1533d98..68345d0b 100644 --- a/opendc-web/opendc-web-ui/src/pages/projects/[project]/portfolios/[portfolio].js +++ b/opendc-web/opendc-web-ui/src/pages/projects/[project]/portfolios/[portfolio].js @@ -20,6 +20,7 @@ * SOFTWARE. */ +import dynamic from 'next/dynamic' import { useRouter } from 'next/router' import Head from 'next/head' import React, { useRef } from 'react' @@ -42,18 +43,24 @@ import PortfolioSelector from '../../../../components/context/PortfolioSelector' import ProjectSelector from '../../../../components/context/ProjectSelector' import BreadcrumbLink from '../../../../components/util/BreadcrumbLink' import PortfolioOverview from '../../../../components/portfolios/PortfolioOverview' -import PortfolioResults from '../../../../components/portfolios/PortfolioResults' +import { usePortfolio } from '../../../../data/project' + +const PortfolioResults = dynamic(() => import('../../../../components/portfolios/PortfolioResults')) /** * Page that displays the results in a portfolio. */ function Portfolio() { const router = useRouter() - const { project: projectId, portfolio: portfolioId } = router.query + const projectId = +router.query['project'] + const portfolioNumber = +router.query['portfolio'] const overviewRef = useRef(null) const resultsRef = useRef(null) + const { data: portfolio } = usePortfolio(projectId, portfolioNumber) + const project = portfolio?.project + const breadcrumb = ( <Breadcrumb> <BreadcrumbItem to="/projects" component={BreadcrumbLink}> @@ -62,7 +69,11 @@ function Portfolio() { <BreadcrumbItem to={`/projects/${projectId}`} component={BreadcrumbLink}> Project details </BreadcrumbItem> - <BreadcrumbItem to={`/projects/${projectId}/portfolios/${portfolioId}`} component={BreadcrumbLink} isActive> + <BreadcrumbItem + to={`/projects/${projectId}/portfolios/${portfolioNumber}`} + component={BreadcrumbLink} + isActive + > Portfolio </BreadcrumbItem> </Breadcrumb> @@ -70,8 +81,8 @@ function Portfolio() { const contextSelectors = ( <ContextSelectionSection> - <ProjectSelector projectId={projectId} /> - <PortfolioSelector projectId={projectId} portfolioId={portfolioId} /> + <ProjectSelector activeProject={project} /> + <PortfolioSelector activePortfolio={portfolio} /> </ContextSelectionSection> ) @@ -104,10 +115,10 @@ function Portfolio() { </PageSection> <PageSection isFilled> <TabContent eventKey={0} id="overview" ref={overviewRef} aria-label="Overview tab"> - <PortfolioOverview portfolioId={portfolioId} /> + <PortfolioOverview projectId={projectId} portfolioId={portfolioNumber} /> </TabContent> <TabContent eventKey={1} id="results" ref={resultsRef} aria-label="Results tab" hidden> - <PortfolioResults portfolioId={portfolioId} /> + <PortfolioResults projectId={projectId} portfolioId={portfolioNumber} /> </TabContent> </PageSection> </AppPage> diff --git a/opendc-web/opendc-web-ui/src/pages/projects/[project]/topologies/[topology].js b/opendc-web/opendc-web-ui/src/pages/projects/[project]/topologies/[topology].js index f7188d9f..6297b8c3 100644 --- a/opendc-web/opendc-web-ui/src/pages/projects/[project]/topologies/[topology].js +++ b/opendc-web/opendc-web-ui/src/pages/projects/[project]/topologies/[topology].js @@ -26,7 +26,6 @@ import ContextSelectionSection from '../../../../components/context/ContextSelec import ProjectSelector from '../../../../components/context/ProjectSelector' import TopologySelector from '../../../../components/context/TopologySelector' import TopologyOverview from '../../../../components/topologies/TopologyOverview' -import { useProject } from '../../../../data/project' import { useDispatch } from 'react-redux' import React, { useEffect, useState } from 'react' import Head from 'next/head' @@ -45,6 +44,7 @@ import { TextContent, } from '@patternfly/react-core' import BreadcrumbLink from '../../../../components/util/BreadcrumbLink' +import { useTopology } from '../../../../data/topology' import { goToRoom } from '../../../../redux/actions/interaction-level' import { openTopology } from '../../../../redux/actions/topology' @@ -55,16 +55,18 @@ const TopologyMap = dynamic(() => import('../../../../components/topologies/Topo */ function Topology() { const router = useRouter() - const { project: projectId, topology: topologyId } = router.query + const projectId = +router.query['project'] + const topologyNumber = +router.query['topology'] - const { data: project } = useProject(projectId) + const { data: topology } = useTopology(projectId, topologyNumber) + const project = topology?.project const dispatch = useDispatch() useEffect(() => { - if (topologyId) { - dispatch(openTopology(topologyId)) + if (topologyNumber) { + dispatch(openTopology(projectId, topologyNumber)) } - }, [topologyId, dispatch]) + }, [projectId, topologyNumber, dispatch]) const [activeTab, setActiveTab] = useState('overview') @@ -76,7 +78,11 @@ function Topology() { <BreadcrumbItem to={`/projects/${projectId}`} component={BreadcrumbLink}> Project details </BreadcrumbItem> - <BreadcrumbItem to={`/projects/${projectId}/topologies/${topologyId}`} component={BreadcrumbLink} isActive> + <BreadcrumbItem + to={`/projects/${projectId}/topologies/${topologyNumber}`} + component={BreadcrumbLink} + isActive + > Topology </BreadcrumbItem> </Breadcrumb> @@ -84,8 +90,8 @@ function Topology() { const contextSelectors = ( <ContextSelectionSection> - <ProjectSelector projectId={projectId} /> - <TopologySelector projectId={projectId} topologyId={topologyId} /> + <ProjectSelector activeProject={project} /> + <TopologySelector activeTopology={topology} /> </ContextSelectionSection> ) @@ -117,16 +123,22 @@ function Topology() { <PageSection padding={activeTab === 'floor-plan' && { default: 'noPadding' }} isFilled> <TabContent id="overview" aria-label="Overview tab" hidden={activeTab !== 'overview'}> <TopologyOverview - topologyId={topologyId} + projectId={projectId} + topologyNumber={topologyNumber} onSelect={(type, obj) => { if (type === 'room') { - dispatch(goToRoom(obj._id)) + dispatch(goToRoom(obj.id)) setActiveTab('floor-plan') } }} /> </TabContent> - <TabContent id="floor-plan" aria-label="Floor Plan tab" className="pf-u-h-100" hidden={activeTab !== 'floor-plan'}> + <TabContent + id="floor-plan" + aria-label="Floor Plan tab" + className="pf-u-h-100" + hidden={activeTab !== 'floor-plan'} + > <TopologyMap /> </TabContent> </PageSection> diff --git a/opendc-web/opendc-web-ui/src/pages/projects/index.js b/opendc-web/opendc-web-ui/src/pages/projects/index.js index eb77701e..bb1fbd69 100644 --- a/opendc-web/opendc-web-ui/src/pages/projects/index.js +++ b/opendc-web/opendc-web-ui/src/pages/projects/index.js @@ -26,9 +26,8 @@ import ProjectFilterPanel from '../../components/projects/FilterPanel' import { useAuth } from '../../auth' import { AppPage } from '../../components/AppPage' import { PageSection, PageSectionVariants, Text, TextContent } from '@patternfly/react-core' -import { useProjects } from '../../data/project' +import { useProjects, useDeleteProject } from '../../data/project' import ProjectTable from '../../components/projects/ProjectTable' -import { useMutation } from 'react-query' import NewProject from '../../components/projects/NewProject' const getVisibleProjects = (projects, filter, userId) => { @@ -52,13 +51,12 @@ function Projects() { const { user } = useAuth() const { status, data: projects } = useProjects() const [filter, setFilter] = useState('SHOW_ALL') - const visibleProjects = useMemo(() => getVisibleProjects(projects ?? [], filter, user?.sub), [ - projects, - filter, - user?.sub, - ]) + const visibleProjects = useMemo( + () => getVisibleProjects(projects ?? [], filter, user?.sub), + [projects, filter, user?.sub] + ) - const { mutate: deleteProject } = useMutation('deleteProject') + const { mutate: deleteProject } = useDeleteProject() return ( <AppPage> @@ -76,7 +74,7 @@ function Projects() { status={status} isFiltering={filter !== 'SHOW_ALL'} projects={visibleProjects} - onDelete={(project) => deleteProject(project._id)} + onDelete={(project) => deleteProject(project.id)} /> <NewProject /> </PageSection> diff --git a/opendc-web/opendc-web-ui/src/redux/actions/topology/building.js b/opendc-web/opendc-web-ui/src/redux/actions/topology/building.js index 939c24a4..e430da2e 100644 --- a/opendc-web/opendc-web-ui/src/redux/actions/topology/building.js +++ b/opendc-web/opendc-web-ui/src/redux/actions/topology/building.js @@ -14,16 +14,16 @@ export const DELETE_TILE = 'DELETE_TILE' export function startNewRoomConstruction() { return (dispatch, getState) => { const { topology } = getState() - const topologyId = topology.root._id + const topologyId = topology.root.id const room = { - _id: uuid(), + id: uuid(), name: 'Room', topologyId, tiles: [], } dispatch(addRoom(topologyId, room)) - dispatch(startNewRoomConstructionSucceeded(room._id)) + dispatch(startNewRoomConstructionSucceeded(room.id)) } } @@ -97,7 +97,7 @@ export function addTile(roomId, positionX, positionY) { return { type: ADD_TILE, tile: { - _id: uuid(), + id: uuid(), roomId, positionX, positionY, diff --git a/opendc-web/opendc-web-ui/src/redux/actions/topology/index.js b/opendc-web/opendc-web-ui/src/redux/actions/topology/index.js index 94a712c4..d48af37a 100644 --- a/opendc-web/opendc-web-ui/src/redux/actions/topology/index.js +++ b/opendc-web/opendc-web-ui/src/redux/actions/topology/index.js @@ -23,9 +23,10 @@ export const OPEN_TOPOLOGY = 'OPEN_TOPOLOGY' export const STORE_TOPOLOGY = 'STORE_TOPOLOGY' -export function openTopology(id) { +export function openTopology(projectId, id) { return { type: OPEN_TOPOLOGY, + projectId, id, } } diff --git a/opendc-web/opendc-web-ui/src/redux/actions/topology/rack.js b/opendc-web/opendc-web-ui/src/redux/actions/topology/rack.js index c319d966..308acaa6 100644 --- a/opendc-web/opendc-web-ui/src/redux/actions/topology/rack.js +++ b/opendc-web/opendc-web-ui/src/redux/actions/topology/rack.js @@ -24,7 +24,7 @@ export function addMachine(rackId, position) { return { type: ADD_MACHINE, machine: { - _id: uuid(), + id: uuid(), rackId, position, cpus: [], diff --git a/opendc-web/opendc-web-ui/src/redux/actions/topology/room.js b/opendc-web/opendc-web-ui/src/redux/actions/topology/room.js index bd447db5..fd2d8cdc 100644 --- a/opendc-web/opendc-web-ui/src/redux/actions/topology/room.js +++ b/opendc-web/opendc-web-ui/src/redux/actions/topology/room.js @@ -16,7 +16,7 @@ export function addRoom(topologyId, room) { return { type: ADD_ROOM, room: { - _id: uuid(), + id: uuid(), topologyId, ...room, }, @@ -54,9 +54,9 @@ export function addRackToTile(positionX, positionY) { dispatch({ type: ADD_RACK_TO_TILE, rack: { - _id: uuid(), + id: uuid(), name: 'Rack', - tileId: tile._id, + tileId: tile.id, capacity: DEFAULT_RACK_SLOT_CAPACITY, powerCapacityW: DEFAULT_RACK_POWER_CAPACITY, machines: [], diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/topology/machine.js b/opendc-web/opendc-web-ui/src/redux/reducers/topology/machine.js index 47af53cf..1789257b 100644 --- a/opendc-web/opendc-web-ui/src/redux/reducers/topology/machine.js +++ b/opendc-web/opendc-web-ui/src/redux/reducers/topology/machine.js @@ -10,7 +10,7 @@ function machine(state = {}, action, { racks }) { case ADD_MACHINE: return produce(state, (draft) => { const { machine } = action - draft[machine._id] = machine + draft[machine.id] = machine }) case DELETE_MACHINE: return produce(state, (draft) => { diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/topology/rack.js b/opendc-web/opendc-web-ui/src/redux/reducers/topology/rack.js index 155837cb..ca79348a 100644 --- a/opendc-web/opendc-web-ui/src/redux/reducers/topology/rack.js +++ b/opendc-web/opendc-web-ui/src/redux/reducers/topology/rack.js @@ -33,7 +33,7 @@ function rack(state = {}, action, { machines }) { case ADD_RACK_TO_TILE: return produce(state, (draft) => { const { rack } = action - draft[rack._id] = rack + draft[rack.id] = rack }) case EDIT_RACK_NAME: return produce(state, (draft) => { @@ -48,7 +48,7 @@ function rack(state = {}, action, { machines }) { case ADD_MACHINE: return produce(state, (draft) => { const { machine } = action - draft[machine.rackId].machines.push(machine._id) + draft[machine.rackId].machines.push(machine.id) }) case DELETE_MACHINE: return produce(state, (draft) => { diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/topology/room.js b/opendc-web/opendc-web-ui/src/redux/reducers/topology/room.js index d6cc51c1..c05c8bfa 100644 --- a/opendc-web/opendc-web-ui/src/redux/reducers/topology/room.js +++ b/opendc-web/opendc-web-ui/src/redux/reducers/topology/room.js @@ -32,7 +32,7 @@ function room(state = {}, action, { tiles }) { case ADD_ROOM: return produce(state, (draft) => { const { room } = action - draft[room._id] = room + draft[room.id] = room }) case DELETE_ROOM: return produce(state, (draft) => { @@ -47,7 +47,7 @@ function room(state = {}, action, { tiles }) { case ADD_TILE: return produce(state, (draft) => { const { tile } = action - draft[tile.roomId].tiles.push(tile._id) + draft[tile.roomId].tiles.push(tile.id) }) case DELETE_TILE: return produce(state, (draft) => { diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/topology/tile.js b/opendc-web/opendc-web-ui/src/redux/reducers/topology/tile.js index 6dbccb66..8e5ecd6e 100644 --- a/opendc-web/opendc-web-ui/src/redux/reducers/topology/tile.js +++ b/opendc-web/opendc-web-ui/src/redux/reducers/topology/tile.js @@ -33,7 +33,7 @@ function tile(state = {}, action, { racks }) { case ADD_TILE: return produce(state, (draft) => { const { tile } = action - draft[tile._id] = tile + draft[tile.id] = tile }) case DELETE_TILE: return produce(state, (draft) => { @@ -43,7 +43,7 @@ function tile(state = {}, action, { racks }) { case ADD_RACK_TO_TILE: return produce(state, (draft) => { const { rack } = action - draft[rack.tileId].rack = rack._id + draft[rack.tileId].rack = rack.id }) case DELETE_RACK: return produce(state, (draft) => { diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/topology/topology.js b/opendc-web/opendc-web-ui/src/redux/reducers/topology/topology.js index cd9b5efd..dff0a69e 100644 --- a/opendc-web/opendc-web-ui/src/redux/reducers/topology/topology.js +++ b/opendc-web/opendc-web-ui/src/redux/reducers/topology/topology.js @@ -21,7 +21,7 @@ */ import produce from 'immer' -import { STORE_TOPOLOGY } from "../../actions/topology"; +import { STORE_TOPOLOGY } from '../../actions/topology' import { ADD_ROOM, DELETE_ROOM } from '../../actions/topology/room' function topology(state = undefined, action) { @@ -31,7 +31,7 @@ function topology(state = undefined, action) { case ADD_ROOM: return produce(state, (draft) => { const { room } = action - draft.rooms.push(room._id) + draft.rooms.push(room.id) }) case DELETE_ROOM: return produce(state, (draft) => { diff --git a/opendc-web/opendc-web-ui/src/redux/sagas/topology.js b/opendc-web/opendc-web-ui/src/redux/sagas/topology.js index 4c8ff5da..15147bcf 100644 --- a/opendc-web/opendc-web-ui/src/redux/sagas/topology.js +++ b/opendc-web/opendc-web-ui/src/redux/sagas/topology.js @@ -32,14 +32,15 @@ export function* updateServer() { * Watch the topology on the server for changes. */ export function* watchServer() { - let { id } = yield take(OPEN_TOPOLOGY) + let { projectId, id } = yield take(OPEN_TOPOLOGY) while (true) { - const channel = yield queryObserver(id) + const channel = yield queryObserver(projectId, id) while (true) { const [action, response] = yield race([take(OPEN_TOPOLOGY), take(channel)]) if (action) { + projectId = action.projectId id = action.id break } @@ -57,9 +58,9 @@ export function* watchServer() { /** * Observe changes for the topology with the specified identifier. */ -function* queryObserver(id) { +function* queryObserver(projectId, id) { const queryClient = yield getContext('queryClient') - const observer = new QueryObserver(queryClient, { queryKey: ['topologies', id] }) + const observer = new QueryObserver(queryClient, { queryKey: ['topologies', projectId, id] }) return eventChannel((emitter) => { const unsubscribe = observer.subscribe((result) => { diff --git a/opendc-web/opendc-web-ui/src/shapes.js b/opendc-web/opendc-web-ui/src/shapes.js index abdf146e..6c93f458 100644 --- a/opendc-web/opendc-web-ui/src/shapes.js +++ b/opendc-web/opendc-web-ui/src/shapes.js @@ -22,26 +22,18 @@ import PropTypes from 'prop-types' -export const User = PropTypes.shape({ - _id: PropTypes.string.isRequired, - googleId: PropTypes.string.isRequired, - email: PropTypes.string.isRequired, - givenName: PropTypes.string.isRequired, - familyName: PropTypes.string.isRequired, - authorizations: PropTypes.array.isRequired, -}) +export const ProjectRole = PropTypes.oneOf(['VIEWER', 'EDITOR', 'OWNER']) export const Project = PropTypes.shape({ - _id: PropTypes.string.isRequired, + id: PropTypes.number.isRequired, name: PropTypes.string.isRequired, - datetimeCreated: PropTypes.string.isRequired, - datetimeLastEdited: PropTypes.string.isRequired, - topologyIds: PropTypes.array.isRequired, - portfolioIds: PropTypes.array.isRequired, + createdAt: PropTypes.string.isRequired, + updatedAt: PropTypes.string.isRequired, + role: ProjectRole, }) export const ProcessingUnit = PropTypes.shape({ - _id: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, clockRateMhz: PropTypes.number.isRequired, numberOfCores: PropTypes.number.isRequired, @@ -49,7 +41,7 @@ export const ProcessingUnit = PropTypes.shape({ }) export const StorageUnit = PropTypes.shape({ - _id: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, speedMbPerS: PropTypes.number.isRequired, sizeMb: PropTypes.number.isRequired, @@ -57,38 +49,45 @@ export const StorageUnit = PropTypes.shape({ }) export const Machine = PropTypes.shape({ - _id: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, position: PropTypes.number.isRequired, - cpus: PropTypes.arrayOf(PropTypes.string), - gpus: PropTypes.arrayOf(PropTypes.string), - memories: PropTypes.arrayOf(PropTypes.string), - storages: PropTypes.arrayOf(PropTypes.string), + cpus: PropTypes.arrayOf(PropTypes.oneOfType([ProcessingUnit, PropTypes.string])), + gpus: PropTypes.arrayOf(PropTypes.oneOfType([ProcessingUnit, PropTypes.string])), + memories: PropTypes.arrayOf(PropTypes.oneOfType([StorageUnit, PropTypes.string])), + storages: PropTypes.arrayOf(PropTypes.oneOfType([StorageUnit, PropTypes.string])), }) export const Rack = PropTypes.shape({ - _id: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, capacity: PropTypes.number.isRequired, powerCapacityW: PropTypes.number.isRequired, - machines: PropTypes.arrayOf(PropTypes.string), + machines: PropTypes.arrayOf(PropTypes.oneOfType([Machine, PropTypes.string])), }) export const Tile = PropTypes.shape({ - _id: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, positionX: PropTypes.number.isRequired, positionY: PropTypes.number.isRequired, - rack: PropTypes.string, + rack: PropTypes.oneOfType([Rack, PropTypes.string]), }) export const Room = PropTypes.shape({ - _id: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, - tiles: PropTypes.arrayOf(PropTypes.string), + tiles: PropTypes.arrayOf(PropTypes.oneOfType([Tile, PropTypes.string])), }) export const Topology = PropTypes.shape({ - _id: PropTypes.string.isRequired, + id: PropTypes.number.isRequired, + number: PropTypes.number.isRequired, + project: Project.isRequired, name: PropTypes.string.isRequired, - rooms: PropTypes.arrayOf(PropTypes.string), + rooms: PropTypes.arrayOf(PropTypes.oneOfType([Room, PropTypes.string])), +}) + +export const Phenomena = PropTypes.shape({ + failures: PropTypes.bool.isRequired, + interference: PropTypes.bool.isRequired, }) export const Scheduler = PropTypes.shape({ @@ -96,47 +95,82 @@ export const Scheduler = PropTypes.shape({ }) export const Trace = PropTypes.shape({ - _id: PropTypes.string.isRequired, + id: PropTypes.number.isRequired, name: PropTypes.string.isRequired, type: PropTypes.string.isRequired, }) -export const Portfolio = PropTypes.shape({ - _id: PropTypes.string.isRequired, - projectId: PropTypes.string.isRequired, +export const Workload = PropTypes.shape({ + trace: Trace.isRequired, + samplingFraction: PropTypes.number.isRequired, +}) + +export const Targets = PropTypes.shape({ + repeats: PropTypes.number.isRequired, + metrics: PropTypes.arrayOf(PropTypes.string).isRequired, +}) + +export const TopologySummary = PropTypes.shape({ + id: PropTypes.number.isRequired, + number: PropTypes.number.isRequired, + project: Project.isRequired, + name: PropTypes.string.isRequired, +}) + +export const PortfolioSummary = PropTypes.shape({ + id: PropTypes.number.isRequired, + number: PropTypes.number.isRequired, + project: Project.isRequired, name: PropTypes.string.isRequired, - scenarioIds: PropTypes.arrayOf(PropTypes.string).isRequired, targets: PropTypes.shape({ - enabledMetrics: PropTypes.arrayOf(PropTypes.string).isRequired, - repeatsPerScenario: PropTypes.number.isRequired, + repeats: PropTypes.number.isRequired, + metrics: PropTypes.arrayOf(PropTypes.string).isRequired, }).isRequired, }) -export const Scenario = PropTypes.shape({ - _id: PropTypes.string.isRequired, - portfolioId: PropTypes.string.isRequired, +export const ScenarioSummary = PropTypes.shape({ + id: PropTypes.number.isRequired, + number: PropTypes.number.isRequired, name: PropTypes.string.isRequired, - simulation: PropTypes.shape({ - state: PropTypes.string.isRequired, - }).isRequired, - trace: PropTypes.shape({ - traceId: PropTypes.string.isRequired, - trace: Trace, - loadSamplingFraction: PropTypes.number.isRequired, - }).isRequired, - topology: PropTypes.shape({ - topologyId: PropTypes.string.isRequired, - topology: Topology, - }).isRequired, - operational: PropTypes.shape({ - failuresEnabled: PropTypes.bool.isRequired, - performanceInterferenceEnabled: PropTypes.bool.isRequired, - schedulerName: PropTypes.string.isRequired, - scheduler: Scheduler, - }).isRequired, + workload: Workload.isRequired, + topology: TopologySummary.isRequired, + phenomena: Phenomena.isRequired, + schedulerName: PropTypes.string.isRequired, results: PropTypes.object, }) +export const JobState = PropTypes.oneOf(['PENDING', 'CLAIMED', 'RUNNING', 'FAILED', 'FINISHED']) + +export const Job = PropTypes.shape({ + id: PropTypes.number.isRequired, + state: JobState.isRequired, + createdAt: PropTypes.string.isRequired, + updatedAt: PropTypes.string.isRequired, + results: PropTypes.object, +}) + +export const Scenario = PropTypes.shape({ + id: PropTypes.number.isRequired, + number: PropTypes.number.isRequired, + project: Project.isRequired, + portfolio: PortfolioSummary.isRequired, + name: PropTypes.string.isRequired, + workload: Workload.isRequired, + topology: TopologySummary.isRequired, + phenomena: Phenomena.isRequired, + schedulerName: PropTypes.string.isRequired, + job: Job.isRequired, +}) + +export const Portfolio = PropTypes.shape({ + id: PropTypes.number.isRequired, + number: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + project: Project.isRequired, + targets: Targets.isRequired, + scenarios: PropTypes.arrayOf(ScenarioSummary).isRequired, +}) + export const WallSegment = PropTypes.shape({ startPosX: PropTypes.number.isRequired, startPosY: PropTypes.number.isRequired, diff --git a/opendc-web/opendc-web-ui/src/util/authorizations.js b/opendc-web/opendc-web-ui/src/util/authorizations.js index ce5d34b6..fffcefeb 100644 --- a/opendc-web/opendc-web-ui/src/util/authorizations.js +++ b/opendc-web/opendc-web-ui/src/util/authorizations.js @@ -3,13 +3,13 @@ import EditIcon from '@patternfly/react-icons/dist/js/icons/edit-icon' import EyeIcon from '@patternfly/react-icons/dist/js/icons/eye-icon' export const AUTH_ICON_MAP = { - OWN: HomeIcon, - EDIT: EditIcon, - VIEW: EyeIcon, + OWNER: HomeIcon, + EDITOR: EditIcon, + VIEWER: EyeIcon, } export const AUTH_DESCRIPTION_MAP = { - OWN: 'Own', - EDIT: 'Can Edit', - VIEW: 'Can View', + OWNER: 'Own', + EDITOR: 'Can Edit', + VIEWER: 'Can View', } diff --git a/opendc-web/opendc-web-ui/src/util/topology-schema.js b/opendc-web/opendc-web-ui/src/util/topology-schema.js index 7779ccfe..ff672dd6 100644 --- a/opendc-web/opendc-web-ui/src/util/topology-schema.js +++ b/opendc-web/opendc-web-ui/src/util/topology-schema.js @@ -22,10 +22,10 @@ import { schema } from 'normalizr' -const Cpu = new schema.Entity('cpus', {}, { idAttribute: '_id' }) -const Gpu = new schema.Entity('gpus', {}, { idAttribute: '_id' }) -const Memory = new schema.Entity('memories', {}, { idAttribute: '_id' }) -const Storage = new schema.Entity('storages', {}, { idAttribute: '_id' }) +const Cpu = new schema.Entity('cpus', {}, { idAttribute: 'id' }) +const Gpu = new schema.Entity('gpus', {}, { idAttribute: 'id' }) +const Memory = new schema.Entity('memories', {}, { idAttribute: 'id' }) +const Storage = new schema.Entity('storages', {}, { idAttribute: 'id' }) export const Machine = new schema.Entity( 'machines', @@ -35,13 +35,13 @@ export const Machine = new schema.Entity( memories: [Memory], storages: [Storage], }, - { idAttribute: '_id' } + { idAttribute: 'id' } ) -export const Rack = new schema.Entity('racks', { machines: [Machine] }, { idAttribute: '_id' }) +export const Rack = new schema.Entity('racks', { machines: [Machine] }, { idAttribute: 'id' }) -export const Tile = new schema.Entity('tiles', { rack: Rack }, { idAttribute: '_id' }) +export const Tile = new schema.Entity('tiles', { rack: Rack }, { idAttribute: 'id' }) -export const Room = new schema.Entity('rooms', { tiles: [Tile] }, { idAttribute: '_id' }) +export const Room = new schema.Entity('rooms', { tiles: [Tile] }, { idAttribute: 'id' }) -export const Topology = new schema.Entity('topologies', { rooms: [Room] }, { idAttribute: '_id' }) +export const Topology = new schema.Entity('topologies', { rooms: [Room] }, { idAttribute: 'id' }) diff --git a/opendc-web/opendc-web-ui/src/util/unit-specifications.js b/opendc-web/opendc-web-ui/src/util/unit-specifications.js index 28479edd..3e3671cd 100644 --- a/opendc-web/opendc-web-ui/src/util/unit-specifications.js +++ b/opendc-web/opendc-web-ui/src/util/unit-specifications.js @@ -1,34 +1,34 @@ export const CPU_UNITS = { 'cpu-1': { - _id: 'cpu-1', + id: 'cpu-1', name: 'Intel i7 v6 6700k', clockRateMhz: 4100, numberOfCores: 4, energyConsumptionW: 70, }, 'cpu-2': { - _id: 'cpu-2', + id: 'cpu-2', name: 'Intel i5 v6 6700k', clockRateMhz: 3500, numberOfCores: 2, energyConsumptionW: 50, }, 'cpu-3': { - _id: 'cpu-3', + id: 'cpu-3', name: 'Intel® Xeon® E-2224G', clockRateMhz: 3500, numberOfCores: 4, energyConsumptionW: 71, }, 'cpu-4': { - _id: 'cpu-4', + id: 'cpu-4', name: 'Intel® Xeon® E-2244G', clockRateMhz: 3800, numberOfCores: 8, energyConsumptionW: 71, }, 'cpu-5': { - _id: 'cpu-5', + id: 'cpu-5', name: 'Intel® Xeon® E-2246G', clockRateMhz: 3600, numberOfCores: 12, @@ -38,14 +38,14 @@ export const CPU_UNITS = { export const GPU_UNITS = { 'gpu-1': { - _id: 'gpu-1', + id: 'gpu-1', name: 'NVIDIA GTX 4 1080', clockRateMhz: 1200, numberOfCores: 200, energyConsumptionW: 250, }, 'gpu-2': { - _id: 'gpu-2', + id: 'gpu-2', name: 'NVIDIA Tesla V100', clockRateMhz: 1200, numberOfCores: 5120, @@ -55,28 +55,28 @@ export const GPU_UNITS = { export const MEMORY_UNITS = { 'memory-1': { - _id: 'memory-1', + id: 'memory-1', name: 'Samsung PC DRAM K4A4G045WD', speedMbPerS: 16000, sizeMb: 4000, energyConsumptionW: 10, }, 'memory-2': { - _id: 'memory-2', + id: 'memory-2', name: 'Samsung PC DRAM M393A2K43BB1-CRC', speedMbPerS: 2400, sizeMb: 16000, energyConsumptionW: 10, }, 'memory-3': { - _id: 'memory-3', + id: 'memory-3', name: 'Crucial MTA18ASF4G72PDZ-3G2E1', speedMbPerS: 3200, sizeMb: 32000, energyConsumptionW: 10, }, 'memory-4': { - _id: 'memory-4', + id: 'memory-4', name: 'Crucial MTA9ASF2G72PZ-3G2E1', speedMbPerS: 3200, sizeMb: 16000, @@ -86,14 +86,14 @@ export const MEMORY_UNITS = { export const STORAGE_UNITS = { 'storage-1': { - _id: 'storage-1', + id: 'storage-1', name: 'Samsung EVO 2016 SATA III', speedMbPerS: 6000, sizeMb: 250000, energyConsumptionW: 10, }, 'storage-2': { - _id: 'storage-2', + id: 'storage-2', name: 'Western Digital MTA9ASF2G72PZ-3G2E1', speedMbPerS: 6000, sizeMb: 4000000, |
