summaryrefslogtreecommitdiff
path: root/opendc-web/opendc-web-api
diff options
context:
space:
mode:
authorFabian Mastenbroek <mail.fabianm@gmail.com>2021-10-26 16:19:55 +0200
committerFabian Mastenbroek <mail.fabianm@gmail.com>2022-04-04 12:48:04 +0200
commitf0c472b1792779e63fdeb97a470b46300de00050 (patch)
tree99646cf4448f4f73c2c98ede338df19a1497f9b5 /opendc-web/opendc-web-api
parent8f958c5a578dc11b890c96c0dc48e3e3f92a4d07 (diff)
feat(web/api): Initial API implementation in Kotlin
This change adds the initial implementation of the new API server in Kotlin, replacing the old API written in Python. The implementation uses Quarkus, RESTEasy, and Hibernate to implement the new API endpoints. The reason for replacing the old API server is unifying the build and deployment toolchains, reducing the number of technologies necessary to work with OpenDC. Furthermore, we envision bundling the entire OpenDC project into a single distributions, allowing users to launch their own deployment trivially.
Diffstat (limited to 'opendc-web/opendc-web-api')
-rw-r--r--opendc-web/opendc-web-api/.coveragerc5
-rw-r--r--opendc-web/opendc-web-api/.dockerignore8
-rw-r--r--opendc-web/opendc-web-api/.gitignore19
-rw-r--r--opendc-web/opendc-web-api/.pylintrc523
-rw-r--r--opendc-web/opendc-web-api/.style.yapf3
-rw-r--r--opendc-web/opendc-web-api/Dockerfile23
-rw-r--r--opendc-web/opendc-web-api/README.md122
-rwxr-xr-xopendc-web/opendc-web-api/app.py137
-rw-r--r--opendc-web/opendc-web-api/build.gradle.kts74
-rwxr-xr-xopendc-web/opendc-web-api/check.sh1
-rw-r--r--opendc-web/opendc-web-api/conftest.py45
-rw-r--r--opendc-web/opendc-web-api/docs/component-diagram.pngbin90161 -> 0 bytes
-rwxr-xr-xopendc-web/opendc-web-api/format.sh1
-rw-r--r--opendc-web/opendc-web-api/opendc/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/jobs.py105
-rw-r--r--opendc-web/opendc-web-api/opendc/api/portfolios.py153
-rw-r--r--opendc-web/opendc-web-api/opendc/api/prefabs.py123
-rw-r--r--opendc-web/opendc-web-api/opendc/api/projects.py224
-rw-r--r--opendc-web/opendc-web-api/opendc/api/scenarios.py86
-rw-r--r--opendc-web/opendc-web-api/opendc/api/schedulers.py46
-rw-r--r--opendc-web/opendc-web-api/opendc/api/topologies.py97
-rw-r--r--opendc-web/opendc-web-api/opendc/api/traces.py51
-rw-r--r--opendc-web/opendc-web-api/opendc/auth.py236
-rw-r--r--opendc-web/opendc-web-api/opendc/database.py97
-rw-r--r--opendc-web/opendc-web-api/opendc/exts.py91
-rw-r--r--opendc-web/opendc-web-api/opendc/models/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/models/model.py61
-rw-r--r--opendc-web/opendc-web-api/opendc/models/portfolio.py47
-rw-r--r--opendc-web/opendc-web-api/opendc/models/prefab.py31
-rw-r--r--opendc-web/opendc-web-api/opendc/models/project.py48
-rw-r--r--opendc-web/opendc-web-api/opendc/models/scenario.py93
-rw-r--r--opendc-web/opendc-web-api/opendc/models/topology.py108
-rw-r--r--opendc-web/opendc-web-api/opendc/models/trace.py16
-rw-r--r--opendc-web/opendc-web-api/opendc/util.py32
-rw-r--r--opendc-web/opendc-web-api/pytest.ini5
-rw-r--r--opendc-web/opendc-web-api/requirements.txt47
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/OpenDCApplication.kt30
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Job.kt96
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Portfolio.kt89
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Project.kt134
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/ProjectAuthorization.kt58
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/ProjectAuthorizationKey.kt38
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Scenario.kt108
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Topology.kt93
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Trace.kt58
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Workload.kt39
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/JobRepository.kt93
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/PortfolioRepository.kt76
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/ProjectRepository.kt157
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/ScenarioRepository.kt90
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/TopologyRepository.kt86
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/TraceRepository.kt53
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/SchedulerResource.kt48
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/TraceResource.kt51
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/error/GenericExceptionMapper.kt45
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/error/MissingKotlinParameterExceptionMapper.kt43
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/runner/JobResource.kt65
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/PortfolioResource.kt77
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/PortfolioScenarioResource.kt59
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/ProjectResource.kt82
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/ScenarioResource.kt60
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/TopologyResource.kt88
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/JobService.kt81
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/PortfolioService.kt104
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/ProjectService.kt86
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/RunnerConversions.kt69
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/ScenarioService.kt128
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/TopologyService.kt127
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/TraceService.kt48
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/UserConversions.kt120
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/Utils.kt40
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/KotlinModuleCustomizer.kt38
-rw-r--r--opendc-web/opendc-web-api/src/main/resources/META-INF/branding/logo.pngbin0 -> 2825 bytes
-rw-r--r--opendc-web/opendc-web-api/src/main/resources/application-dev.properties28
-rw-r--r--opendc-web/opendc-web-api/src/main/resources/application-prod.properties29
-rw-r--r--opendc-web/opendc-web-api/src/main/resources/application-test.properties36
-rw-r--r--opendc-web/opendc-web-api/src/main/resources/application.properties48
-rw-r--r--opendc-web/opendc-web-api/src/main/resources/init-dev.sql3
-rw-r--r--opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/SchedulerResourceTest.kt48
-rw-r--r--opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/TraceResourceTest.kt100
-rw-r--r--opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/runner/JobResourceTest.kt200
-rw-r--r--opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/PortfolioResourceTest.kt265
-rw-r--r--opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/PortfolioScenarioResourceTest.kt213
-rw-r--r--opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/ProjectResourceTest.kt240
-rw-r--r--opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/ScenarioResourceTest.kt178
-rw-r--r--opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/TopologyResourceTest.kt304
-rw-r--r--opendc-web/opendc-web-api/static/schema.yml1631
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_jobs.py139
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_portfolios.py340
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_prefabs.py252
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_projects.py197
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_scenarios.py135
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_schedulers.py22
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_topologies.py140
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_traces.py40
96 files changed, 4522 insertions, 5581 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
deleted file mode 100644
index 505a69de..00000000
--- a/opendc-web/opendc-web-api/Dockerfile
+++ /dev/null
@@ -1,23 +0,0 @@
-FROM python:3.9-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
-
-# Copy OpenDC directory
-COPY ./ /opendc
-
-# 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
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.
-
-![OpenDC Web Server Component Diagram](docs/component-diagram.png)
-
-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..9889b832 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.hibernate.types)
+ implementation(libs.quarkus.jdbc.postgresql)
+ quarkusDev(libs.quarkus.jdbc.h2)
+
+ testImplementation(libs.quarkus.junit5.core)
+ testImplementation(libs.quarkus.junit5.mockk)
+ 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
deleted file mode 100644
index 91b26006..00000000
--- a/opendc-web/opendc-web-api/docs/component-diagram.png
+++ /dev/null
Binary files differ
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-api/src/main/kotlin/org/opendc/web/api/OpenDCApplication.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/OpenDCApplication.kt
new file mode 100644
index 00000000..ddbd5390
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/OpenDCApplication.kt
@@ -0,0 +1,30 @@
+/*
+ * 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
+
+import javax.ws.rs.core.Application
+
+/**
+ * [Application] definition for the OpenDC web API.
+ */
+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..23838e34
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Job.kt
@@ -0,0 +1,96 @@
+/*
+ * 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 io.quarkiverse.hibernate.types.json.JsonBinaryType
+import io.quarkiverse.hibernate.types.json.JsonTypes
+import org.hibernate.annotations.Type
+import org.hibernate.annotations.TypeDef
+import org.opendc.web.proto.JobState
+import java.time.Instant
+import javax.persistence.*
+
+/**
+ * A simulation job to be run by the simulator.
+ */
+@TypeDef(name = JsonTypes.JSON_BIN, typeClass = JsonBinaryType::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 = JsonTypes.JSON_BIN)
+ @Column(columnDefinition = JsonTypes.JSON_BIN)
+ 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..9a383c7c
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Scenario.kt
@@ -0,0 +1,108 @@
+/*
+ * 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 io.quarkiverse.hibernate.types.json.JsonBinaryType
+import io.quarkiverse.hibernate.types.json.JsonTypes
+import org.hibernate.annotations.Type
+import org.hibernate.annotations.TypeDef
+import org.opendc.web.proto.OperationalPhenomena
+import javax.persistence.*
+
+/**
+ * A single scenario to be explored by the simulator.
+ */
+@TypeDef(name = JsonTypes.JSON_BIN, typeClass = JsonBinaryType::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 = JsonTypes.JSON_BIN)
+ @Column(columnDefinition = JsonTypes.JSON_BIN, 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..32bf799a
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Topology.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.model
+
+import io.quarkiverse.hibernate.types.json.JsonBinaryType
+import io.quarkiverse.hibernate.types.json.JsonTypes
+import org.hibernate.annotations.Type
+import org.hibernate.annotations.TypeDef
+import org.opendc.web.proto.Room
+import java.time.Instant
+import javax.persistence.*
+
+/**
+ * A datacenter design in OpenDC.
+ */
+@TypeDef(name = JsonTypes.JSON_BIN, typeClass = JsonBinaryType::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 = JsonTypes.JSON_BIN)
+ @Column(columnDefinition = JsonTypes.JSON_BIN, 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-api/src/main/kotlin/org/opendc/web/api/service/TraceService.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/TraceService.kt
new file mode 100644
index 00000000..a942696e
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/TraceService.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.service
+
+import org.opendc.web.api.repository.TraceRepository
+import org.opendc.web.proto.Trace
+import javax.enterprise.context.ApplicationScoped
+import javax.inject.Inject
+
+/**
+ * Service for managing [Trace]s.
+ */
+@ApplicationScoped
+class TraceService @Inject constructor(private val repository: TraceRepository) {
+ /**
+ * Obtain all available workload traces.
+ */
+ fun findAll(): List<Trace> {
+ return repository.findAll().map { it.toUserDto() }
+ }
+
+ /**
+ * Obtain a workload trace by identifier.
+ */
+ 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/resources/META-INF/branding/logo.png b/opendc-web/opendc-web-api/src/main/resources/META-INF/branding/logo.png
new file mode 100644
index 00000000..d743038b
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/resources/META-INF/branding/logo.png
Binary files differ
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..6f941067
--- /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 text;
+
+# 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..ce3a9473
--- /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 text;
+
+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