summaryrefslogtreecommitdiff
path: root/opendc-web
diff options
context:
space:
mode:
authorFabian Mastenbroek <mail.fabianm@gmail.com>2022-04-04 17:00:31 +0200
committerGitHub <noreply@github.com>2022-04-04 17:00:31 +0200
commit38769373c7e89783d33849283586bfa0b62e8251 (patch)
tree4fda128ee6b30018c1aa14c584cc53ade80e67f7 /opendc-web
parent6021aa4278bebb34bf5603ead4b5daeabcdc4c19 (diff)
parent527ae2230f5c2dd22f496f45d5d8e3bd4acdb854 (diff)
merge: Migrate to Quarkus-based web API
This pull request changes the web API to a Quarkus-based version. Currently, the OpenDC web API is written in Python (using Flask). Although Python is a powerful language to develop web services, having another language next to Kotlin/Java and JavaScript introduces some challenges. For instance, the web API and UI lack integration with our Gradle-based build pipeline and require additional steps from the developer to start working with. Furthermore, deploying OpenDC requires having Python installed in addition to the JVM. By converting the web API into a Quarkus application, we can enjoy further integration with our Gradle-based build pipeline and simplify the development/deployment process of OpenDC, by requiring only the JVM and Node to work with OpenDC. ## Implementation Notes :hammer_and_pick: * Move build dependencies into version catalog * Design unified communication protocol * Add Quarkus API implementation * Add new web client implementation * Update runner to use new web client * Fix compatibility with React.js UI * Remove Python build steps from CI pipeline * Update Docker deployment for new web API * Remove obsolete database configuration ## External Dependencies :four_leaf_clover: * Quarkus ## Breaking API Changes :warning: * The new web API only supports SQL-based databases for storing user-data, as opposed to MongoDB currently. We intend to use H2 for development and Postgres for production.
Diffstat (limited to 'opendc-web')
-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/Dockerfile32
-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.kt (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/SimulationState.kt)10
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Job.kt95
-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.kt107
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Topology.kt92
-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.kt (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/ApiResult.kt)27
-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/kotlin/org/opendc/web/api/util/hibernate/json/AbstractJsonSqlTypeDescriptor.kt74
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonBinarySqlTypeDescriptor.kt26
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonBytesSqlTypeDescriptor.kt83
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonSqlTypeDescriptor.kt107
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonStringSqlTypeDescriptor.kt38
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonType.kt48
-rw-r--r--opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonTypeDescriptor.kt149
-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
-rw-r--r--opendc-web/opendc-web-client/build.gradle.kts (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Job.kt)29
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/OpenDCClient.kt73
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/PortfolioResource.kt58
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ProjectResource.kt52
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ScenarioResource.kt63
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/SchedulerResource.kt (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/AuthConfiguration.kt)18
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TopologyResource.kt66
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TraceResource.kt42
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/AuthController.kt40
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/OpenIdAuthController.kt140
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/ClientUtils.kt54
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenRequest.kt62
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenResponse.kt40
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OpenIdConfiguration.kt43
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/JobResource.kt47
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/OpenDCRunnerClient.kt59
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/HttpTransportClient.kt145
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/TransportClient.kt50
-rw-r--r--opendc-web/opendc-web-proto/build.gradle.kts (renamed from opendc-web/opendc-web-ui/src/api/prefabs.js)24
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/JobState.kt53
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Machine.kt (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Machine.kt)7
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/MemoryUnit.kt (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/MemoryUnit.kt)5
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/OperationalPhenomena.kt (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/OperationalPhenomena.kt)7
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/ProcessingUnit.kt (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ProcessingUnit.kt)5
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/ProtocolError.kt (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ScenarioTopology.kt)8
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Rack.kt (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Rack.kt)11
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Room.kt (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Room.kt)9
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/RoomTile.kt (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/RoomTile.kt)9
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Targets.kt (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/PortfolioTargets.kt)15
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Trace.kt (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Portfolio.kt)20
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Workload.kt44
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Job.kt46
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Portfolio.kt43
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Scenario.kt (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Scenario.kt)24
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Topology.kt (renamed from opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Topology.kt)22
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Job.kt40
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Portfolio.kt72
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Project.kt44
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/ProjectRole.kt43
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Scenario.kt86
-rw-r--r--opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Topology.kt75
-rw-r--r--opendc-web/opendc-web-runner/Dockerfile18
-rw-r--r--opendc-web/opendc-web-runner/build.gradle.kts7
-rw-r--r--opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/ApiClient.kt179
-rw-r--r--opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ScenarioTrace.kt28
-rw-r--r--opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/Main.kt57
-rw-r--r--opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/ScenarioManager.kt66
-rw-r--r--opendc-web/opendc-web-runner/src/test/kotlin/org/opendc/web/client/ApiClientTest.kt264
-rw-r--r--opendc-web/opendc-web-ui/src/api/index.js2
-rw-r--r--opendc-web/opendc-web-ui/src/api/portfolios.js18
-rw-r--r--opendc-web/opendc-web-ui/src/api/projects.js6
-rw-r--r--opendc-web/opendc-web-ui/src/api/scenarios.js20
-rw-r--r--opendc-web/opendc-web-ui/src/api/topologies.js19
-rw-r--r--opendc-web/opendc-web-ui/src/components/AppNavigation.js30
-rw-r--r--opendc-web/opendc-web-ui/src/components/context/ContextSelector.js21
-rw-r--r--opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss1
-rw-r--r--opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js24
-rw-r--r--opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js17
-rw-r--r--opendc-web/opendc-web-ui/src/components/context/TopologySelector.js23
-rw-r--r--opendc-web/opendc-web-ui/src/components/portfolios/NewScenario.js20
-rw-r--r--opendc-web/opendc-web-ui/src/components/portfolios/NewScenarioModal.js48
-rw-r--r--opendc-web/opendc-web-ui/src/components/portfolios/PortfolioOverview.js23
-rw-r--r--opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js19
-rw-r--r--opendc-web/opendc-web-ui/src/components/portfolios/ScenarioState.js6
-rw-r--r--opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js31
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/NewPortfolio.js6
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/NewPortfolioModal.js2
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/NewProject.js4
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/NewTopology.js16
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js22
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js27
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/ProjectOverview.js2
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js11
-rw-r--r--opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js19
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js9
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/TopologyOverview.js15
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js2
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/map/elements/ImageComponent.js1
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js4
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/map/groups/RoomGroup.js6
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/map/layers/RoomHoverLayer.js4
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/MachineSidebar.js2
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitAddComponent.js2
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitListContainer.js2
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/AddPrefab.js7
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/MachineListContainer.js2
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackNameContainer.js4
-rw-r--r--opendc-web/opendc-web-ui/src/components/topologies/sidebar/room/RoomName.js4
-rw-r--r--opendc-web/opendc-web-ui/src/data/experiments.js8
-rw-r--r--opendc-web/opendc-web-ui/src/data/project.js114
-rw-r--r--opendc-web/opendc-web-ui/src/data/topology.js69
-rw-r--r--opendc-web/opendc-web-ui/src/pages/_app.js14
-rw-r--r--opendc-web/opendc-web-ui/src/pages/_document.js13
-rw-r--r--opendc-web/opendc-web-ui/src/pages/projects/[project]/index.js6
-rw-r--r--opendc-web/opendc-web-ui/src/pages/projects/[project]/portfolios/[portfolio].js25
-rw-r--r--opendc-web/opendc-web-ui/src/pages/projects/[project]/topologies/[topology].js36
-rw-r--r--opendc-web/opendc-web-ui/src/pages/projects/index.js16
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/topology/building.js8
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/topology/index.js3
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/topology/rack.js2
-rw-r--r--opendc-web/opendc-web-ui/src/redux/actions/topology/room.js6
-rw-r--r--opendc-web/opendc-web-ui/src/redux/reducers/topology/machine.js2
-rw-r--r--opendc-web/opendc-web-ui/src/redux/reducers/topology/rack.js4
-rw-r--r--opendc-web/opendc-web-ui/src/redux/reducers/topology/room.js4
-rw-r--r--opendc-web/opendc-web-ui/src/redux/reducers/topology/tile.js4
-rw-r--r--opendc-web/opendc-web-ui/src/redux/reducers/topology/topology.js4
-rw-r--r--opendc-web/opendc-web-ui/src/redux/sagas/topology.js9
-rw-r--r--opendc-web/opendc-web-ui/src/shapes.js146
-rw-r--r--opendc-web/opendc-web-ui/src/util/authorizations.js12
-rw-r--r--opendc-web/opendc-web-ui/src/util/topology-schema.js18
-rw-r--r--opendc-web/opendc-web-ui/src/util/unit-specifications.js26
214 files changed, 7299 insertions, 6756 deletions
diff --git a/opendc-web/opendc-web-api/.coveragerc b/opendc-web/opendc-web-api/.coveragerc
deleted file mode 100644
index 55d99c2e..00000000
--- a/opendc-web/opendc-web-api/.coveragerc
+++ /dev/null
@@ -1,5 +0,0 @@
-[run]
-source = .
-omit =
- tests/*
- conftest.py
diff --git a/opendc-web/opendc-web-api/.dockerignore b/opendc-web/opendc-web-api/.dockerignore
deleted file mode 100644
index 06d67de9..00000000
--- a/opendc-web/opendc-web-api/.dockerignore
+++ /dev/null
@@ -1,8 +0,0 @@
-Dockerfile
-
-.idea/
-**/out
-*.iml
-.idea_modules/
-
-.pytest_cache
diff --git a/opendc-web/opendc-web-api/.gitignore b/opendc-web/opendc-web-api/.gitignore
deleted file mode 100644
index 9f8dfc5c..00000000
--- a/opendc-web/opendc-web-api/.gitignore
+++ /dev/null
@@ -1,19 +0,0 @@
-.DS_Store
-*.pyc
-*.pyo
-venv
-venv*
-dist
-build
-*.egg
-*.egg-info
-_mailinglist
-.tox
-.cache/
-.idea/
-config.json
-test.json
-.env*
-.coverage
-coverage.xml
-junit-report.xml
diff --git a/opendc-web/opendc-web-api/.pylintrc b/opendc-web/opendc-web-api/.pylintrc
deleted file mode 100644
index 4dbb0b50..00000000
--- a/opendc-web/opendc-web-api/.pylintrc
+++ /dev/null
@@ -1,523 +0,0 @@
-[MASTER]
-
-# A comma-separated list of package or module names from where C extensions may
-# be loaded. Extensions are loading into the active Python interpreter and may
-# run arbitrary code.
-extension-pkg-whitelist=
-
-# Specify a score threshold to be exceeded before program exits with error.
-fail-under=10
-
-# Add files or directories to the blacklist. They should be base names, not
-# paths.
-ignore=CVS
-
-# Add files or directories matching the regex patterns to the blacklist. The
-# regex matches against base names, not paths.
-ignore-patterns=
-
-# Python code to execute, usually for sys.path manipulation such as
-# pygtk.require().
-#init-hook=
-
-# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
-# number of processors available to use.
-jobs=1
-
-# Control the amount of potential inferred values when inferring a single
-# object. This can help the performance when dealing with large functions or
-# complex, nested conditions.
-limit-inference-results=100
-
-# List of plugins (as comma separated values of python module names) to load,
-# usually to register additional checkers.
-load-plugins=
-
-# Pickle collected data for later comparisons.
-persistent=yes
-
-# When enabled, pylint would attempt to guess common misconfiguration and emit
-# user-friendly hints instead of false-positive error messages.
-suggestion-mode=yes
-
-# Allow loading of arbitrary C extensions. Extensions are imported into the
-# active Python interpreter and may run arbitrary code.
-unsafe-load-any-extension=no
-
-
-[MESSAGES CONTROL]
-
-# Only show warnings with the listed confidence levels. Leave empty to show
-# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED.
-confidence=
-
-# Disable the message, report, category or checker with the given id(s). You
-# can either give multiple identifiers separated by comma (,) or put this
-# option multiple times (only on the command line, not in the configuration
-# file where it should appear only once). You can also use "--disable=all" to
-# disable everything first and then reenable specific checks. For example, if
-# you want to run only the similarities checker, you can use "--disable=all
-# --enable=similarities". If you want to run only the classes checker, but have
-# no Warning level messages displayed, use "--disable=all --enable=classes
-# --disable=W".
-disable=duplicate-code,
- missing-module-docstring,
- invalid-name,
- bare-except,
- too-few-public-methods,
- fixme,
- no-self-use
-
-# Enable the message, report, category or checker with the given id(s). You can
-# either give multiple identifier separated by comma (,) or put this option
-# multiple time (only on the command line, not in the configuration file where
-# it should appear only once). See also the "--disable" option for examples.
-enable=c-extension-no-member
-
-
-[REPORTS]
-
-# Python expression which should return a score less than or equal to 10. You
-# have access to the variables 'error', 'warning', 'refactor', and 'convention'
-# which contain the number of messages in each category, as well as 'statement'
-# which is the total number of statements analyzed. This score is used by the
-# global evaluation report (RP0004).
-evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
-
-# Template used to display messages. This is a python new-style format string
-# used to format the message information. See doc for all details.
-#msg-template=
-
-# Set the output format. Available formats are text, parseable, colorized, json
-# and msvs (visual studio). You can also give a reporter class, e.g.
-# mypackage.mymodule.MyReporterClass.
-output-format=text
-
-# Tells whether to display a full report or only the messages.
-reports=no
-
-# Activate the evaluation score.
-score=yes
-
-
-[REFACTORING]
-
-# Maximum number of nested blocks for function / method body
-max-nested-blocks=5
-
-# Complete name of functions that never returns. When checking for
-# inconsistent-return-statements if a never returning function is called then
-# it will be considered as an explicit return statement and no message will be
-# printed.
-never-returning-functions=sys.exit
-
-
-[LOGGING]
-
-# The type of string formatting that logging methods do. `old` means using %
-# formatting, `new` is for `{}` formatting.
-logging-format-style=old
-
-# Logging modules to check that the string format arguments are in logging
-# function parameter format.
-logging-modules=logging
-
-
-[SPELLING]
-
-# Limits count of emitted suggestions for spelling mistakes.
-max-spelling-suggestions=4
-
-# Spelling dictionary name. Available dictionaries: none. To make it work,
-# install the python-enchant package.
-spelling-dict=
-
-# List of comma separated words that should not be checked.
-spelling-ignore-words=
-
-# A path to a file that contains the private dictionary; one word per line.
-spelling-private-dict-file=
-
-# Tells whether to store unknown words to the private dictionary (see the
-# --spelling-private-dict-file option) instead of raising a message.
-spelling-store-unknown-words=no
-
-
-[MISCELLANEOUS]
-
-# List of note tags to take in consideration, separated by a comma.
-notes=FIXME,
- XXX,
- TODO
-
-# Regular expression of note tags to take in consideration.
-#notes-rgx=
-
-
-[TYPECHECK]
-
-# List of decorators that produce context managers, such as
-# contextlib.contextmanager. Add to this list to register other decorators that
-# produce valid context managers.
-contextmanager-decorators=contextlib.contextmanager
-
-# List of members which are set dynamically and missed by pylint inference
-# system, and so shouldn't trigger E1101 when accessed. Python regular
-# expressions are accepted.
-generated-members=
-
-# Tells whether missing members accessed in mixin class should be ignored. A
-# mixin class is detected if its name ends with "mixin" (case insensitive).
-ignore-mixin-members=yes
-
-# Tells whether to warn about missing members when the owner of the attribute
-# is inferred to be None.
-ignore-none=yes
-
-# This flag controls whether pylint should warn about no-member and similar
-# checks whenever an opaque object is returned when inferring. The inference
-# can return multiple potential results while evaluating a Python object, but
-# some branches might not be evaluated, which results in partial inference. In
-# that case, it might be useful to still emit no-member and other checks for
-# the rest of the inferred objects.
-ignore-on-opaque-inference=yes
-
-# List of class names for which member attributes should not be checked (useful
-# for classes with dynamically set attributes). This supports the use of
-# qualified names.
-ignored-classes=optparse.Values,thread._local,_thread._local
-
-# List of module names for which member attributes should not be checked
-# (useful for modules/projects where namespaces are manipulated during runtime
-# and thus existing member attributes cannot be deduced by static analysis). It
-# supports qualified module names, as well as Unix pattern matching.
-ignored-modules=
-
-# Show a hint with possible names when a member name was not found. The aspect
-# of finding the hint is based on edit distance.
-missing-member-hint=yes
-
-# The minimum edit distance a name should have in order to be considered a
-# similar match for a missing member name.
-missing-member-hint-distance=1
-
-# The total number of similar names that should be taken in consideration when
-# showing a hint for a missing member.
-missing-member-max-choices=1
-
-# List of decorators that change the signature of a decorated function.
-signature-mutators=
-
-
-[VARIABLES]
-
-# List of additional names supposed to be defined in builtins. Remember that
-# you should avoid defining new builtins when possible.
-additional-builtins=
-
-# Tells whether unused global variables should be treated as a violation.
-allow-global-unused-variables=yes
-
-# List of strings which can identify a callback function by name. A callback
-# name must start or end with one of those strings.
-callbacks=cb_,
- _cb
-
-# A regular expression matching the name of dummy variables (i.e. expected to
-# not be used).
-dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
-
-# Argument names that match this expression will be ignored. Default to name
-# with leading underscore.
-ignored-argument-names=_.*|^ignored_|^unused_
-
-# Tells whether we should check for unused import in __init__ files.
-init-import=no
-
-# List of qualified module names which can have objects that can redefine
-# builtins.
-redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
-
-
-[FORMAT]
-
-# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
-expected-line-ending-format=
-
-# Regexp for a line that is allowed to be longer than the limit.
-ignore-long-lines=^\s*(# )?<?https?://\S+>?$
-
-# Number of spaces of indent required inside a hanging or continued line.
-indent-after-paren=4
-
-# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
-# tab).
-indent-string=' '
-
-# Maximum number of characters on a single line.
-max-line-length=120
-
-# Maximum number of lines in a module.
-max-module-lines=1000
-
-# List of optional constructs for which whitespace checking is disabled. `dict-
-# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}.
-# `trailing-comma` allows a space between comma and closing bracket: (a, ).
-# `empty-line` allows space-only lines.
-no-space-check=trailing-comma,
- dict-separator
-
-# Allow the body of a class to be on the same line as the declaration if body
-# contains single statement.
-single-line-class-stmt=no
-
-# Allow the body of an if to be on the same line as the test if there is no
-# else.
-single-line-if-stmt=no
-
-
-[SIMILARITIES]
-
-# Ignore comments when computing similarities.
-ignore-comments=yes
-
-# Ignore docstrings when computing similarities.
-ignore-docstrings=yes
-
-# Ignore imports when computing similarities.
-ignore-imports=no
-
-# Minimum lines number of a similarity.
-min-similarity-lines=4
-
-
-[BASIC]
-
-# Naming style matching correct argument names.
-argument-naming-style=snake_case
-
-# Regular expression matching correct argument names. Overrides argument-
-# naming-style.
-#argument-rgx=
-
-# Naming style matching correct attribute names.
-attr-naming-style=snake_case
-
-# Regular expression matching correct attribute names. Overrides attr-naming-
-# style.
-#attr-rgx=
-
-# Bad variable names which should always be refused, separated by a comma.
-bad-names=foo,
- bar,
- baz,
- toto,
- tutu,
- tata
-
-# Bad variable names regexes, separated by a comma. If names match any regex,
-# they will always be refused
-bad-names-rgxs=
-
-# Naming style matching correct class attribute names.
-class-attribute-naming-style=any
-
-# Regular expression matching correct class attribute names. Overrides class-
-# attribute-naming-style.
-#class-attribute-rgx=
-
-# Naming style matching correct class names.
-class-naming-style=PascalCase
-
-# Regular expression matching correct class names. Overrides class-naming-
-# style.
-#class-rgx=
-
-# Naming style matching correct constant names.
-const-naming-style=UPPER_CASE
-
-# Regular expression matching correct constant names. Overrides const-naming-
-# style.
-#const-rgx=
-
-# Minimum line length for functions/classes that require docstrings, shorter
-# ones are exempt.
-docstring-min-length=-1
-
-# Naming style matching correct function names.
-function-naming-style=snake_case
-
-# Regular expression matching correct function names. Overrides function-
-# naming-style.
-#function-rgx=
-
-# Good variable names which should always be accepted, separated by a comma.
-good-names=i,
- j,
- k,
- ex,
- Run,
- _
-
-# Good variable names regexes, separated by a comma. If names match any regex,
-# they will always be accepted
-good-names-rgxs=
-
-# Include a hint for the correct naming format with invalid-name.
-include-naming-hint=no
-
-# Naming style matching correct inline iteration names.
-inlinevar-naming-style=any
-
-# Regular expression matching correct inline iteration names. Overrides
-# inlinevar-naming-style.
-#inlinevar-rgx=
-
-# Naming style matching correct method names.
-method-naming-style=snake_case
-
-# Regular expression matching correct method names. Overrides method-naming-
-# style.
-#method-rgx=
-
-# Naming style matching correct module names.
-module-naming-style=snake_case
-
-# Regular expression matching correct module names. Overrides module-naming-
-# style.
-#module-rgx=
-
-# Colon-delimited sets of names that determine each other's naming style when
-# the name regexes allow several styles.
-name-group=
-
-# Regular expression which should only match function or class names that do
-# not require a docstring.
-no-docstring-rgx=^_
-
-# List of decorators that produce properties, such as abc.abstractproperty. Add
-# to this list to register other decorators that produce valid properties.
-# These decorators are taken in consideration only for invalid-name.
-property-classes=abc.abstractproperty
-
-# Naming style matching correct variable names.
-variable-naming-style=snake_case
-
-# Regular expression matching correct variable names. Overrides variable-
-# naming-style.
-#variable-rgx=
-
-
-[STRING]
-
-# This flag controls whether inconsistent-quotes generates a warning when the
-# character used as a quote delimiter is used inconsistently within a module.
-check-quote-consistency=no
-
-# This flag controls whether the implicit-str-concat should generate a warning
-# on implicit string concatenation in sequences defined over several lines.
-check-str-concat-over-line-jumps=no
-
-
-[IMPORTS]
-
-# List of modules that can be imported at any level, not just the top level
-# one.
-allow-any-import-level=
-
-# Allow wildcard imports from modules that define __all__.
-allow-wildcard-with-all=no
-
-# Analyse import fallback blocks. This can be used to support both Python 2 and
-# 3 compatible code, which means that the block might have code that exists
-# only in one or another interpreter, leading to false positives when analysed.
-analyse-fallback-blocks=no
-
-# Deprecated modules which should not be used, separated by a comma.
-deprecated-modules=optparse,tkinter.tix
-
-# Create a graph of external dependencies in the given file (report RP0402 must
-# not be disabled).
-ext-import-graph=
-
-# Create a graph of every (i.e. internal and external) dependencies in the
-# given file (report RP0402 must not be disabled).
-import-graph=
-
-# Create a graph of internal dependencies in the given file (report RP0402 must
-# not be disabled).
-int-import-graph=
-
-# Force import order to recognize a module as part of the standard
-# compatibility libraries.
-known-standard-library=
-
-# Force import order to recognize a module as part of a third party library.
-known-third-party=enchant
-
-# Couples of modules and preferred modules, separated by a comma.
-preferred-modules=
-
-
-[CLASSES]
-
-# List of method names used to declare (i.e. assign) instance attributes.
-defining-attr-methods=__init__,
- __new__,
- setUp,
- __post_init__
-
-# List of member names, which should be excluded from the protected access
-# warning.
-exclude-protected=_asdict,
- _fields,
- _replace,
- _source,
- _make
-
-# List of valid names for the first argument in a class method.
-valid-classmethod-first-arg=cls
-
-# List of valid names for the first argument in a metaclass class method.
-valid-metaclass-classmethod-first-arg=cls
-
-
-[DESIGN]
-
-# Maximum number of arguments for function / method.
-max-args=5
-
-# Maximum number of attributes for a class (see R0902).
-max-attributes=12
-
-# Maximum number of boolean expressions in an if statement (see R0916).
-max-bool-expr=5
-
-# Maximum number of branch for function / method body.
-max-branches=12
-
-# Maximum number of locals for function / method body.
-max-locals=15
-
-# Maximum number of parents for a class (see R0901).
-max-parents=7
-
-# Maximum number of public methods for a class (see R0904).
-max-public-methods=20
-
-# Maximum number of return / yield for function / method body.
-max-returns=6
-
-# Maximum number of statements in function / method body.
-max-statements=50
-
-# Minimum number of public methods for a class (see R0903).
-min-public-methods=2
-
-
-[EXCEPTIONS]
-
-# Exceptions that will emit a warning when being caught. Defaults to
-# "BaseException, Exception".
-overgeneral-exceptions=BaseException,
- Exception
diff --git a/opendc-web/opendc-web-api/.style.yapf b/opendc-web/opendc-web-api/.style.yapf
deleted file mode 100644
index f5c26c57..00000000
--- a/opendc-web/opendc-web-api/.style.yapf
+++ /dev/null
@@ -1,3 +0,0 @@
-[style]
-based_on_style = pep8
-column_limit=120
diff --git a/opendc-web/opendc-web-api/Dockerfile b/opendc-web/opendc-web-api/Dockerfile
index 505a69de..ff300170 100644
--- a/opendc-web/opendc-web-api/Dockerfile
+++ b/opendc-web/opendc-web-api/Dockerfile
@@ -1,23 +1,17 @@
-FROM python:3.9-slim
+FROM openjdk:17-slim
MAINTAINER OpenDC Maintainers <opendc@atlarge-research.com>
-# Ensure the STDOUT is not buffered by Python so that our logs become visible
-# See https://stackoverflow.com/q/29663459/10213073
-ENV PYTHONUNBUFFERED 1
+# Obtain (cache) Gradle wrapper
+COPY gradlew /app/
+COPY gradle /app/gradle
+WORKDIR /app
+RUN ./gradlew --version
-# Copy OpenDC directory
-COPY ./ /opendc
+# Build project
+COPY ./ /app/
+RUN ./gradlew --no-daemon :opendc-web:opendc-web-api:build
-# Fetch web server dependencies
-RUN pip install -r /opendc/requirements.txt && pip install pyuwsgi
-
-# Create opendc user
-RUN groupadd --gid 1000 opendc \
- && useradd --uid 1000 --gid opendc --shell /bin/bash --create-home opendc
-RUN chown -R opendc:opendc /opendc
-USER opendc
-
-# Set working directory
-WORKDIR /opendc
-
-CMD uwsgi -M --socket 0.0.0.0:80 --protocol=http --wsgi-file app.py --enable-threads --processes 2 --lazy-app
+FROM openjdk:17-slim
+COPY --from=0 /app/opendc-web/opendc-web-api/build/quarkus-app /opt/opendc
+WORKDIR /opt/opendc
+CMD java -jar quarkus-run.jar
diff --git a/opendc-web/opendc-web-api/README.md b/opendc-web/opendc-web-api/README.md
deleted file mode 100644
index d1c469c1..00000000
--- a/opendc-web/opendc-web-api/README.md
+++ /dev/null
@@ -1,122 +0,0 @@
-<h1 align="center">
- <img src="../../docs/images/logo.png" width="100" alt="OpenDC">
- <br>
- OpenDC Web Server
-</h1>
-<p align="center">
- Collaborative Datacenter Simulation and Exploration for Everybody
-</p>
-
-<br>
-
-The OpenDC web server is the bridge between OpenDC's frontend and database. It is built with Flask/SocketIO in Python
-and implements the OpenAPI-compliant [OpenDC API specification](../../opendc-api-spec.yml).
-
-This document explains a high-level view of the web server architecture ([jump](#architecture)), and describes how to
-set up the web server for local development ([jump](#setup-for-local-development)).
-
-## Architecture
-
-The following diagram shows a high-level view of the architecture of the OpenDC web server. Squared-off colored boxes
-indicate packages (colors become more saturated as packages are nested); rounded-off boxes indicate individual
-components; dotted lines indicate control flow; and solid lines indicate data flow.
-
-![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..853632a7 100644
--- a/opendc-web/opendc-web-api/build.gradle.kts
+++ b/opendc-web/opendc-web-api/build.gradle.kts
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 AtLarge Research
+ * Copyright (c) 2020 AtLarge Research
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -19,3 +19,75 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
+
+description = "REST API for the OpenDC website"
+
+/* Build configuration */
+plugins {
+ `kotlin-conventions`
+ kotlin("plugin.allopen")
+ kotlin("plugin.jpa")
+ `testing-conventions`
+ `jacoco-conventions`
+ id("io.quarkus")
+}
+
+dependencies {
+ implementation(enforcedPlatform(libs.quarkus.bom))
+
+ implementation(projects.opendcWeb.opendcWebProto)
+
+ implementation(libs.quarkus.kotlin)
+ implementation(libs.quarkus.resteasy.core)
+ implementation(libs.quarkus.resteasy.jackson)
+ implementation(libs.jackson.module.kotlin)
+ implementation(libs.quarkus.smallrye.openapi)
+
+ implementation(libs.quarkus.security)
+ implementation(libs.quarkus.oidc)
+
+ implementation(libs.quarkus.hibernate.orm)
+ implementation(libs.quarkus.hibernate.validator)
+ implementation(libs.quarkus.jdbc.postgresql)
+ quarkusDev(libs.quarkus.jdbc.h2)
+
+ testImplementation(libs.quarkus.junit5.core)
+ testImplementation(libs.quarkus.junit5.mockk)
+ testImplementation(libs.quarkus.jacoco)
+ testImplementation(libs.restassured.core)
+ testImplementation(libs.restassured.kotlin)
+ testImplementation(libs.quarkus.test.security)
+ testImplementation(libs.quarkus.jdbc.h2)
+}
+
+allOpen {
+ annotation("javax.ws.rs.Path")
+ annotation("javax.enterprise.context.ApplicationScoped")
+ annotation("io.quarkus.test.junit.QuarkusTest")
+ annotation("javax.persistence.Entity")
+}
+
+tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
+ kotlinOptions.javaParameters = true
+}
+
+tasks.quarkusDev {
+ workingDir = rootProject.projectDir.toString()
+}
+
+tasks.test {
+ extensions.configure(JacocoTaskExtension::class) {
+ excludeClassLoaders = listOf("*QuarkusClassLoader")
+ // destinationFile = layout.buildDirectory.file("jacoco-quarkus.exec").get().asFile
+ }
+}
+
+/* Fix for Quarkus/ktlint-gradle incompatibilities */
+tasks.named("runKtlintCheckOverMainSourceSet").configure {
+ mustRunAfter(tasks.quarkusGenerateCode)
+ mustRunAfter(tasks.quarkusGenerateCodeDev)
+}
+
+tasks.named("runKtlintCheckOverTestSourceSet").configure {
+ mustRunAfter(tasks.quarkusGenerateCodeTests)
+}
diff --git a/opendc-web/opendc-web-api/check.sh b/opendc-web/opendc-web-api/check.sh
deleted file mode 100755
index abe2c596..00000000
--- a/opendc-web/opendc-web-api/check.sh
+++ /dev/null
@@ -1 +0,0 @@
-pylint opendc --ignore-patterns=test_.*?py
diff --git a/opendc-web/opendc-web-api/conftest.py b/opendc-web/opendc-web-api/conftest.py
deleted file mode 100644
index 958a5894..00000000
--- a/opendc-web/opendc-web-api/conftest.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""
-Configuration file for all unit tests.
-"""
-
-from functools import wraps
-import pytest
-from flask import _request_ctx_stack, g
-from opendc.database import Database
-
-
-def requires_auth_mock(f):
- @wraps(f)
- def decorated_function(*args, **kwargs):
- _request_ctx_stack.top.current_user = {'sub': 'test'}
- return f(*args, **kwargs)
- return decorated_function
-
-
-def requires_scope_mock(required_scope):
- def decorator(f):
- @wraps(f)
- def decorated_function(*args, **kwargs):
- return f(*args, **kwargs)
- return decorated_function
- return decorator
-
-
-@pytest.fixture
-def client():
- """Returns a Flask API client to interact with."""
-
- # Disable authorization for test API endpoints
- from opendc import exts
- exts.requires_auth = requires_auth_mock
- exts.requires_scope = requires_scope_mock
- exts.has_scope = lambda x: False
-
- from app import create_app
-
- app = create_app(testing=True)
-
- with app.app_context():
- g.db = Database()
- with app.test_client() as client:
- yield client
diff --git a/opendc-web/opendc-web-api/docs/component-diagram.png b/opendc-web/opendc-web-api/docs/component-diagram.png
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-runner/src/main/kotlin/org/opendc/web/client/model/SimulationState.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/OpenDCApplication.kt
index 2eadd747..ddbd5390 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/SimulationState.kt
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/OpenDCApplication.kt
@@ -20,11 +20,11 @@
* SOFTWARE.
*/
-package org.opendc.web.client.model
+package org.opendc.web.api
+
+import javax.ws.rs.core.Application
/**
- * The state of a simulation job.
+ * [Application] definition for the OpenDC web API.
*/
-public enum class SimulationState {
- QUEUED, CLAIMED, RUNNING, FINISHED, FAILED
-}
+class OpenDCApplication : Application()
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Job.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Job.kt
new file mode 100644
index 00000000..b09b46a1
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Job.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.model
+
+import org.hibernate.annotations.Type
+import org.hibernate.annotations.TypeDef
+import org.opendc.web.api.util.hibernate.json.JsonType
+import org.opendc.web.proto.JobState
+import java.time.Instant
+import javax.persistence.*
+
+/**
+ * A simulation job to be run by the simulator.
+ */
+@TypeDef(name = "json", typeClass = JsonType::class)
+@Entity
+@Table(name = "jobs")
+@NamedQueries(
+ value = [
+ NamedQuery(
+ name = "Job.findAll",
+ query = "SELECT j FROM Job j WHERE j.state = :state"
+ ),
+ NamedQuery(
+ name = "Job.updateOne",
+ query = """
+ UPDATE Job j
+ SET j.state = :newState, j.updatedAt = :updatedAt, j.results = :results
+ WHERE j.id = :id AND j.state = :oldState
+ """
+ )
+ ]
+)
+class Job(
+ @Id
+ @GeneratedValue(strategy = GenerationType.AUTO)
+ val id: Long,
+
+ @OneToOne(optional = false, mappedBy = "job", fetch = FetchType.EAGER)
+ @JoinColumn(name = "scenario_id", nullable = false)
+ val scenario: Scenario,
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ val createdAt: Instant,
+
+ /**
+ * The number of simulation runs to perform.
+ */
+ @Column(nullable = false, updatable = false)
+ val repeats: Int
+) {
+ /**
+ * The instant at which the job was updated.
+ */
+ @Column(name = "updated_at", nullable = false)
+ var updatedAt: Instant = createdAt
+
+ /**
+ * The state of the job.
+ */
+ @Column(nullable = false)
+ var state: JobState = JobState.PENDING
+
+ /**
+ * Experiment results in JSON
+ */
+ @Type(type = "json")
+ @Column(columnDefinition = "jsonb")
+ var results: Map<String, Any>? = null
+
+ /**
+ * Return a string representation of this job.
+ */
+ override fun toString(): String = "Job[id=$id,scenario=${scenario.id},state=$state]"
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Portfolio.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Portfolio.kt
new file mode 100644
index 00000000..c8b94daf
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Portfolio.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.model
+
+import org.hibernate.annotations.Type
+import org.hibernate.annotations.TypeDef
+import org.opendc.web.api.util.hibernate.json.JsonType
+import org.opendc.web.proto.Targets
+import javax.persistence.*
+
+/**
+ * A portfolio is the composition of multiple scenarios.
+ */
+@TypeDef(name = "json", typeClass = JsonType::class)
+@Entity
+@Table(
+ name = "portfolios",
+ uniqueConstraints = [UniqueConstraint(columnNames = ["project_id", "number"])],
+ indexes = [Index(name = "fn_portfolios_number", columnList = "project_id, number")]
+)
+@NamedQueries(
+ value = [
+ NamedQuery(
+ name = "Portfolio.findAll",
+ query = "SELECT p FROM Portfolio p WHERE p.project.id = :projectId"
+ ),
+ NamedQuery(
+ name = "Portfolio.findOne",
+ query = "SELECT p FROM Portfolio p WHERE p.project.id = :projectId AND p.number = :number"
+ )
+ ]
+)
+class Portfolio(
+ @Id
+ @GeneratedValue(strategy = GenerationType.AUTO)
+ val id: Long,
+
+ /**
+ * Unique number of the portfolio for the project.
+ */
+ @Column(nullable = false)
+ val number: Int,
+
+ @Column(nullable = false)
+ val name: String,
+
+ @ManyToOne(optional = false)
+ @JoinColumn(name = "project_id", nullable = false)
+ val project: Project,
+
+ /**
+ * The portfolio targets (metrics, repetitions).
+ */
+ @Type(type = "json")
+ @Column(columnDefinition = "jsonb", nullable = false, updatable = false)
+ val targets: Targets,
+) {
+ /**
+ * The scenarios in this portfolio.
+ */
+ @OneToMany(cascade = [CascadeType.ALL], mappedBy = "portfolio", orphanRemoval = true)
+ @OrderBy("id ASC")
+ val scenarios: MutableSet<Scenario> = mutableSetOf()
+
+ /**
+ * Return a string representation of this portfolio.
+ */
+ override fun toString(): String = "Job[id=$id,name=$name,project=${project.id},targets=$targets]"
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Project.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Project.kt
new file mode 100644
index 00000000..e0440bf4
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Project.kt
@@ -0,0 +1,134 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.model
+
+import java.time.Instant
+import javax.persistence.*
+
+/**
+ * A project in OpenDC encapsulates all the datacenter designs and simulation runs for a set of users.
+ */
+@Entity
+@Table(name = "projects")
+@NamedQueries(
+ value = [
+ NamedQuery(
+ name = "Project.findAll",
+ query = """
+ SELECT a
+ FROM ProjectAuthorization a
+ WHERE a.key.userId = :userId
+ """
+ ),
+ NamedQuery(
+ name = "Project.allocatePortfolio",
+ query = """
+ UPDATE Project p
+ SET p.portfoliosCreated = :oldState + 1, p.updatedAt = :now
+ WHERE p.id = :id AND p.portfoliosCreated = :oldState
+ """
+ ),
+ NamedQuery(
+ name = "Project.allocateTopology",
+ query = """
+ UPDATE Project p
+ SET p.topologiesCreated = :oldState + 1, p.updatedAt = :now
+ WHERE p.id = :id AND p.topologiesCreated = :oldState
+ """
+ ),
+ NamedQuery(
+ name = "Project.allocateScenario",
+ query = """
+ UPDATE Project p
+ SET p.scenariosCreated = :oldState + 1, p.updatedAt = :now
+ WHERE p.id = :id AND p.scenariosCreated = :oldState
+ """
+ )
+ ]
+)
+class Project(
+ @Id
+ @GeneratedValue(strategy = GenerationType.AUTO)
+ val id: Long,
+
+ @Column(nullable = false)
+ var name: String,
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ val createdAt: Instant,
+) {
+ /**
+ * The instant at which the project was updated.
+ */
+ @Column(name = "updated_at", nullable = false)
+ var updatedAt: Instant = createdAt
+
+ /**
+ * The portfolios belonging to this project.
+ */
+ @OneToMany(cascade = [CascadeType.ALL], mappedBy = "project", orphanRemoval = true)
+ @OrderBy("id ASC")
+ val portfolios: MutableSet<Portfolio> = mutableSetOf()
+
+ /**
+ * The number of portfolios created for this project (including deleted portfolios).
+ */
+ @Column(name = "portfolios_created", nullable = false)
+ var portfoliosCreated: Int = 0
+
+ /**
+ * The topologies belonging to this project.
+ */
+ @OneToMany(cascade = [CascadeType.ALL], mappedBy = "project", orphanRemoval = true)
+ @OrderBy("id ASC")
+ val topologies: MutableSet<Topology> = mutableSetOf()
+
+ /**
+ * The number of topologies created for this project (including deleted topologies).
+ */
+ @Column(name = "topologies_created", nullable = false)
+ var topologiesCreated: Int = 0
+
+ /**
+ * The scenarios belonging to this project.
+ */
+ @OneToMany(mappedBy = "project", orphanRemoval = true)
+ val scenarios: MutableSet<Scenario> = mutableSetOf()
+
+ /**
+ * The number of scenarios created for this project (including deleted scenarios).
+ */
+ @Column(name = "scenarios_created", nullable = false)
+ var scenariosCreated: Int = 0
+
+ /**
+ * The users authorized to access the project.
+ */
+ @OneToMany(cascade = [CascadeType.ALL], mappedBy = "project", orphanRemoval = true)
+ val authorizations: MutableSet<ProjectAuthorization> = mutableSetOf()
+
+ /**
+ * Return a string representation of this project.
+ */
+ override fun toString(): String = "Project[id=$id,name=$name]"
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/ProjectAuthorization.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/ProjectAuthorization.kt
new file mode 100644
index 00000000..a72ff06a
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/ProjectAuthorization.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.model
+
+import org.opendc.web.proto.user.ProjectRole
+import javax.persistence.*
+
+/**
+ * An authorization for some user to participate in a project.
+ */
+@Entity
+@Table(name = "project_authorizations")
+class ProjectAuthorization(
+ /**
+ * The user identifier of the authorization.
+ */
+ @EmbeddedId
+ val key: ProjectAuthorizationKey,
+
+ /**
+ * The project that the user is authorized to participate in.
+ */
+ @ManyToOne(optional = false)
+ @MapsId("projectId")
+ @JoinColumn(name = "project_id", updatable = false, insertable = false, nullable = false)
+ val project: Project,
+
+ /**
+ * The role of the user in the project.
+ */
+ @Column(nullable = false)
+ val role: ProjectRole
+) {
+ /**
+ * Return a string representation of this project authorization.
+ */
+ override fun toString(): String = "ProjectAuthorization[project=${key.projectId},user=${key.userId},role=$role]"
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/ProjectAuthorizationKey.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/ProjectAuthorizationKey.kt
new file mode 100644
index 00000000..b5f66e70
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/ProjectAuthorizationKey.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.model
+
+import javax.persistence.Column
+import javax.persistence.Embeddable
+
+/**
+ * Key for representing a [ProjectAuthorization] object.
+ */
+@Embeddable
+data class ProjectAuthorizationKey(
+ @Column(name = "user_id", nullable = false)
+ val userId: String,
+
+ @Column(name = "project_id", nullable = false)
+ val projectId: Long
+) : java.io.Serializable
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Scenario.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Scenario.kt
new file mode 100644
index 00000000..5c9cb259
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Scenario.kt
@@ -0,0 +1,107 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.model
+
+import org.hibernate.annotations.Type
+import org.hibernate.annotations.TypeDef
+import org.opendc.web.api.util.hibernate.json.JsonType
+import org.opendc.web.proto.OperationalPhenomena
+import javax.persistence.*
+
+/**
+ * A single scenario to be explored by the simulator.
+ */
+@TypeDef(name = "json", typeClass = JsonType::class)
+@Entity
+@Table(
+ name = "scenarios",
+ uniqueConstraints = [UniqueConstraint(columnNames = ["project_id", "number"])],
+ indexes = [Index(name = "fn_scenarios_number", columnList = "project_id, number")]
+)
+@NamedQueries(
+ value = [
+ NamedQuery(
+ name = "Scenario.findAll",
+ query = "SELECT s FROM Scenario s WHERE s.project.id = :projectId"
+ ),
+ NamedQuery(
+ name = "Scenario.findAllForPortfolio",
+ query = """
+ SELECT s
+ FROM Scenario s
+ JOIN Portfolio p ON p.id = s.portfolio.id AND p.number = :number
+ WHERE s.project.id = :projectId
+ """
+ ),
+ NamedQuery(
+ name = "Scenario.findOne",
+ query = "SELECT s FROM Scenario s WHERE s.project.id = :projectId AND s.number = :number"
+ )
+ ]
+)
+class Scenario(
+ @Id
+ @GeneratedValue(strategy = GenerationType.AUTO)
+ val id: Long,
+
+ /**
+ * Unique number of the scenario for the project.
+ */
+ @Column(nullable = false)
+ val number: Int,
+
+ @Column(nullable = false, updatable = false)
+ val name: String,
+
+ @ManyToOne(optional = false)
+ @JoinColumn(name = "project_id", nullable = false)
+ val project: Project,
+
+ @ManyToOne(optional = false)
+ @JoinColumn(name = "portfolio_id", nullable = false)
+ val portfolio: Portfolio,
+
+ @Embedded
+ val workload: Workload,
+
+ @ManyToOne(optional = false)
+ val topology: Topology,
+
+ @Type(type = "json")
+ @Column(columnDefinition = "jsonb", nullable = false, updatable = false)
+ val phenomena: OperationalPhenomena,
+
+ @Column(name = "scheduler_name", nullable = false, updatable = false)
+ val schedulerName: String,
+) {
+ /**
+ * The [Job] associated with the scenario.
+ */
+ @OneToOne(cascade = [CascadeType.ALL])
+ lateinit var job: Job
+
+ /**
+ * Return a string representation of this scenario.
+ */
+ override fun toString(): String = "Scenario[id=$id,name=$name,project=${project.id},portfolio=${portfolio.id}]"
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Topology.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Topology.kt
new file mode 100644
index 00000000..9b64e382
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Topology.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.model
+
+import org.hibernate.annotations.Type
+import org.hibernate.annotations.TypeDef
+import org.opendc.web.api.util.hibernate.json.JsonType
+import org.opendc.web.proto.Room
+import java.time.Instant
+import javax.persistence.*
+
+/**
+ * A datacenter design in OpenDC.
+ */
+@TypeDef(name = "json", typeClass = JsonType::class)
+@Entity
+@Table(
+ name = "topologies",
+ uniqueConstraints = [UniqueConstraint(columnNames = ["project_id", "number"])],
+ indexes = [Index(name = "fn_topologies_number", columnList = "project_id, number")]
+)
+@NamedQueries(
+ value = [
+ NamedQuery(
+ name = "Topology.findAll",
+ query = "SELECT t FROM Topology t WHERE t.project.id = :projectId"
+ ),
+ NamedQuery(
+ name = "Topology.findOne",
+ query = "SELECT t FROM Topology t WHERE t.project.id = :projectId AND t.number = :number"
+ )
+ ]
+)
+class Topology(
+ @Id
+ @GeneratedValue(strategy = GenerationType.AUTO)
+ val id: Long,
+
+ /**
+ * Unique number of the topology for the project.
+ */
+ @Column(nullable = false)
+ val number: Int,
+
+ @Column(nullable = false)
+ val name: String,
+
+ @ManyToOne(optional = false)
+ @JoinColumn(name = "project_id", nullable = false)
+ val project: Project,
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ val createdAt: Instant,
+
+ /**
+ * Datacenter design in JSON
+ */
+ @Type(type = "json")
+ @Column(columnDefinition = "jsonb", nullable = false)
+ var rooms: List<Room> = emptyList()
+) {
+ /**
+ * The instant at which the topology was updated.
+ */
+ @Column(name = "updated_at", nullable = false)
+ var updatedAt: Instant = createdAt
+
+ /**
+ * Return a string representation of this topology.
+ */
+ override fun toString(): String = "Topology[id=$id,name=$name,project=${project.id}]"
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Trace.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Trace.kt
new file mode 100644
index 00000000..2e2d71f8
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Trace.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2021 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.model
+
+import javax.persistence.*
+
+/**
+ * A workload trace available for simulation.
+ *
+ * @param id The unique identifier of the trace.
+ * @param name The name of the trace.
+ * @param type The type of trace.
+ */
+@Entity
+@Table(name = "traces")
+@NamedQueries(
+ value = [
+ NamedQuery(
+ name = "Trace.findAll",
+ query = "SELECT t FROM Trace t"
+ ),
+ ]
+)
+class Trace(
+ @Id
+ val id: String,
+
+ @Column(nullable = false, updatable = false)
+ val name: String,
+
+ @Column(nullable = false, updatable = false)
+ val type: String,
+) {
+ /**
+ * Return a string representation of this trace.
+ */
+ override fun toString(): String = "Trace[id=$id,name=$name,type=$type]"
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Workload.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Workload.kt
new file mode 100644
index 00000000..07fc096b
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/model/Workload.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.model
+
+import javax.persistence.Column
+import javax.persistence.Embeddable
+import javax.persistence.ManyToOne
+
+/**
+ * Specification of the workload for a [Scenario].
+ */
+@Embeddable
+class Workload(
+ @ManyToOne(optional = false)
+ val trace: Trace,
+
+ @Column(name = "sampling_fraction", nullable = false, updatable = false)
+ val samplingFraction: Double
+)
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/JobRepository.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/JobRepository.kt
new file mode 100644
index 00000000..558d7c38
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/JobRepository.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.repository
+
+import org.opendc.web.api.model.Job
+import org.opendc.web.proto.JobState
+import java.time.Instant
+import javax.enterprise.context.ApplicationScoped
+import javax.inject.Inject
+import javax.persistence.EntityManager
+
+/**
+ * A repository to manage [Job] entities.
+ */
+@ApplicationScoped
+class JobRepository @Inject constructor(private val em: EntityManager) {
+ /**
+ * Find all jobs currently residing in [state].
+ *
+ * @param state The state in which the jobs should be.
+ * @return The list of jobs in state [state].
+ */
+ fun findAll(state: JobState): List<Job> {
+ return em.createNamedQuery("Job.findAll", Job::class.java)
+ .setParameter("state", state)
+ .resultList
+ }
+
+ /**
+ * Find the [Job] with the specified [id].
+ *
+ * @param id The unique identifier of the job.
+ * @return The trace or `null` if it does not exist.
+ */
+ fun findOne(id: Long): Job? {
+ return em.find(Job::class.java, id)
+ }
+
+ /**
+ * Delete the specified [job].
+ */
+ fun delete(job: Job) {
+ em.remove(job)
+ }
+
+ /**
+ * Save the specified [job] to the database.
+ */
+ fun save(job: Job) {
+ em.persist(job)
+ }
+
+ /**
+ * Atomically update the specified [job].
+ *
+ * @param job The job to update atomically.
+ * @param newState The new state to enter into.
+ * @param time The time at which the update occurs.
+ * @param results The results to possible set.
+ * @return `true` when the update succeeded`, `false` when there was a conflict.
+ */
+ fun updateOne(job: Job, newState: JobState, time: Instant, results: Map<String, Any>?): Boolean {
+ val count = em.createNamedQuery("Job.updateOne")
+ .setParameter("id", job.id)
+ .setParameter("oldState", job.state)
+ .setParameter("newState", newState)
+ .setParameter("updatedAt", Instant.now())
+ .setParameter("results", results)
+ .executeUpdate()
+ em.refresh(job)
+ return count > 0
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/PortfolioRepository.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/PortfolioRepository.kt
new file mode 100644
index 00000000..34b3598c
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/PortfolioRepository.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.repository
+
+import org.opendc.web.api.model.Portfolio
+import javax.enterprise.context.ApplicationScoped
+import javax.inject.Inject
+import javax.persistence.EntityManager
+
+/**
+ * A repository to manage [Portfolio] entities.
+ */
+@ApplicationScoped
+class PortfolioRepository @Inject constructor(private val em: EntityManager) {
+ /**
+ * Find all [Portfolio]s that belong to [project][projectId].
+ *
+ * @param projectId The unique identifier of the project.
+ * @return The list of portfolios that belong to the specified project.
+ */
+ fun findAll(projectId: Long): List<Portfolio> {
+ return em.createNamedQuery("Portfolio.findAll", Portfolio::class.java)
+ .setParameter("projectId", projectId)
+ .resultList
+ }
+
+ /**
+ * Find the [Portfolio] with the specified [number] belonging to [project][projectId].
+ *
+ * @param projectId The unique identifier of the project.
+ * @param number The number of the portfolio.
+ * @return The portfolio or `null` if it does not exist.
+ */
+ fun findOne(projectId: Long, number: Int): Portfolio? {
+ return em.createNamedQuery("Portfolio.findOne", Portfolio::class.java)
+ .setParameter("projectId", projectId)
+ .setParameter("number", number)
+ .setMaxResults(1)
+ .resultList
+ .firstOrNull()
+ }
+
+ /**
+ * Delete the specified [portfolio].
+ */
+ fun delete(portfolio: Portfolio) {
+ em.remove(portfolio)
+ }
+
+ /**
+ * Save the specified [portfolio] to the database.
+ */
+ fun save(portfolio: Portfolio) {
+ em.persist(portfolio)
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/ProjectRepository.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/ProjectRepository.kt
new file mode 100644
index 00000000..6529f778
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/ProjectRepository.kt
@@ -0,0 +1,157 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.repository
+
+import org.opendc.web.api.model.Project
+import org.opendc.web.api.model.ProjectAuthorization
+import org.opendc.web.api.model.ProjectAuthorizationKey
+import java.time.Instant
+import javax.enterprise.context.ApplicationScoped
+import javax.inject.Inject
+import javax.persistence.EntityManager
+
+/**
+ * A repository to manage [Project] entities.
+ */
+@ApplicationScoped
+class ProjectRepository @Inject constructor(private val em: EntityManager) {
+ /**
+ * List all projects for the user with the specified [userId].
+ *
+ * @param userId The identifier of the user that is requesting the list of projects.
+ * @return A list of projects that the user has received authorization for.
+ */
+ fun findAll(userId: String): List<ProjectAuthorization> {
+ return em.createNamedQuery("Project.findAll", ProjectAuthorization::class.java)
+ .setParameter("userId", userId)
+ .resultList
+ }
+
+ /**
+ * Find the project with [id] for the user with the specified [userId].
+ *
+ * @param userId The identifier of the user that is requesting the list of projects.
+ * @param id The unique identifier of the project.
+ * @return The project with the specified identifier or `null` if it does not exist or is not accessible to the
+ * user with the specified identifier.
+ */
+ fun findOne(userId: String, id: Long): ProjectAuthorization? {
+ return em.find(ProjectAuthorization::class.java, ProjectAuthorizationKey(userId, id))
+ }
+
+ /**
+ * Delete the specified [project].
+ */
+ fun delete(project: Project) {
+ em.remove(project)
+ }
+
+ /**
+ * Save the specified [project] to the database.
+ */
+ fun save(project: Project) {
+ em.persist(project)
+ }
+
+ /**
+ * Save the specified [auth] to the database.
+ */
+ fun save(auth: ProjectAuthorization) {
+ em.persist(auth)
+ }
+
+ /**
+ * Allocate the next portfolio number for the specified [project].
+ *
+ * @param project The project to allocate the portfolio number for.
+ * @param time The time at which the new portfolio is created.
+ * @param tries The number of times to try to allocate the number before failing.
+ */
+ fun allocatePortfolio(project: Project, time: Instant, tries: Int = 4): Int {
+ repeat(tries) {
+ val count = em.createNamedQuery("Project.allocatePortfolio")
+ .setParameter("id", project.id)
+ .setParameter("oldState", project.portfoliosCreated)
+ .setParameter("now", time)
+ .executeUpdate()
+
+ if (count > 0) {
+ return project.portfoliosCreated + 1
+ } else {
+ em.refresh(project)
+ }
+ }
+
+ throw IllegalStateException("Failed to allocate next portfolio")
+ }
+
+ /**
+ * Allocate the next topology number for the specified [project].
+ *
+ * @param project The project to allocate the topology number for.
+ * @param time The time at which the new topology is created.
+ * @param tries The number of times to try to allocate the number before failing.
+ */
+ fun allocateTopology(project: Project, time: Instant, tries: Int = 4): Int {
+ repeat(tries) {
+ val count = em.createNamedQuery("Project.allocateTopology")
+ .setParameter("id", project.id)
+ .setParameter("oldState", project.topologiesCreated)
+ .setParameter("now", time)
+ .executeUpdate()
+
+ if (count > 0) {
+ return project.topologiesCreated + 1
+ } else {
+ em.refresh(project)
+ }
+ }
+
+ throw IllegalStateException("Failed to allocate next topology")
+ }
+
+ /**
+ * Allocate the next scenario number for the specified [project].
+ *
+ * @param project The project to allocate the scenario number for.
+ * @param time The time at which the new scenario is created.
+ * @param tries The number of times to try to allocate the number before failing.
+ */
+ fun allocateScenario(project: Project, time: Instant, tries: Int = 4): Int {
+ repeat(tries) {
+ val count = em.createNamedQuery("Project.allocateScenario")
+ .setParameter("id", project.id)
+ .setParameter("oldState", project.scenariosCreated)
+ .setParameter("now", time)
+ .executeUpdate()
+
+ if (count > 0) {
+ return project.scenariosCreated + 1
+ } else {
+ em.refresh(project)
+ }
+ }
+
+ throw IllegalStateException("Failed to allocate next scenario")
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/ScenarioRepository.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/ScenarioRepository.kt
new file mode 100644
index 00000000..de116ad6
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/ScenarioRepository.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.repository
+
+import org.opendc.web.api.model.Scenario
+import javax.enterprise.context.ApplicationScoped
+import javax.inject.Inject
+import javax.persistence.EntityManager
+
+/**
+ * A repository to manage [Scenario] entities.
+ */
+@ApplicationScoped
+class ScenarioRepository @Inject constructor(private val em: EntityManager) {
+ /**
+ * Find all [Scenario]s that belong to [project][projectId].
+ *
+ * @param projectId The unique identifier of the project.
+ * @return The list of scenarios that belong to the specified project.
+ */
+ fun findAll(projectId: Long): List<Scenario> {
+ return em.createNamedQuery("Scenario.findAll", Scenario::class.java)
+ .setParameter("projectId", projectId)
+ .resultList
+ }
+
+ /**
+ * Find all [Scenario]s that belong to [portfolio][number] of [project][projectId].
+ *
+ * @param projectId The unique identifier of the project.
+ * @param number The number of the portfolio to which the scenarios should belong.
+ * @return The list of scenarios that belong to the specified portfolio.
+ */
+ fun findAll(projectId: Long, number: Int): List<Scenario> {
+ return em.createNamedQuery("Scenario.findAllForPortfolio", Scenario::class.java)
+ .setParameter("projectId", projectId)
+ .setParameter("number", number)
+ .resultList
+ }
+
+ /**
+ * Find the [Scenario] with the specified [number] belonging to [project][projectId].
+ *
+ * @param projectId The unique identifier of the project.
+ * @param number The number of the scenario.
+ * @return The scenario or `null` if it does not exist.
+ */
+ fun findOne(projectId: Long, number: Int): Scenario? {
+ return em.createNamedQuery("Scenario.findOne", Scenario::class.java)
+ .setParameter("projectId", projectId)
+ .setParameter("number", number)
+ .setMaxResults(1)
+ .resultList
+ .firstOrNull()
+ }
+
+ /**
+ * Delete the specified [scenario].
+ */
+ fun delete(scenario: Scenario) {
+ em.remove(scenario)
+ }
+
+ /**
+ * Save the specified [scenario] to the database.
+ */
+ fun save(scenario: Scenario) {
+ em.persist(scenario)
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/TopologyRepository.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/TopologyRepository.kt
new file mode 100644
index 00000000..cd8f666e
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/TopologyRepository.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.repository
+
+import org.opendc.web.api.model.Topology
+import javax.enterprise.context.ApplicationScoped
+import javax.inject.Inject
+import javax.persistence.EntityManager
+
+/**
+ * A repository to manage [Topology] entities.
+ */
+@ApplicationScoped
+class TopologyRepository @Inject constructor(private val em: EntityManager) {
+ /**
+ * Find all [Topology]s that belong to [project][projectId].
+ *
+ * @param projectId The unique identifier of the project.
+ * @return The list of topologies that belong to the specified project.
+ */
+ fun findAll(projectId: Long): List<Topology> {
+ return em.createNamedQuery("Topology.findAll", Topology::class.java)
+ .setParameter("projectId", projectId)
+ .resultList
+ }
+
+ /**
+ * Find the [Topology] with the specified [number] belonging to [project][projectId].
+ *
+ * @param projectId The unique identifier of the project.
+ * @param number The number of the topology.
+ * @return The topology or `null` if it does not exist.
+ */
+ fun findOne(projectId: Long, number: Int): Topology? {
+ return em.createNamedQuery("Topology.findOne", Topology::class.java)
+ .setParameter("projectId", projectId)
+ .setParameter("number", number)
+ .setMaxResults(1)
+ .resultList
+ .firstOrNull()
+ }
+
+ /**
+ * Find the [Topology] with the specified [id].
+ *
+ * @param id Unique identifier of the topology.
+ * @return The topology or `null` if it does not exist.
+ */
+ fun findOne(id: Long): Topology? {
+ return em.find(Topology::class.java, id)
+ }
+
+ /**
+ * Delete the specified [topology].
+ */
+ fun delete(topology: Topology) {
+ em.remove(topology)
+ }
+
+ /**
+ * Save the specified [topology] to the database.
+ */
+ fun save(topology: Topology) {
+ em.persist(topology)
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/TraceRepository.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/TraceRepository.kt
new file mode 100644
index 00000000..6652fc80
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/repository/TraceRepository.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.repository
+
+import org.opendc.web.api.model.Trace
+import javax.enterprise.context.ApplicationScoped
+import javax.inject.Inject
+import javax.persistence.EntityManager
+
+/**
+ * A repository to manage [Trace] entities.
+ */
+@ApplicationScoped
+class TraceRepository @Inject constructor(private val em: EntityManager) {
+ /**
+ * Find all workload traces in the database.
+ *
+ * @return The list of available workload traces.
+ */
+ fun findAll(): List<Trace> {
+ return em.createNamedQuery("Trace.findAll", Trace::class.java).resultList
+ }
+
+ /**
+ * Find the [Trace] with the specified [id].
+ *
+ * @param id The unique identifier of the trace.
+ * @return The trace or `null` if it does not exist.
+ */
+ fun findOne(id: String): Trace? {
+ return em.find(Trace::class.java, id)
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/SchedulerResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/SchedulerResource.kt
new file mode 100644
index 00000000..735fdd9b
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/SchedulerResource.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2021 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest
+
+import javax.ws.rs.GET
+import javax.ws.rs.Path
+
+/**
+ * A resource representing the available schedulers that can be used during experiments.
+ */
+@Path("/schedulers")
+class SchedulerResource {
+ /**
+ * Obtain all available schedulers.
+ */
+ @GET
+ fun getAll() = listOf(
+ "mem",
+ "mem-inv",
+ "core-mem",
+ "core-mem-inv",
+ "active-servers",
+ "active-servers-inv",
+ "provisioned-cores",
+ "provisioned-cores-inv",
+ "random"
+ )
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/TraceResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/TraceResource.kt
new file mode 100644
index 00000000..e87fe602
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/TraceResource.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2021 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest
+
+import org.opendc.web.api.service.TraceService
+import org.opendc.web.proto.Trace
+import javax.inject.Inject
+import javax.ws.rs.*
+
+/**
+ * A resource representing the workload traces available in the OpenDC instance.
+ */
+@Path("/traces")
+class TraceResource @Inject constructor(private val traceService: TraceService) {
+ /**
+ * Obtain all available traces.
+ */
+ @GET
+ fun getAll(): List<Trace> {
+ return traceService.findAll()
+ }
+
+ /**
+ * Obtain trace information by identifier.
+ */
+ @GET
+ @Path("{id}")
+ fun get(@PathParam("id") id: String): Trace {
+ return traceService.findById(id) ?: throw WebApplicationException("Trace not found", 404)
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/error/GenericExceptionMapper.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/error/GenericExceptionMapper.kt
new file mode 100644
index 00000000..fb253758
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/error/GenericExceptionMapper.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest.error
+
+import org.opendc.web.proto.ProtocolError
+import javax.ws.rs.WebApplicationException
+import javax.ws.rs.core.MediaType
+import javax.ws.rs.core.Response
+import javax.ws.rs.ext.ExceptionMapper
+import javax.ws.rs.ext.Provider
+
+/**
+ * Helper class to transform an exception into an JSON error response.
+ */
+@Provider
+class GenericExceptionMapper : ExceptionMapper<Exception> {
+ override fun toResponse(exception: Exception): Response {
+ val code = if (exception is WebApplicationException) exception.response.status else 500
+
+ return Response.status(code)
+ .entity(ProtocolError(code, exception.message ?: "Unknown error"))
+ .type(MediaType.APPLICATION_JSON)
+ .build()
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/error/MissingKotlinParameterExceptionMapper.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/error/MissingKotlinParameterExceptionMapper.kt
new file mode 100644
index 00000000..57cd35d1
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/error/MissingKotlinParameterExceptionMapper.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest.error
+
+import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException
+import org.opendc.web.proto.ProtocolError
+import javax.ws.rs.core.MediaType
+import javax.ws.rs.core.Response
+import javax.ws.rs.ext.ExceptionMapper
+import javax.ws.rs.ext.Provider
+
+/**
+ * An [ExceptionMapper] for [MissingKotlinParameterException] thrown by Jackson.
+ */
+@Provider
+class MissingKotlinParameterExceptionMapper : ExceptionMapper<MissingKotlinParameterException> {
+ override fun toResponse(exception: MissingKotlinParameterException): Response {
+ return Response.status(Response.Status.BAD_REQUEST)
+ .entity(ProtocolError(Response.Status.BAD_REQUEST.statusCode, "Field '${exception.parameter.name}' is missing from body."))
+ .type(MediaType.APPLICATION_JSON)
+ .build()
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/runner/JobResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/runner/JobResource.kt
new file mode 100644
index 00000000..7e31e2c5
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/runner/JobResource.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest.runner
+
+import org.opendc.web.api.service.JobService
+import org.opendc.web.proto.runner.Job
+import javax.annotation.security.RolesAllowed
+import javax.inject.Inject
+import javax.transaction.Transactional
+import javax.validation.Valid
+import javax.ws.rs.*
+
+/**
+ * A resource representing the available simulation jobs.
+ */
+@Path("/jobs")
+@RolesAllowed("runner")
+class JobResource @Inject constructor(private val jobService: JobService) {
+ /**
+ * Obtain all pending simulation jobs.
+ */
+ @GET
+ fun queryPending(): List<Job> {
+ return jobService.queryPending()
+ }
+
+ /**
+ * Get a job by identifier.
+ */
+ @GET
+ @Path("{job}")
+ fun get(@PathParam("job") id: Long): Job {
+ return jobService.findById(id) ?: throw WebApplicationException("Job not found", 404)
+ }
+
+ /**
+ * Atomically update the state of a job.
+ */
+ @POST
+ @Path("{job}")
+ @Transactional
+ fun update(@PathParam("job") id: Long, @Valid update: Job.Update): Job {
+ return jobService.updateState(id, update.state, update.results) ?: throw WebApplicationException("Job not found", 404)
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/PortfolioResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/PortfolioResource.kt
new file mode 100644
index 00000000..e720de75
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/PortfolioResource.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest.user
+
+import io.quarkus.security.identity.SecurityIdentity
+import org.opendc.web.api.service.PortfolioService
+import org.opendc.web.proto.user.Portfolio
+import javax.annotation.security.RolesAllowed
+import javax.inject.Inject
+import javax.transaction.Transactional
+import javax.validation.Valid
+import javax.ws.rs.*
+
+/**
+ * A resource representing the portfolios of a project.
+ */
+@Path("/projects/{project}/portfolios")
+@RolesAllowed("openid")
+class PortfolioResource @Inject constructor(
+ private val portfolioService: PortfolioService,
+ private val identity: SecurityIdentity,
+) {
+ /**
+ * Get all portfolios that belong to the specified project.
+ */
+ @GET
+ fun getAll(@PathParam("project") projectId: Long): List<Portfolio> {
+ return portfolioService.findAll(identity.principal.name, projectId)
+ }
+
+ /**
+ * Create a portfolio for this project.
+ */
+ @POST
+ @Transactional
+ fun create(@PathParam("project") projectId: Long, @Valid request: Portfolio.Create): Portfolio {
+ return portfolioService.create(identity.principal.name, projectId, request) ?: throw WebApplicationException("Project not found", 404)
+ }
+
+ /**
+ * Obtain a portfolio by its identifier.
+ */
+ @GET
+ @Path("{portfolio}")
+ fun get(@PathParam("project") projectId: Long, @PathParam("portfolio") number: Int): Portfolio {
+ return portfolioService.findOne(identity.principal.name, projectId, number) ?: throw WebApplicationException("Portfolio not found", 404)
+ }
+
+ /**
+ * Delete a portfolio.
+ */
+ @DELETE
+ @Path("{portfolio}")
+ fun delete(@PathParam("project") projectId: Long, @PathParam("portfolio") number: Int): Portfolio {
+ return portfolioService.delete(identity.principal.name, projectId, number) ?: throw WebApplicationException("Portfolio not found", 404)
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/PortfolioScenarioResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/PortfolioScenarioResource.kt
new file mode 100644
index 00000000..8d24b2eb
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/PortfolioScenarioResource.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest.user
+
+import io.quarkus.security.identity.SecurityIdentity
+import org.opendc.web.api.service.ScenarioService
+import org.opendc.web.proto.user.Scenario
+import javax.annotation.security.RolesAllowed
+import javax.inject.Inject
+import javax.transaction.Transactional
+import javax.validation.Valid
+import javax.ws.rs.*
+
+/**
+ * A resource representing the scenarios of a portfolio.
+ */
+@Path("/projects/{project}/portfolios/{portfolio}/scenarios")
+@RolesAllowed("openid")
+class PortfolioScenarioResource @Inject constructor(
+ private val scenarioService: ScenarioService,
+ private val identity: SecurityIdentity,
+) {
+ /**
+ * Get all scenarios that belong to the specified portfolio.
+ */
+ @GET
+ fun get(@PathParam("project") projectId: Long, @PathParam("portfolio") portfolioNumber: Int): List<Scenario> {
+ return scenarioService.findAll(identity.principal.name, projectId, portfolioNumber)
+ }
+
+ /**
+ * Create a scenario for this portfolio.
+ */
+ @POST
+ @Transactional
+ fun create(@PathParam("project") projectId: Long, @PathParam("portfolio") portfolioNumber: Int, @Valid request: Scenario.Create): Scenario {
+ return scenarioService.create(identity.principal.name, projectId, portfolioNumber, request) ?: throw WebApplicationException("Portfolio not found", 404)
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/ProjectResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/ProjectResource.kt
new file mode 100644
index 00000000..a27d50e7
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/ProjectResource.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest.user
+
+import io.quarkus.security.identity.SecurityIdentity
+import org.opendc.web.api.service.ProjectService
+import org.opendc.web.proto.user.Project
+import javax.annotation.security.RolesAllowed
+import javax.inject.Inject
+import javax.transaction.Transactional
+import javax.validation.Valid
+import javax.ws.rs.*
+
+/**
+ * A resource representing the created projects.
+ */
+@Path("/projects")
+@RolesAllowed("openid")
+class ProjectResource @Inject constructor(
+ private val projectService: ProjectService,
+ private val identity: SecurityIdentity
+) {
+ /**
+ * Obtain all the projects of the current user.
+ */
+ @GET
+ fun getAll(): List<Project> {
+ return projectService.findWithUser(identity.principal.name)
+ }
+
+ /**
+ * Create a new project for the current user.
+ */
+ @POST
+ @Transactional
+ fun create(@Valid request: Project.Create): Project {
+ return projectService.createForUser(identity.principal.name, request.name)
+ }
+
+ /**
+ * Obtain a single project by its identifier.
+ */
+ @GET
+ @Path("{project}")
+ fun get(@PathParam("project") id: Long): Project {
+ return projectService.findWithUser(identity.principal.name, id) ?: throw WebApplicationException("Project not found", 404)
+ }
+
+ /**
+ * Delete a project.
+ */
+ @DELETE
+ @Path("{project}")
+ @Transactional
+ fun delete(@PathParam("project") id: Long): Project {
+ try {
+ return projectService.deleteWithUser(identity.principal.name, id) ?: throw WebApplicationException("Project not found", 404)
+ } catch (e: IllegalArgumentException) {
+ throw WebApplicationException(e.message, 403)
+ }
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/ScenarioResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/ScenarioResource.kt
new file mode 100644
index 00000000..3690f987
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/ScenarioResource.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest.user
+
+import io.quarkus.security.identity.SecurityIdentity
+import org.opendc.web.api.service.ScenarioService
+import org.opendc.web.proto.user.Scenario
+import javax.annotation.security.RolesAllowed
+import javax.inject.Inject
+import javax.transaction.Transactional
+import javax.ws.rs.*
+
+/**
+ * A resource representing the scenarios of a portfolio.
+ */
+@Path("/projects/{project}/scenarios")
+@RolesAllowed("openid")
+class ScenarioResource @Inject constructor(
+ private val scenarioService: ScenarioService,
+ private val identity: SecurityIdentity
+) {
+ /**
+ * Obtain a scenario by its identifier.
+ */
+ @GET
+ @Path("{scenario}")
+ fun get(@PathParam("project") projectId: Long, @PathParam("scenario") number: Int): Scenario {
+ return scenarioService.findOne(identity.principal.name, projectId, number) ?: throw WebApplicationException("Scenario not found", 404)
+ }
+
+ /**
+ * Delete a scenario.
+ */
+ @DELETE
+ @Path("{scenario}")
+ @Transactional
+ fun delete(@PathParam("project") projectId: Long, @PathParam("scenario") number: Int): Scenario {
+ return scenarioService.delete(identity.principal.name, projectId, number) ?: throw WebApplicationException("Scenario not found", 404)
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/TopologyResource.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/TopologyResource.kt
new file mode 100644
index 00000000..52c5eaaa
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/rest/user/TopologyResource.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest.user
+
+import io.quarkus.security.identity.SecurityIdentity
+import org.opendc.web.api.service.TopologyService
+import org.opendc.web.proto.user.Topology
+import javax.annotation.security.RolesAllowed
+import javax.inject.Inject
+import javax.transaction.Transactional
+import javax.validation.Valid
+import javax.ws.rs.*
+
+/**
+ * A resource representing the constructed datacenter topologies.
+ */
+@Path("/projects/{project}/topologies")
+@RolesAllowed("openid")
+class TopologyResource @Inject constructor(
+ private val topologyService: TopologyService,
+ private val identity: SecurityIdentity
+) {
+ /**
+ * Get all topologies that belong to the specified project.
+ */
+ @GET
+ fun getAll(@PathParam("project") projectId: Long): List<Topology> {
+ return topologyService.findAll(identity.principal.name, projectId)
+ }
+
+ /**
+ * Create a topology for this project.
+ */
+ @POST
+ @Transactional
+ fun create(@PathParam("project") projectId: Long, @Valid request: Topology.Create): Topology {
+ return topologyService.create(identity.principal.name, projectId, request) ?: throw WebApplicationException("Topology not found", 404)
+ }
+
+ /**
+ * Obtain a topology by its number.
+ */
+ @GET
+ @Path("{topology}")
+ fun get(@PathParam("project") projectId: Long, @PathParam("topology") number: Int): Topology {
+ return topologyService.findOne(identity.principal.name, projectId, number) ?: throw WebApplicationException("Topology not found", 404)
+ }
+
+ /**
+ * Update the specified topology by its number.
+ */
+ @PUT
+ @Path("{topology}")
+ @Transactional
+ fun update(@PathParam("project") projectId: Long, @PathParam("topology") number: Int, @Valid request: Topology.Update): Topology {
+ return topologyService.update(identity.principal.name, projectId, number, request) ?: throw WebApplicationException("Topology not found", 404)
+ }
+
+ /**
+ * Delete the specified topology.
+ */
+ @Path("{topology}")
+ @DELETE
+ @Transactional
+ fun delete(@PathParam("project") projectId: Long, @PathParam("topology") number: Int): Topology {
+ return topologyService.delete(identity.principal.name, projectId, number) ?: throw WebApplicationException("Topology not found", 404)
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/JobService.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/JobService.kt
new file mode 100644
index 00000000..1b33248d
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/JobService.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.service
+
+import org.opendc.web.api.repository.JobRepository
+import org.opendc.web.proto.JobState
+import org.opendc.web.proto.runner.Job
+import java.time.Instant
+import javax.enterprise.context.ApplicationScoped
+import javax.inject.Inject
+
+/**
+ * Service for managing [Job]s.
+ */
+@ApplicationScoped
+class JobService @Inject constructor(private val repository: JobRepository) {
+ /**
+ * Query the pending simulation jobs.
+ */
+ fun queryPending(): List<Job> {
+ return repository.findAll(JobState.PENDING).map { it.toRunnerDto() }
+ }
+
+ /**
+ * Find a job by its identifier.
+ */
+ fun findById(id: Long): Job? {
+ return repository.findOne(id)?.toRunnerDto()
+ }
+
+ /**
+ * Atomically update the state of a [Job].
+ */
+ fun updateState(id: Long, newState: JobState, results: Map<String, Any>?): Job? {
+ val entity = repository.findOne(id) ?: return null
+ val state = entity.state
+ if (!state.isTransitionLegal(newState)) {
+ throw IllegalArgumentException("Invalid transition from $state to $newState")
+ }
+
+ val now = Instant.now()
+ if (!repository.updateOne(entity, newState, now, results)) {
+ throw IllegalStateException("Conflicting update")
+ }
+
+ return entity.toRunnerDto()
+ }
+
+ /**
+ * Determine whether the transition from [this] to [newState] is legal.
+ */
+ private fun JobState.isTransitionLegal(newState: JobState): Boolean {
+ // Note that we always allow transitions from the state
+ return newState == this || when (this) {
+ JobState.PENDING -> newState == JobState.CLAIMED
+ JobState.CLAIMED -> newState == JobState.RUNNING || newState == JobState.FAILED
+ JobState.RUNNING -> newState == JobState.FINISHED || newState == JobState.FAILED
+ JobState.FINISHED, JobState.FAILED -> false
+ }
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/PortfolioService.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/PortfolioService.kt
new file mode 100644
index 00000000..1f41c2d7
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/PortfolioService.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.service
+
+import org.opendc.web.api.model.*
+import org.opendc.web.api.repository.PortfolioRepository
+import org.opendc.web.api.repository.ProjectRepository
+import org.opendc.web.proto.user.Portfolio
+import java.time.Instant
+import javax.enterprise.context.ApplicationScoped
+import javax.inject.Inject
+import org.opendc.web.api.model.Portfolio as PortfolioEntity
+
+/**
+ * Service for managing [Portfolio]s.
+ */
+@ApplicationScoped
+class PortfolioService @Inject constructor(
+ private val projectRepository: ProjectRepository,
+ private val portfolioRepository: PortfolioRepository
+) {
+ /**
+ * List all [Portfolio]s that belong a certain project.
+ */
+ fun findAll(userId: String, projectId: Long): List<Portfolio> {
+ // User must have access to project
+ val auth = projectRepository.findOne(userId, projectId) ?: return emptyList()
+ val project = auth.toUserDto()
+ return portfolioRepository.findAll(projectId).map { it.toUserDto(project) }
+ }
+
+ /**
+ * Find a [Portfolio] with the specified [number] belonging to [project][projectId].
+ */
+ fun findOne(userId: String, projectId: Long, number: Int): Portfolio? {
+ // User must have access to project
+ val auth = projectRepository.findOne(userId, projectId) ?: return null
+ return portfolioRepository.findOne(projectId, number)?.toUserDto(auth.toUserDto())
+ }
+
+ /**
+ * Delete the portfolio with the specified [number] belonging to [project][projectId].
+ */
+ fun delete(userId: String, projectId: Long, number: Int): Portfolio? {
+ // User must have access to project
+ val auth = projectRepository.findOne(userId, projectId)
+
+ if (auth == null) {
+ return null
+ } else if (!auth.role.canEdit) {
+ throw IllegalStateException("Not permitted to edit project")
+ }
+
+ val entity = portfolioRepository.findOne(projectId, number) ?: return null
+ val portfolio = entity.toUserDto(auth.toUserDto())
+ portfolioRepository.delete(entity)
+ return portfolio
+ }
+
+ /**
+ * Construct a new [Portfolio] with the specified name.
+ */
+ fun create(userId: String, projectId: Long, request: Portfolio.Create): Portfolio? {
+ // User must have access to project
+ val auth = projectRepository.findOne(userId, projectId)
+
+ if (auth == null) {
+ return null
+ } else if (!auth.role.canEdit) {
+ throw IllegalStateException("Not permitted to edit project")
+ }
+
+ val now = Instant.now()
+ val project = auth.project
+ val number = projectRepository.allocatePortfolio(auth.project, now)
+
+ val portfolio = PortfolioEntity(0, number, request.name, project, request.targets)
+
+ project.portfolios.add(portfolio)
+ portfolioRepository.save(portfolio)
+
+ return portfolio.toUserDto(auth.toUserDto())
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/ProjectService.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/ProjectService.kt
new file mode 100644
index 00000000..c3e43395
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/ProjectService.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.service
+
+import org.opendc.web.api.model.*
+import org.opendc.web.api.repository.ProjectRepository
+import org.opendc.web.proto.user.Project
+import org.opendc.web.proto.user.ProjectRole
+import java.time.Instant
+import javax.enterprise.context.ApplicationScoped
+import javax.inject.Inject
+
+/**
+ * Service for managing [Project]s.
+ */
+@ApplicationScoped
+class ProjectService @Inject constructor(private val repository: ProjectRepository) {
+ /**
+ * List all projects for the user with the specified [userId].
+ */
+ fun findWithUser(userId: String): List<Project> {
+ return repository.findAll(userId).map { it.toUserDto() }
+ }
+
+ /**
+ * Obtain the project with the specified [id] for the user with the specified [userId].
+ */
+ fun findWithUser(userId: String, id: Long): Project? {
+ return repository.findOne(userId, id)?.toUserDto()
+ }
+
+ /**
+ * Create a new [Project] for the user with the specified [userId].
+ */
+ fun createForUser(userId: String, name: String): Project {
+ val now = Instant.now()
+ val entity = Project(0, name, now)
+ repository.save(entity)
+
+ val authorization = ProjectAuthorization(ProjectAuthorizationKey(userId, entity.id), entity, ProjectRole.OWNER)
+
+ entity.authorizations.add(authorization)
+ repository.save(authorization)
+
+ return authorization.toUserDto()
+ }
+
+ /**
+ * Delete a project by its identifier.
+ *
+ * @param userId The user that invokes the action.
+ * @param id The identifier of the project.
+ */
+ fun deleteWithUser(userId: String, id: Long): Project? {
+ val auth = repository.findOne(userId, id) ?: return null
+
+ if (!auth.role.canDelete) {
+ throw IllegalArgumentException("Not allowed to delete project")
+ }
+
+ val now = Instant.now()
+ val project = auth.toUserDto().copy(updatedAt = now)
+ repository.delete(auth.project)
+ return project
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/RunnerConversions.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/RunnerConversions.kt
new file mode 100644
index 00000000..3722a641
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/RunnerConversions.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.service
+
+import org.opendc.web.api.model.Job
+import org.opendc.web.api.model.Portfolio
+import org.opendc.web.api.model.Scenario
+import org.opendc.web.api.model.Topology
+
+/**
+ * Conversions into DTOs provided to OpenDC runners.
+ */
+
+/**
+ * Convert a [Topology] into a runner-facing DTO.
+ */
+internal fun Topology.toRunnerDto(): org.opendc.web.proto.runner.Topology {
+ return org.opendc.web.proto.runner.Topology(id, number, name, rooms, createdAt, updatedAt)
+}
+
+/**
+ * Convert a [Portfolio] into a runner-facing DTO.
+ */
+internal fun Portfolio.toRunnerDto(): org.opendc.web.proto.runner.Portfolio {
+ return org.opendc.web.proto.runner.Portfolio(id, number, name, targets)
+}
+
+/**
+ * Convert a [Job] into a runner-facing DTO.
+ */
+internal fun Job.toRunnerDto(): org.opendc.web.proto.runner.Job {
+ return org.opendc.web.proto.runner.Job(id, scenario.toRunnerDto(), state, createdAt, updatedAt, results)
+}
+
+/**
+ * Convert a [Job] into a runner-facing DTO.
+ */
+internal fun Scenario.toRunnerDto(): org.opendc.web.proto.runner.Scenario {
+ return org.opendc.web.proto.runner.Scenario(
+ id,
+ number,
+ portfolio.toRunnerDto(),
+ name,
+ workload.toDto(),
+ topology.toRunnerDto(),
+ phenomena,
+ schedulerName
+ )
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/ScenarioService.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/ScenarioService.kt
new file mode 100644
index 00000000..dd51a929
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/ScenarioService.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.service
+
+import org.opendc.web.api.model.*
+import org.opendc.web.api.repository.*
+import org.opendc.web.proto.user.Scenario
+import java.time.Instant
+import javax.enterprise.context.ApplicationScoped
+import javax.inject.Inject
+
+/**
+ * Service for managing [Scenario]s.
+ */
+@ApplicationScoped
+class ScenarioService @Inject constructor(
+ private val projectRepository: ProjectRepository,
+ private val portfolioRepository: PortfolioRepository,
+ private val topologyRepository: TopologyRepository,
+ private val traceRepository: TraceRepository,
+ private val scenarioRepository: ScenarioRepository,
+) {
+ /**
+ * List all [Scenario]s that belong a certain portfolio.
+ */
+ fun findAll(userId: String, projectId: Long, number: Int): List<Scenario> {
+ // User must have access to project
+ val auth = projectRepository.findOne(userId, projectId) ?: return emptyList()
+ val project = auth.toUserDto()
+ return scenarioRepository.findAll(projectId).map { it.toUserDto(project) }
+ }
+
+ /**
+ * Obtain a [Scenario] by identifier.
+ */
+ fun findOne(userId: String, projectId: Long, number: Int): Scenario? {
+ // User must have access to project
+ val auth = projectRepository.findOne(userId, projectId) ?: return null
+ val project = auth.toUserDto()
+ return scenarioRepository.findOne(projectId, number)?.toUserDto(project)
+ }
+
+ /**
+ * Delete the specified scenario.
+ */
+ fun delete(userId: String, projectId: Long, number: Int): Scenario? {
+ // User must have access to project
+ val auth = projectRepository.findOne(userId, projectId)
+
+ if (auth == null) {
+ return null
+ } else if (!auth.role.canEdit) {
+ throw IllegalStateException("Not permitted to edit project")
+ }
+
+ val entity = scenarioRepository.findOne(projectId, number) ?: return null
+ val scenario = entity.toUserDto(auth.toUserDto())
+ scenarioRepository.delete(entity)
+ return scenario
+ }
+
+ /**
+ * Construct a new [Scenario] with the specified data.
+ */
+ fun create(userId: String, projectId: Long, portfolioNumber: Int, request: Scenario.Create): Scenario? {
+ // User must have access to project
+ val auth = projectRepository.findOne(userId, projectId)
+
+ if (auth == null) {
+ return null
+ } else if (!auth.role.canEdit) {
+ throw IllegalStateException("Not permitted to edit project")
+ }
+
+ val portfolio = portfolioRepository.findOne(projectId, portfolioNumber) ?: return null
+ val topology = requireNotNull(
+ topologyRepository.findOne(
+ projectId,
+ request.topology.toInt()
+ )
+ ) { "Referred topology does not exist" }
+ val trace =
+ requireNotNull(traceRepository.findOne(request.workload.trace)) { "Referred trace does not exist" }
+
+ val now = Instant.now()
+ val project = auth.project
+ val number = projectRepository.allocateScenario(auth.project, now)
+
+ val scenario = Scenario(
+ 0,
+ number,
+ request.name,
+ project,
+ portfolio,
+ Workload(trace, request.workload.samplingFraction),
+ topology,
+ request.phenomena,
+ request.schedulerName
+ )
+ val job = Job(0, scenario, now, portfolio.targets.repeats)
+
+ scenario.job = job
+ portfolio.scenarios.add(scenario)
+ scenarioRepository.save(scenario)
+
+ return scenario.toUserDto(auth.toUserDto())
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/TopologyService.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/TopologyService.kt
new file mode 100644
index 00000000..f3460496
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/TopologyService.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.service
+
+import org.opendc.web.api.repository.ProjectRepository
+import org.opendc.web.api.repository.TopologyRepository
+import org.opendc.web.proto.user.Topology
+import java.time.Instant
+import javax.enterprise.context.ApplicationScoped
+import javax.inject.Inject
+import org.opendc.web.api.model.Topology as TopologyEntity
+
+/**
+ * Service for managing [Topology]s.
+ */
+@ApplicationScoped
+class TopologyService @Inject constructor(
+ private val projectRepository: ProjectRepository,
+ private val topologyRepository: TopologyRepository
+) {
+ /**
+ * List all [Topology]s that belong a certain project.
+ */
+ fun findAll(userId: String, projectId: Long): List<Topology> {
+ // User must have access to project
+ val auth = projectRepository.findOne(userId, projectId) ?: return emptyList()
+ val project = auth.toUserDto()
+ return topologyRepository.findAll(projectId).map { it.toUserDto(project) }
+ }
+
+ /**
+ * Find the [Topology] with the specified [number] belonging to [project][projectId].
+ */
+ fun findOne(userId: String, projectId: Long, number: Int): Topology? {
+ // User must have access to project
+ val auth = projectRepository.findOne(userId, projectId) ?: return null
+ return topologyRepository.findOne(projectId, number)?.toUserDto(auth.toUserDto())
+ }
+
+ /**
+ * Delete the [Topology] with the specified [number] belonging to [project][projectId].
+ */
+ fun delete(userId: String, projectId: Long, number: Int): Topology? {
+ // User must have access to project
+ val auth = projectRepository.findOne(userId, projectId)
+
+ if (auth == null) {
+ return null
+ } else if (!auth.role.canEdit) {
+ throw IllegalStateException("Not permitted to edit project")
+ }
+
+ val entity = topologyRepository.findOne(projectId, number) ?: return null
+ val now = Instant.now()
+ val topology = entity.toUserDto(auth.toUserDto()).copy(updatedAt = now)
+ topologyRepository.delete(entity)
+
+ return topology
+ }
+
+ /**
+ * Update a [Topology] with the specified [number] belonging to [project][projectId].
+ */
+ fun update(userId: String, projectId: Long, number: Int, request: Topology.Update): Topology? {
+ // User must have access to project
+ val auth = projectRepository.findOne(userId, projectId)
+
+ if (auth == null) {
+ return null
+ } else if (!auth.role.canEdit) {
+ throw IllegalStateException("Not permitted to edit project")
+ }
+
+ val entity = topologyRepository.findOne(projectId, number) ?: return null
+ val now = Instant.now()
+
+ entity.updatedAt = now
+ entity.rooms = request.rooms
+
+ return entity.toUserDto(auth.toUserDto())
+ }
+
+ /**
+ * Construct a new [Topology] with the specified name.
+ */
+ fun create(userId: String, projectId: Long, request: Topology.Create): Topology? {
+ // User must have access to project
+ val auth = projectRepository.findOne(userId, projectId)
+
+ if (auth == null) {
+ return null
+ } else if (!auth.role.canEdit) {
+ throw IllegalStateException("Not permitted to edit project")
+ }
+
+ val now = Instant.now()
+ val project = auth.project
+ val number = projectRepository.allocateTopology(auth.project, now)
+
+ val topology = TopologyEntity(0, number, request.name, project, now, request.rooms)
+
+ project.topologies.add(topology)
+ topologyRepository.save(topology)
+
+ return topology.toUserDto(auth.toUserDto())
+ }
+}
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/ApiResult.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/TraceService.kt
index a3df01c5..a942696e 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/ApiResult.kt
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/TraceService.kt
@@ -20,24 +20,29 @@
* SOFTWARE.
*/
-package org.opendc.web.client
+package org.opendc.web.api.service
-import com.fasterxml.jackson.annotation.JsonSubTypes
-import com.fasterxml.jackson.annotation.JsonTypeInfo
+import org.opendc.web.api.repository.TraceRepository
+import org.opendc.web.proto.Trace
+import javax.enterprise.context.ApplicationScoped
+import javax.inject.Inject
/**
- * Generic response model for the OpenDC API.
+ * Service for managing [Trace]s.
*/
-@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
-@JsonSubTypes(JsonSubTypes.Type(ApiResult.Success::class), JsonSubTypes.Type(ApiResult.Failure::class))
-public sealed class ApiResult<out T> {
+@ApplicationScoped
+class TraceService @Inject constructor(private val repository: TraceRepository) {
/**
- * A response indicating everything is okay.
+ * Obtain all available workload traces.
*/
- public data class Success<out T>(val data: T) : ApiResult<T>()
+ fun findAll(): List<Trace> {
+ return repository.findAll().map { it.toUserDto() }
+ }
/**
- * A response indicating a failure.
+ * Obtain a workload trace by identifier.
*/
- public data class Failure<out T>(val message: String, val errors: List<String> = emptyList()) : ApiResult<T>()
+ fun findById(id: String): Trace? {
+ return repository.findOne(id)?.toUserDto()
+ }
}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/UserConversions.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/UserConversions.kt
new file mode 100644
index 00000000..8612ee8c
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/UserConversions.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.service
+
+import org.opendc.web.api.model.*
+import org.opendc.web.proto.user.Project
+
+/**
+ * Conversions into DTOs provided to users.
+ */
+
+/**
+ * Convert a [Trace] entity into a [org.opendc.web.proto.Trace] DTO.
+ */
+internal fun Trace.toUserDto(): org.opendc.web.proto.Trace {
+ return org.opendc.web.proto.Trace(id, name, type)
+}
+
+/**
+ * Convert a [ProjectAuthorization] entity into a [Project] DTO.
+ */
+internal fun ProjectAuthorization.toUserDto(): Project {
+ return Project(project.id, project.name, project.createdAt, project.updatedAt, role)
+}
+
+/**
+ * Convert a [Topology] entity into a [org.opendc.web.proto.user.Topology] DTO.
+ */
+internal fun Topology.toUserDto(project: Project): org.opendc.web.proto.user.Topology {
+ return org.opendc.web.proto.user.Topology(id, number, project, name, rooms, createdAt, updatedAt)
+}
+
+/**
+ * Convert a [Topology] entity into a [org.opendc.web.proto.user.Topology.Summary] DTO.
+ */
+private fun Topology.toSummaryDto(): org.opendc.web.proto.user.Topology.Summary {
+ return org.opendc.web.proto.user.Topology.Summary(id, number, name, createdAt, updatedAt)
+}
+
+/**
+ * Convert a [Portfolio] entity into a [org.opendc.web.proto.user.Portfolio] DTO.
+ */
+internal fun Portfolio.toUserDto(project: Project): org.opendc.web.proto.user.Portfolio {
+ return org.opendc.web.proto.user.Portfolio(id, number, project, name, targets, scenarios.map { it.toSummaryDto() })
+}
+
+/**
+ * Convert a [Portfolio] entity into a [org.opendc.web.proto.user.Portfolio.Summary] DTO.
+ */
+private fun Portfolio.toSummaryDto(): org.opendc.web.proto.user.Portfolio.Summary {
+ return org.opendc.web.proto.user.Portfolio.Summary(id, number, name, targets)
+}
+
+/**
+ * Convert a [Scenario] entity into a [org.opendc.web.proto.user.Scenario] DTO.
+ */
+internal fun Scenario.toUserDto(project: Project): org.opendc.web.proto.user.Scenario {
+ return org.opendc.web.proto.user.Scenario(
+ id,
+ number,
+ project,
+ portfolio.toSummaryDto(),
+ name,
+ workload.toDto(),
+ topology.toSummaryDto(),
+ phenomena,
+ schedulerName,
+ job.toUserDto()
+ )
+}
+
+/**
+ * Convert a [Scenario] entity into a [org.opendc.web.proto.user.Scenario.Summary] DTO.
+ */
+private fun Scenario.toSummaryDto(): org.opendc.web.proto.user.Scenario.Summary {
+ return org.opendc.web.proto.user.Scenario.Summary(
+ id,
+ number,
+ name,
+ workload.toDto(),
+ topology.toSummaryDto(),
+ phenomena,
+ schedulerName,
+ job.toUserDto()
+ )
+}
+
+/**
+ * Convert a [Job] entity into a [org.opendc.web.proto.user.Job] DTO.
+ */
+internal fun Job.toUserDto(): org.opendc.web.proto.user.Job {
+ return org.opendc.web.proto.user.Job(id, state, createdAt, updatedAt, results)
+}
+
+/**
+ * Convert a [Workload] entity into a DTO.
+ */
+internal fun Workload.toDto(): org.opendc.web.proto.Workload {
+ return org.opendc.web.proto.Workload(trace.toUserDto(), samplingFraction)
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/Utils.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/Utils.kt
new file mode 100644
index 00000000..254be8b7
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/service/Utils.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.service
+
+import org.opendc.web.proto.user.ProjectRole
+
+/**
+ * Flag to indicate that the user can edit a project.
+ */
+internal val ProjectRole.canEdit: Boolean
+ get() = when (this) {
+ ProjectRole.OWNER, ProjectRole.EDITOR -> true
+ ProjectRole.VIEWER -> false
+ }
+
+/**
+ * Flag to indicate that the user can delete a project.
+ */
+internal val ProjectRole.canDelete: Boolean
+ get() = this == ProjectRole.OWNER
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/KotlinModuleCustomizer.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/KotlinModuleCustomizer.kt
new file mode 100644
index 00000000..8d91a00c
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/KotlinModuleCustomizer.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.util
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.kotlin.KotlinModule
+import io.quarkus.jackson.ObjectMapperCustomizer
+import javax.inject.Singleton
+
+/**
+ * Helper class to register the Kotlin Jackson module.
+ */
+@Singleton
+class KotlinModuleCustomizer : ObjectMapperCustomizer {
+ override fun customize(objectMapper: ObjectMapper) {
+ objectMapper.registerModule(KotlinModule.Builder().build())
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/AbstractJsonSqlTypeDescriptor.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/AbstractJsonSqlTypeDescriptor.kt
new file mode 100644
index 00000000..134739c9
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/AbstractJsonSqlTypeDescriptor.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.util.hibernate.json
+
+import org.hibernate.type.descriptor.ValueExtractor
+import org.hibernate.type.descriptor.WrapperOptions
+import org.hibernate.type.descriptor.java.JavaTypeDescriptor
+import org.hibernate.type.descriptor.sql.BasicExtractor
+import org.hibernate.type.descriptor.sql.SqlTypeDescriptor
+import java.sql.CallableStatement
+import java.sql.ResultSet
+import java.sql.Types
+
+/**
+ * Abstract implementation of a [SqlTypeDescriptor] for Hibernate JSON type.
+ */
+internal abstract class AbstractJsonSqlTypeDescriptor : SqlTypeDescriptor {
+
+ override fun getSqlType(): Int {
+ return Types.OTHER
+ }
+
+ override fun canBeRemapped(): Boolean {
+ return true
+ }
+
+ override fun <X> getExtractor(typeDescriptor: JavaTypeDescriptor<X>): ValueExtractor<X> {
+ return object : BasicExtractor<X>(typeDescriptor, this) {
+ override fun doExtract(rs: ResultSet, name: String, options: WrapperOptions): X {
+ return typeDescriptor.wrap(extractJson(rs, name), options)
+ }
+
+ override fun doExtract(statement: CallableStatement, index: Int, options: WrapperOptions): X {
+ return typeDescriptor.wrap(extractJson(statement, index), options)
+ }
+
+ override fun doExtract(statement: CallableStatement, name: String, options: WrapperOptions): X {
+ return typeDescriptor.wrap(extractJson(statement, name), options)
+ }
+ }
+ }
+
+ open fun extractJson(rs: ResultSet, name: String): Any? {
+ return rs.getObject(name)
+ }
+
+ open fun extractJson(statement: CallableStatement, index: Int): Any? {
+ return statement.getObject(index)
+ }
+
+ open fun extractJson(statement: CallableStatement, name: String): Any? {
+ return statement.getObject(name)
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonBinarySqlTypeDescriptor.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonBinarySqlTypeDescriptor.kt
new file mode 100644
index 00000000..32f69928
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonBinarySqlTypeDescriptor.kt
@@ -0,0 +1,26 @@
+package org.opendc.web.api.util.hibernate.json
+
+import com.fasterxml.jackson.databind.JsonNode
+import org.hibernate.type.descriptor.ValueBinder
+import org.hibernate.type.descriptor.WrapperOptions
+import org.hibernate.type.descriptor.java.JavaTypeDescriptor
+import org.hibernate.type.descriptor.sql.BasicBinder
+import java.sql.CallableStatement
+import java.sql.PreparedStatement
+
+/**
+ * A [AbstractJsonSqlTypeDescriptor] that stores the JSON as binary (JSONB).
+ */
+internal object JsonBinarySqlTypeDescriptor : AbstractJsonSqlTypeDescriptor() {
+ override fun <X> getBinder(typeDescriptor: JavaTypeDescriptor<X>): ValueBinder<X> {
+ return object : BasicBinder<X>(typeDescriptor, this) {
+ override fun doBind(st: PreparedStatement, value: X, index: Int, options: WrapperOptions) {
+ st.setObject(index, typeDescriptor.unwrap(value, JsonNode::class.java, options), sqlType)
+ }
+
+ override fun doBind(st: CallableStatement, value: X, name: String, options: WrapperOptions) {
+ st.setObject(name, typeDescriptor.unwrap(value, JsonNode::class.java, options), sqlType)
+ }
+ }
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonBytesSqlTypeDescriptor.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonBytesSqlTypeDescriptor.kt
new file mode 100644
index 00000000..eaecc5b0
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonBytesSqlTypeDescriptor.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.util.hibernate.json
+
+import org.hibernate.type.descriptor.ValueBinder
+import org.hibernate.type.descriptor.WrapperOptions
+import org.hibernate.type.descriptor.java.JavaTypeDescriptor
+import org.hibernate.type.descriptor.sql.BasicBinder
+import java.io.UnsupportedEncodingException
+import java.sql.*
+
+/**
+ * A [AbstractJsonSqlTypeDescriptor] that stores the JSON as UTF-8 encoded bytes.
+ */
+internal object JsonBytesSqlTypeDescriptor : AbstractJsonSqlTypeDescriptor() {
+ private val CHARSET = Charsets.UTF_8
+
+ override fun getSqlType(): Int {
+ return Types.BINARY
+ }
+
+ override fun <X> getBinder(javaTypeDescriptor: JavaTypeDescriptor<X>): ValueBinder<X> {
+ return object : BasicBinder<X>(javaTypeDescriptor, this) {
+ override fun doBind(st: PreparedStatement, value: X, index: Int, options: WrapperOptions) {
+ st.setBytes(index, toJsonBytes(javaTypeDescriptor.unwrap(value, String::class.java, options)))
+ }
+
+ override fun doBind(st: CallableStatement, value: X, name: String, options: WrapperOptions) {
+ st.setBytes(name, toJsonBytes(javaTypeDescriptor.unwrap(value, String::class.java, options)))
+ }
+ }
+ }
+
+ override fun extractJson(rs: ResultSet, name: String): Any? {
+ return fromJsonBytes(rs.getBytes(name))
+ }
+
+ override fun extractJson(statement: CallableStatement, index: Int): Any? {
+ return fromJsonBytes(statement.getBytes(index))
+ }
+
+ override fun extractJson(statement: CallableStatement, name: String): Any? {
+ return fromJsonBytes(statement.getBytes(name))
+ }
+
+ private fun toJsonBytes(jsonValue: String): ByteArray? {
+ return try {
+ jsonValue.toByteArray(CHARSET)
+ } catch (e: UnsupportedEncodingException) {
+ throw IllegalStateException(e)
+ }
+ }
+
+ private fun fromJsonBytes(jsonBytes: ByteArray?): String? {
+ return if (jsonBytes == null) {
+ null
+ } else try {
+ String(jsonBytes, CHARSET)
+ } catch (e: UnsupportedEncodingException) {
+ throw IllegalStateException(e)
+ }
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonSqlTypeDescriptor.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonSqlTypeDescriptor.kt
new file mode 100644
index 00000000..e005f368
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonSqlTypeDescriptor.kt
@@ -0,0 +1,107 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.util.hibernate.json
+
+import org.hibernate.dialect.H2Dialect
+import org.hibernate.dialect.PostgreSQL81Dialect
+import org.hibernate.internal.SessionImpl
+import org.hibernate.type.descriptor.ValueBinder
+import org.hibernate.type.descriptor.ValueExtractor
+import org.hibernate.type.descriptor.WrapperOptions
+import org.hibernate.type.descriptor.java.JavaTypeDescriptor
+import org.hibernate.type.descriptor.sql.BasicBinder
+import org.hibernate.type.descriptor.sql.BasicExtractor
+import org.hibernate.type.descriptor.sql.SqlTypeDescriptor
+import java.sql.*
+
+/**
+ * A [SqlTypeDescriptor] that automatically selects the correct implementation for the database dialect.
+ */
+internal object JsonSqlTypeDescriptor : SqlTypeDescriptor {
+
+ override fun getSqlType(): Int = Types.OTHER
+
+ override fun canBeRemapped(): Boolean = true
+
+ override fun <X> getExtractor(javaTypeDescriptor: JavaTypeDescriptor<X>): ValueExtractor<X> {
+ return object : BasicExtractor<X>(javaTypeDescriptor, this) {
+ private var delegate: AbstractJsonSqlTypeDescriptor? = null
+
+ override fun doExtract(rs: ResultSet, name: String, options: WrapperOptions): X {
+ return javaTypeDescriptor.wrap(delegate(options).extractJson(rs, name), options)
+ }
+
+ override fun doExtract(statement: CallableStatement, index: Int, options: WrapperOptions): X {
+ return javaTypeDescriptor.wrap(delegate(options).extractJson(statement, index), options)
+ }
+
+ override fun doExtract(statement: CallableStatement, name: String, options: WrapperOptions): X {
+ return javaTypeDescriptor.wrap(delegate(options).extractJson(statement, name), options)
+ }
+
+ private fun delegate(options: WrapperOptions): AbstractJsonSqlTypeDescriptor {
+ var delegate = delegate
+ if (delegate == null) {
+ delegate = resolveSqlTypeDescriptor(options)
+ this.delegate = delegate
+ }
+ return delegate
+ }
+ }
+ }
+
+ override fun <X> getBinder(javaTypeDescriptor: JavaTypeDescriptor<X>): ValueBinder<X> {
+ return object : BasicBinder<X>(javaTypeDescriptor, this) {
+ private var delegate: ValueBinder<X>? = null
+
+ override fun doBind(st: PreparedStatement, value: X, index: Int, options: WrapperOptions) {
+ delegate(options).bind(st, value, index, options)
+ }
+
+ override fun doBind(st: CallableStatement, value: X, name: String, options: WrapperOptions) {
+ delegate(options).bind(st, value, name, options)
+ }
+
+ private fun delegate(options: WrapperOptions): ValueBinder<X> {
+ var delegate = delegate
+ if (delegate == null) {
+ delegate = checkNotNull(resolveSqlTypeDescriptor(options).getBinder(javaTypeDescriptor))
+ this.delegate = delegate
+ }
+ return delegate
+ }
+ }
+ }
+
+ /**
+ * Helper method to resolve the appropriate [SqlTypeDescriptor] based on the [WrapperOptions].
+ */
+ private fun resolveSqlTypeDescriptor(options: WrapperOptions): AbstractJsonSqlTypeDescriptor {
+ val session = options as? SessionImpl
+ return when (session?.jdbcServices?.dialect) {
+ is PostgreSQL81Dialect -> JsonBinarySqlTypeDescriptor
+ is H2Dialect -> JsonBytesSqlTypeDescriptor
+ else -> JsonStringSqlTypeDescriptor
+ }
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonStringSqlTypeDescriptor.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonStringSqlTypeDescriptor.kt
new file mode 100644
index 00000000..cf400c95
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonStringSqlTypeDescriptor.kt
@@ -0,0 +1,38 @@
+package org.opendc.web.api.util.hibernate.json
+
+import org.hibernate.type.descriptor.ValueBinder
+import org.hibernate.type.descriptor.WrapperOptions
+import org.hibernate.type.descriptor.java.JavaTypeDescriptor
+import org.hibernate.type.descriptor.sql.BasicBinder
+import java.sql.*
+
+/**
+ * A [AbstractJsonSqlTypeDescriptor] that stores the JSON as string (VARCHAR).
+ */
+internal object JsonStringSqlTypeDescriptor : AbstractJsonSqlTypeDescriptor() {
+ override fun getSqlType(): Int = Types.VARCHAR
+
+ override fun <X> getBinder(typeDescriptor: JavaTypeDescriptor<X>): ValueBinder<X> {
+ return object : BasicBinder<X>(typeDescriptor, this) {
+ override fun doBind(st: PreparedStatement, value: X, index: Int, options: WrapperOptions) {
+ st.setString(index, typeDescriptor.unwrap(value, String::class.java, options))
+ }
+
+ override fun doBind(st: CallableStatement, value: X, name: String, options: WrapperOptions) {
+ st.setString(name, typeDescriptor.unwrap(value, String::class.java, options))
+ }
+ }
+ }
+
+ override fun extractJson(rs: ResultSet, name: String): Any? {
+ return rs.getString(name)
+ }
+
+ override fun extractJson(statement: CallableStatement, index: Int): Any? {
+ return statement.getString(index)
+ }
+
+ override fun extractJson(statement: CallableStatement, name: String): Any? {
+ return statement.getString(name)
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonType.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonType.kt
new file mode 100644
index 00000000..2206e82f
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonType.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.util.hibernate.json
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import org.hibernate.type.AbstractSingleColumnStandardBasicType
+import org.hibernate.type.BasicType
+import org.hibernate.usertype.DynamicParameterizedType
+import java.util.*
+import javax.enterprise.inject.spi.CDI
+
+/**
+ * A [BasicType] that contains JSON.
+ */
+class JsonType(objectMapper: ObjectMapper) : AbstractSingleColumnStandardBasicType<Any>(JsonSqlTypeDescriptor, JsonTypeDescriptor(objectMapper)), DynamicParameterizedType {
+ /**
+ * No-arg constructor for Hibernate to instantiate.
+ */
+ constructor() : this(CDI.current().select(ObjectMapper::class.java).get())
+
+ override fun getName(): String = "json"
+
+ override fun registerUnderJavaType(): Boolean = true
+
+ override fun setParameterValues(parameters: Properties) {
+ (javaTypeDescriptor as JsonTypeDescriptor).setParameterValues(parameters)
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonTypeDescriptor.kt b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonTypeDescriptor.kt
new file mode 100644
index 00000000..3386582e
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/kotlin/org/opendc/web/api/util/hibernate/json/JsonTypeDescriptor.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.util.hibernate.json
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import org.hibernate.HibernateException
+import org.hibernate.annotations.common.reflection.XProperty
+import org.hibernate.annotations.common.reflection.java.JavaXMember
+import org.hibernate.engine.jdbc.BinaryStream
+import org.hibernate.engine.jdbc.internal.BinaryStreamImpl
+import org.hibernate.type.descriptor.WrapperOptions
+import org.hibernate.type.descriptor.java.AbstractTypeDescriptor
+import org.hibernate.type.descriptor.java.BlobTypeDescriptor
+import org.hibernate.type.descriptor.java.DataHelper
+import org.hibernate.type.descriptor.java.MutableMutabilityPlan
+import org.hibernate.usertype.DynamicParameterizedType
+import java.io.ByteArrayInputStream
+import java.io.IOException
+import java.io.InputStream
+import java.lang.reflect.Type
+import java.sql.Blob
+import java.sql.SQLException
+import java.util.*
+
+/**
+ * An [AbstractTypeDescriptor] implementation for Hibernate JSON type.
+ */
+internal class JsonTypeDescriptor(private val objectMapper: ObjectMapper) : AbstractTypeDescriptor<Any>(Any::class.java, JsonMutabilityPlan(objectMapper)), DynamicParameterizedType {
+ private var type: Type? = null
+
+ override fun setParameterValues(parameters: Properties) {
+ val xProperty = parameters[DynamicParameterizedType.XPROPERTY] as XProperty
+ type = if (xProperty is JavaXMember) {
+ val x = xProperty as JavaXMember
+ x.javaType
+ } else {
+ (parameters[DynamicParameterizedType.PARAMETER_TYPE] as DynamicParameterizedType.ParameterType).returnedClass
+ }
+ }
+
+ override fun areEqual(one: Any?, another: Any?): Boolean {
+ return when {
+ one === another -> true
+ one == null || another == null -> false
+ one is String && another is String -> one == another
+ one is Collection<*> && another is Collection<*> -> Objects.equals(one, another)
+ else -> areJsonEqual(one, another)
+ }
+ }
+
+ override fun toString(value: Any?): String {
+ return objectMapper.writeValueAsString(value)
+ }
+
+ override fun fromString(string: String): Any? {
+ return objectMapper.readValue(string, objectMapper.typeFactory.constructType(type))
+ }
+
+ override fun <X> unwrap(value: Any?, type: Class<X>, options: WrapperOptions): X? {
+ if (value == null) {
+ return null
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ return when {
+ String::class.java.isAssignableFrom(type) -> toString(value)
+ BinaryStream::class.java.isAssignableFrom(type) || ByteArray::class.java.isAssignableFrom(type) -> {
+ val stringValue = if (value is String) value else toString(value)
+ BinaryStreamImpl(DataHelper.extractBytes(ByteArrayInputStream(stringValue.toByteArray())))
+ }
+ Blob::class.java.isAssignableFrom(type) -> {
+ val stringValue = if (value is String) value else toString(value)
+ BlobTypeDescriptor.INSTANCE.fromString(stringValue)
+ }
+ Any::class.java.isAssignableFrom(type) -> toJsonType(value)
+ else -> throw unknownUnwrap(type)
+ } as X
+ }
+
+ override fun <X> wrap(value: X?, options: WrapperOptions): Any? {
+ if (value == null) {
+ return null
+ }
+
+ var blob: Blob? = null
+ if (Blob::class.java.isAssignableFrom(value.javaClass)) {
+ blob = options.lobCreator.wrap(value as Blob?)
+ } else if (ByteArray::class.java.isAssignableFrom(value.javaClass)) {
+ blob = options.lobCreator.createBlob(value as ByteArray?)
+ } else if (InputStream::class.java.isAssignableFrom(value.javaClass)) {
+ val inputStream = value as InputStream
+ blob = try {
+ options.lobCreator.createBlob(inputStream, inputStream.available().toLong())
+ } catch (e: IOException) {
+ throw unknownWrap(value.javaClass)
+ }
+ }
+
+ val stringValue: String = try {
+ if (blob != null) String(DataHelper.extractBytes(blob.binaryStream)) else value.toString()
+ } catch (e: SQLException) {
+ throw HibernateException("Unable to extract binary stream from Blob", e)
+ }
+
+ return fromString(stringValue)
+ }
+
+ private class JsonMutabilityPlan(private val objectMapper: ObjectMapper) : MutableMutabilityPlan<Any>() {
+ override fun deepCopyNotNull(value: Any): Any {
+ return objectMapper.treeToValue(objectMapper.valueToTree(value), value.javaClass)
+ }
+ }
+
+ private fun readObject(value: String): Any {
+ return objectMapper.readTree(value)
+ }
+
+ private fun areJsonEqual(one: Any, another: Any): Boolean {
+ return readObject(objectMapper.writeValueAsString(one)) == readObject(objectMapper.writeValueAsString(another))
+ }
+
+ private fun toJsonType(value: Any?): Any {
+ return try {
+ readObject(objectMapper.writeValueAsString(value))
+ } catch (e: Exception) {
+ throw IllegalArgumentException(e)
+ }
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/main/resources/META-INF/branding/logo.png b/opendc-web/opendc-web-api/src/main/resources/META-INF/branding/logo.png
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..84da528f
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/resources/application-dev.properties
@@ -0,0 +1,28 @@
+# Copyright (c) 2022 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# Datasource (H2)
+quarkus.datasource.db-kind = h2
+quarkus.datasource.jdbc.url=jdbc:h2:mem:default;DB_CLOSE_DELAY=-1;INIT=CREATE TYPE IF NOT EXISTS "JSONB" AS blob;
+
+# Hibernate
+quarkus.hibernate-orm.dialect=org.hibernate.dialect.H2Dialect
+quarkus.hibernate-orm.database.generation=drop-and-create
+quarkus.hibernate-orm.sql-load-script=init-dev.sql
diff --git a/opendc-web/opendc-web-api/src/main/resources/application-prod.properties b/opendc-web/opendc-web-api/src/main/resources/application-prod.properties
new file mode 100644
index 00000000..3af1dfd9
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/resources/application-prod.properties
@@ -0,0 +1,29 @@
+# Copyright (c) 2022 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# Datasource
+quarkus.datasource.db-kind=postgresql
+quarkus.datasource.username=${OPENDC_DB_USERNAME}
+quarkus.datasource.password=${OPENDC_DB_PASSWORD}
+quarkus.datasource.jdbc.url=${OPENDC_DB_URL}
+
+# Hibernate
+quarkus.hibernate-orm.dialect=org.hibernate.dialect.PostgreSQL95Dialect
+quarkus.hibernate-orm.database.generation=validate
diff --git a/opendc-web/opendc-web-api/src/main/resources/application-test.properties b/opendc-web/opendc-web-api/src/main/resources/application-test.properties
new file mode 100644
index 00000000..0710f200
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/resources/application-test.properties
@@ -0,0 +1,36 @@
+# Copyright (c) 2022 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# Datasource configuration
+quarkus.datasource.db-kind = h2
+quarkus.datasource.jdbc.url=jdbc:h2:mem:default;DB_CLOSE_DELAY=-1;INIT=CREATE TYPE "JSONB" AS blob;
+
+quarkus.hibernate-orm.dialect=org.hibernate.dialect.H2Dialect
+quarkus.hibernate-orm.database.generation=drop-and-create
+
+# No OIDC for tests
+quarkus.oidc.enabled=false
+quarkus.oidc.auth-server-url=
+quarkus.oidc.client-id=
+
+# Disable OpenAPI/Swagger
+quarkus.smallrye-openapi.enable=false
+quarkus.swagger-ui.enable=false
+quarkus.smallrye-openapi.oidc-open-id-connect-url=
diff --git a/opendc-web/opendc-web-api/src/main/resources/application.properties b/opendc-web/opendc-web-api/src/main/resources/application.properties
new file mode 100644
index 00000000..fa134e7e
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/resources/application.properties
@@ -0,0 +1,48 @@
+# Copyright (c) 2022 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+quarkus.http.cors=true
+
+# OpenID
+quarkus.oidc.auth-server-url=https://${OPENDC_AUTH0_DOMAIN}
+quarkus.oidc.client-id=${OPENDC_AUTH0_AUDIENCE}
+quarkus.oidc.token.audience=${quarkus.oidc.client-id}
+quarkus.oidc.roles.role-claim-path=scope
+
+# OpenAPI and Swagger
+quarkus.smallrye-openapi.info-title=OpenDC REST API
+%dev.quarkus.smallrye-openapi.info-title=OpenDC REST API (development)
+quarkus.smallrye-openapi.info-version=2.1-rc1
+quarkus.smallrye-openapi.info-description=OpenDC is an open-source datacenter simulator for education, featuring real-time online collaboration, diverse simulation models, and detailed performance feedback statistics.
+quarkus.smallrye-openapi.info-contact-email=opendc@atlarge-research.com
+quarkus.smallrye-openapi.info-contact-name=OpenDC Support
+quarkus.smallrye-openapi.info-contact-url=https://opendc.org
+quarkus.smallrye-openapi.info-license-name=MIT
+quarkus.smallrye-openapi.info-license-url=https://github.com/atlarge-research/opendc/blob/master/LICENSE.txt
+
+quarkus.swagger-ui.path=docs
+quarkus.swagger-ui.always-include=true
+quarkus.swagger-ui.oauth-client-id=${OPENDC_AUTH0_DOCS_CLIENT_ID:}
+quarkus.swagger-ui.oauth-additional-query-string-params={"audience":"${OPENDC_AUTH0_AUDIENCE:https://api.opendc.org/}"}
+
+quarkus.smallrye-openapi.security-scheme=oidc
+quarkus.smallrye-openapi.security-scheme-name=Auth0
+quarkus.smallrye-openapi.oidc-open-id-connect-url=https://${OPENDC_AUTH0_DOMAIN:opendc.eu.auth0.com}/.well-known/openid-configuration
+quarkus.smallrye-openapi.servers=http://localhost:8080
diff --git a/opendc-web/opendc-web-api/src/main/resources/init-dev.sql b/opendc-web/opendc-web-api/src/main/resources/init-dev.sql
new file mode 100644
index 00000000..756eff46
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/main/resources/init-dev.sql
@@ -0,0 +1,3 @@
+
+-- Add example traces
+INSERT INTO traces (id, name, type) VALUES ('bitbrains-small', 'Bitbrains Small', 'vm');
diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/SchedulerResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/SchedulerResourceTest.kt
new file mode 100644
index 00000000..e88e1c1c
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/SchedulerResourceTest.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2021 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest
+
+import io.quarkus.test.junit.QuarkusTest
+import io.restassured.http.ContentType
+import io.restassured.module.kotlin.extensions.Then
+import io.restassured.module.kotlin.extensions.When
+import org.junit.jupiter.api.Test
+
+/**
+ * Test suite for [SchedulerResource]
+ */
+@QuarkusTest
+class SchedulerResourceTest {
+ /**
+ * Test to verify whether we can obtain all schedulers.
+ */
+ @Test
+ fun testGetSchedulers() {
+ When {
+ get("/schedulers")
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ }
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/TraceResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/TraceResourceTest.kt
new file mode 100644
index 00000000..6fab9953
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/TraceResourceTest.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest
+
+import io.mockk.every
+import io.quarkiverse.test.junit.mockk.InjectMock
+import io.quarkus.test.common.http.TestHTTPEndpoint
+import io.quarkus.test.junit.QuarkusMock
+import io.quarkus.test.junit.QuarkusTest
+import io.restassured.http.ContentType
+import io.restassured.module.kotlin.extensions.Then
+import io.restassured.module.kotlin.extensions.When
+import org.hamcrest.Matchers
+import org.hamcrest.Matchers.equalTo
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.opendc.web.api.service.TraceService
+import org.opendc.web.proto.Trace
+
+/**
+ * Test suite for [TraceResource].
+ */
+@QuarkusTest
+@TestHTTPEndpoint(TraceResource::class)
+class TraceResourceTest {
+ @InjectMock
+ private lateinit var traceService: TraceService
+
+ @BeforeEach
+ fun setUp() {
+ QuarkusMock.installMockForType(traceService, TraceService::class.java)
+ }
+
+ /**
+ * Test that tries to obtain all traces (empty response).
+ */
+ @Test
+ fun testGetAllEmpy() {
+ every { traceService.findAll() } returns emptyList()
+
+ When {
+ get()
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ body("", Matchers.empty<String>())
+ }
+ }
+
+ /**
+ * Test that tries to obtain a non-existent trace.
+ */
+ @Test
+ fun testGetNonExisting() {
+ every { traceService.findById("bitbrains") } returns null
+
+ When {
+ get("/bitbrains")
+ } Then {
+ statusCode(404)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test that tries to obtain an existing trace.
+ */
+ @Test
+ fun testGetExisting() {
+ every { traceService.findById("bitbrains") } returns Trace("bitbrains", "Bitbrains", "VM")
+
+ When {
+ get("/bitbrains")
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ body("name", equalTo("Bitbrains"))
+ }
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/runner/JobResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/runner/JobResourceTest.kt
new file mode 100644
index 00000000..b82c60e8
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/runner/JobResourceTest.kt
@@ -0,0 +1,200 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest.runner
+
+import io.mockk.every
+import io.quarkiverse.test.junit.mockk.InjectMock
+import io.quarkus.test.common.http.TestHTTPEndpoint
+import io.quarkus.test.junit.QuarkusMock
+import io.quarkus.test.junit.QuarkusTest
+import io.quarkus.test.security.TestSecurity
+import io.restassured.http.ContentType
+import io.restassured.module.kotlin.extensions.Given
+import io.restassured.module.kotlin.extensions.Then
+import io.restassured.module.kotlin.extensions.When
+import org.hamcrest.Matchers.equalTo
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.opendc.web.api.service.JobService
+import org.opendc.web.proto.*
+import org.opendc.web.proto.Targets
+import org.opendc.web.proto.runner.Job
+import org.opendc.web.proto.runner.Portfolio
+import org.opendc.web.proto.runner.Scenario
+import org.opendc.web.proto.runner.Topology
+import java.time.Instant
+
+/**
+ * Test suite for [JobResource].
+ */
+@QuarkusTest
+@TestHTTPEndpoint(JobResource::class)
+class JobResourceTest {
+ @InjectMock
+ private lateinit var jobService: JobService
+
+ /**
+ * Dummy values
+ */
+ private val dummyPortfolio = Portfolio(1, 1, "test", Targets(emptySet()))
+ private val dummyTopology = Topology(1, 1, "test", emptyList(), Instant.now(), Instant.now())
+ private val dummyTrace = Trace("bitbrains", "Bitbrains", "vm")
+ private val dummyScenario = Scenario(1, 1, dummyPortfolio, "test", Workload(dummyTrace, 1.0), dummyTopology, OperationalPhenomena(false, false), "test",)
+ private val dummyJob = Job(1, dummyScenario, JobState.PENDING, Instant.now(), Instant.now())
+
+ @BeforeEach
+ fun setUp() {
+ QuarkusMock.installMockForType(jobService, JobService::class.java)
+ }
+
+ /**
+ * Test that tries to query the pending jobs without token.
+ */
+ @Test
+ fun testQueryWithoutToken() {
+ When {
+ get()
+ } Then {
+ statusCode(401)
+ }
+ }
+
+ /**
+ * Test that tries to query the pending jobs for a user.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testQueryInvalidScope() {
+ When {
+ get()
+ } Then {
+ statusCode(403)
+ }
+ }
+
+ /**
+ * Test that tries to query the pending jobs for a runner.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["runner"])
+ fun testQuery() {
+ every { jobService.queryPending() } returns listOf(dummyJob)
+
+ When {
+ get()
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ body("get(0).id", equalTo(1))
+ }
+ }
+
+ /**
+ * Test that tries to obtain a non-existent job.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["runner"])
+ fun testGetNonExisting() {
+ every { jobService.findById(1) } returns null
+
+ When {
+ get("/1")
+ } Then {
+ statusCode(404)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test that tries to obtain a job.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["runner"])
+ fun testGetExisting() {
+ every { jobService.findById(1) } returns dummyJob
+
+ When {
+ get("/1")
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ body("id", equalTo(1))
+ }
+ }
+
+ /**
+ * Test that tries to update a non-existent job.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["runner"])
+ fun testUpdateNonExistent() {
+ every { jobService.updateState(1, any(), any()) } returns null
+
+ Given {
+ body(Job.Update(JobState.PENDING))
+ contentType(ContentType.JSON)
+ } When {
+ post("/1")
+ } Then {
+ statusCode(404)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test that tries to update a job.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["runner"])
+ fun testUpdateState() {
+ every { jobService.updateState(1, any(), any()) } returns dummyJob.copy(state = JobState.CLAIMED)
+
+ Given {
+ body(Job.Update(JobState.CLAIMED))
+ contentType(ContentType.JSON)
+ } When {
+ post("/1")
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ body("state", equalTo(JobState.CLAIMED.toString()))
+ }
+ }
+
+ /**
+ * Test that tries to update a job with invalid input.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["runner"])
+ fun testUpdateInvalidInput() {
+ Given {
+ body("""{ "test": "test" }""")
+ contentType(ContentType.JSON)
+ } When {
+ post("/1")
+ } Then {
+ statusCode(400)
+ contentType(ContentType.JSON)
+ }
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/PortfolioResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/PortfolioResourceTest.kt
new file mode 100644
index 00000000..f74efbca
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/PortfolioResourceTest.kt
@@ -0,0 +1,265 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest.user
+
+import io.mockk.every
+import io.quarkiverse.test.junit.mockk.InjectMock
+import io.quarkus.test.common.http.TestHTTPEndpoint
+import io.quarkus.test.junit.QuarkusMock
+import io.quarkus.test.junit.QuarkusTest
+import io.quarkus.test.security.TestSecurity
+import io.restassured.http.ContentType
+import io.restassured.module.kotlin.extensions.Given
+import io.restassured.module.kotlin.extensions.Then
+import io.restassured.module.kotlin.extensions.When
+import org.hamcrest.Matchers
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.opendc.web.api.service.PortfolioService
+import org.opendc.web.proto.Targets
+import org.opendc.web.proto.user.Portfolio
+import org.opendc.web.proto.user.Project
+import org.opendc.web.proto.user.ProjectRole
+import java.time.Instant
+
+/**
+ * Test suite for [PortfolioResource].
+ */
+@QuarkusTest
+@TestHTTPEndpoint(PortfolioResource::class)
+class PortfolioResourceTest {
+ @InjectMock
+ private lateinit var portfolioService: PortfolioService
+
+ /**
+ * Dummy project and portfolio
+ */
+ private val dummyProject = Project(1, "test", Instant.now(), Instant.now(), ProjectRole.OWNER)
+ private val dummyPortfolio = Portfolio(1, 1, dummyProject, "test", Targets(emptySet(), 1), emptyList())
+
+ @BeforeEach
+ fun setUp() {
+ QuarkusMock.installMockForType(portfolioService, PortfolioService::class.java)
+ }
+
+ /**
+ * Test that tries to obtain the list of portfolios belonging to a project.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testGetForProject() {
+ every { portfolioService.findAll("testUser", 1) } returns emptyList()
+
+ Given {
+ pathParam("project", "1")
+ } When {
+ get()
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test that tries to create a topology for a project.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testCreateNonExistent() {
+ every { portfolioService.create("testUser", 1, any()) } returns null
+
+ Given {
+ pathParam("project", "1")
+
+ body(Portfolio.Create("test", Targets(emptySet(), 1)))
+ contentType(ContentType.JSON)
+ } When {
+ post()
+ } Then {
+ statusCode(404)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test that tries to create a portfolio for a scenario.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testCreate() {
+ every { portfolioService.create("testUser", 1, any()) } returns dummyPortfolio
+
+ Given {
+ pathParam("project", "1")
+
+ body(Portfolio.Create("test", Targets(emptySet(), 1)))
+ contentType(ContentType.JSON)
+ } When {
+ post()
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ body("id", Matchers.equalTo(1))
+ body("name", Matchers.equalTo("test"))
+ }
+ }
+
+ /**
+ * Test to create a portfolio with an empty body.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testCreateEmpty() {
+ Given {
+ pathParam("project", "1")
+
+ body("{}")
+ contentType(ContentType.JSON)
+ } When {
+ post()
+ } Then {
+ statusCode(400)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test to create a portfolio with a blank name.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testCreateBlankName() {
+ Given {
+ pathParam("project", "1")
+
+ body(Portfolio.Create("", Targets(emptySet(), 1)))
+ contentType(ContentType.JSON)
+ } When {
+ post()
+ } Then {
+ statusCode(400)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test that tries to obtain a portfolio without token.
+ */
+ @Test
+ fun testGetWithoutToken() {
+ Given {
+ pathParam("project", "1")
+ } When {
+ get("/1")
+ } Then {
+ statusCode(401)
+ }
+ }
+
+ /**
+ * Test that tries to obtain a portfolio with an invalid scope.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["runner"])
+ fun testGetInvalidToken() {
+ Given {
+ pathParam("project", "1")
+ } When {
+ get("/1")
+ } Then {
+ statusCode(403)
+ }
+ }
+
+ /**
+ * Test that tries to obtain a non-existent portfolio.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testGetNonExisting() {
+ every { portfolioService.findOne("testUser", 1, 1) } returns null
+
+ Given {
+ pathParam("project", "1")
+ } When {
+ get("/1")
+ } Then {
+ statusCode(404)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test that tries to obtain a portfolio.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testGetExisting() {
+ every { portfolioService.findOne("testUser", 1, 1) } returns dummyPortfolio
+
+ Given {
+ pathParam("project", "1")
+ } When {
+ get("/1")
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ body("id", Matchers.equalTo(1))
+ }
+ }
+
+ /**
+ * Test to delete a non-existent portfolio.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testDeleteNonExistent() {
+ every { portfolioService.delete("testUser", 1, 1) } returns null
+
+ Given {
+ pathParam("project", "1")
+ } When {
+ delete("/1")
+ } Then {
+ statusCode(404)
+ }
+ }
+
+ /**
+ * Test to delete a portfolio.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testDelete() {
+ every { portfolioService.delete("testUser", 1, 1) } returns dummyPortfolio
+
+ Given {
+ pathParam("project", "1")
+ } When {
+ delete("/1")
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ }
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/PortfolioScenarioResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/PortfolioScenarioResourceTest.kt
new file mode 100644
index 00000000..dbafa8c0
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/PortfolioScenarioResourceTest.kt
@@ -0,0 +1,213 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest.user
+
+import io.mockk.every
+import io.quarkiverse.test.junit.mockk.InjectMock
+import io.quarkus.test.common.http.TestHTTPEndpoint
+import io.quarkus.test.junit.QuarkusMock
+import io.quarkus.test.junit.QuarkusTest
+import io.quarkus.test.security.TestSecurity
+import io.restassured.http.ContentType
+import io.restassured.module.kotlin.extensions.Given
+import io.restassured.module.kotlin.extensions.Then
+import io.restassured.module.kotlin.extensions.When
+import org.hamcrest.Matchers
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.opendc.web.api.service.ScenarioService
+import org.opendc.web.proto.*
+import org.opendc.web.proto.user.*
+import java.time.Instant
+
+/**
+ * Test suite for [PortfolioScenarioResource].
+ */
+@QuarkusTest
+@TestHTTPEndpoint(PortfolioScenarioResource::class)
+class PortfolioScenarioResourceTest {
+ @InjectMock
+ private lateinit var scenarioService: ScenarioService
+
+ /**
+ * Dummy values
+ */
+ private val dummyProject = Project(0, "test", Instant.now(), Instant.now(), ProjectRole.OWNER)
+ private val dummyPortfolio = Portfolio.Summary(1, 1, "test", Targets(emptySet()))
+ private val dummyJob = Job(1, JobState.PENDING, Instant.now(), Instant.now(), null)
+ private val dummyTrace = Trace("bitbrains", "Bitbrains", "vm")
+ private val dummyTopology = Topology.Summary(1, 1, "test", Instant.now(), Instant.now())
+ private val dummyScenario = Scenario(
+ 1,
+ 1,
+ dummyProject,
+ dummyPortfolio,
+ "test",
+ Workload(dummyTrace, 1.0),
+ dummyTopology,
+ OperationalPhenomena(false, false),
+ "test",
+ dummyJob
+ )
+
+ @BeforeEach
+ fun setUp() {
+ QuarkusMock.installMockForType(scenarioService, ScenarioService::class.java)
+ }
+
+ /**
+ * Test that tries to obtain a portfolio without token.
+ */
+ @Test
+ fun testGetWithoutToken() {
+ Given {
+ pathParam("project", "1")
+ pathParam("portfolio", "1")
+ } When {
+ get()
+ } Then {
+ statusCode(401)
+ }
+ }
+
+ /**
+ * Test that tries to obtain a portfolio with an invalid scope.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["runner"])
+ fun testGetInvalidToken() {
+ Given {
+ pathParam("project", "1")
+ pathParam("portfolio", "1")
+ } When {
+ get()
+ } Then {
+ statusCode(403)
+ }
+ }
+
+ /**
+ * Test that tries to obtain a non-existent portfolio.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testGet() {
+ every { scenarioService.findAll("testUser", 1, 1) } returns emptyList()
+
+ Given {
+ pathParam("project", "1")
+ pathParam("portfolio", "1")
+ } When {
+ get()
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test that tries to create a scenario for a portfolio.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testCreateNonExistent() {
+ every { scenarioService.create("testUser", 1, any(), any()) } returns null
+
+ Given {
+ pathParam("project", "1")
+ pathParam("portfolio", "1")
+
+ body(Scenario.Create("test", Workload.Spec("test", 1.0), 1, OperationalPhenomena(false, false), "test"))
+ contentType(ContentType.JSON)
+ } When {
+ post()
+ } Then {
+ statusCode(404)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test that tries to create a scenario for a portfolio.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testCreate() {
+ every { scenarioService.create("testUser", 1, 1, any()) } returns dummyScenario
+
+ Given {
+ pathParam("project", "1")
+ pathParam("portfolio", "1")
+
+ body(Scenario.Create("test", Workload.Spec("test", 1.0), 1, OperationalPhenomena(false, false), "test"))
+ contentType(ContentType.JSON)
+ } When {
+ post()
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ body("id", Matchers.equalTo(1))
+ body("name", Matchers.equalTo("test"))
+ }
+ }
+
+ /**
+ * Test to create a project with an empty body.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testCreateEmpty() {
+ Given {
+ pathParam("project", "1")
+ pathParam("portfolio", "1")
+
+ body("{}")
+ contentType(ContentType.JSON)
+ } When {
+ post()
+ } Then {
+ statusCode(400)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test to create a project with a blank name.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testCreateBlankName() {
+ Given {
+ pathParam("project", "1")
+ pathParam("portfolio", "1")
+
+ body(Scenario.Create("", Workload.Spec("test", 1.0), 1, OperationalPhenomena(false, false), "test"))
+ contentType(ContentType.JSON)
+ } When {
+ post()
+ } Then {
+ statusCode(400)
+ contentType(ContentType.JSON)
+ }
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/ProjectResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/ProjectResourceTest.kt
new file mode 100644
index 00000000..bcbcbab1
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/ProjectResourceTest.kt
@@ -0,0 +1,240 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest.user
+
+import io.mockk.every
+import io.quarkiverse.test.junit.mockk.InjectMock
+import io.quarkus.test.common.http.TestHTTPEndpoint
+import io.quarkus.test.junit.QuarkusMock
+import io.quarkus.test.junit.QuarkusTest
+import io.quarkus.test.security.TestSecurity
+import io.restassured.http.ContentType
+import io.restassured.module.kotlin.extensions.Given
+import io.restassured.module.kotlin.extensions.Then
+import io.restassured.module.kotlin.extensions.When
+import org.hamcrest.Matchers.equalTo
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.opendc.web.api.service.ProjectService
+import org.opendc.web.proto.user.Project
+import org.opendc.web.proto.user.ProjectRole
+import java.time.Instant
+
+/**
+ * Test suite for [ProjectResource].
+ */
+@QuarkusTest
+@TestHTTPEndpoint(ProjectResource::class)
+class ProjectResourceTest {
+ @InjectMock
+ private lateinit var projectService: ProjectService
+
+ /**
+ * Dummy values.
+ */
+ private val dummyProject = Project(0, "test", Instant.now(), Instant.now(), ProjectRole.OWNER)
+
+ @BeforeEach
+ fun setUp() {
+ QuarkusMock.installMockForType(projectService, ProjectService::class.java)
+ }
+
+ /**
+ * Test that tries to obtain all projects without token.
+ */
+ @Test
+ fun testGetAllWithoutToken() {
+ When {
+ get()
+ } Then {
+ statusCode(401)
+ }
+ }
+
+ /**
+ * Test that tries to obtain all projects with an invalid scope.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["runner"])
+ fun testGetAllWithInvalidScope() {
+ When {
+ get()
+ } Then {
+ statusCode(403)
+ }
+ }
+
+ /**
+ * Test that tries to obtain all project for a user.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testGetAll() {
+ val projects = listOf(dummyProject)
+ every { projectService.findWithUser("testUser") } returns projects
+
+ When {
+ get()
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ body("get(0).name", equalTo("test"))
+ }
+ }
+
+ /**
+ * Test that tries to obtain a non-existent project.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testGetNonExisting() {
+ every { projectService.findWithUser("testUser", 1) } returns null
+
+ When {
+ get("/1")
+ } Then {
+ statusCode(404)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test that tries to obtain a job.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testGetExisting() {
+ every { projectService.findWithUser("testUser", 1) } returns dummyProject
+
+ When {
+ get("/1")
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ body("id", equalTo(0))
+ }
+ }
+
+ /**
+ * Test that tries to create a project.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testCreate() {
+ every { projectService.createForUser("testUser", "test") } returns dummyProject
+
+ Given {
+ body(Project.Create("test"))
+ contentType(ContentType.JSON)
+ } When {
+ post()
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ body("id", equalTo(0))
+ body("name", equalTo("test"))
+ }
+ }
+
+ /**
+ * Test to create a project with an empty body.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testCreateEmpty() {
+ Given {
+ body("{}")
+ contentType(ContentType.JSON)
+ } When {
+ post()
+ } Then {
+ statusCode(400)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test to create a project with a blank name.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testCreateBlankName() {
+ Given {
+ body(Project.Create(""))
+ contentType(ContentType.JSON)
+ } When {
+ post()
+ } Then {
+ statusCode(400)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test to delete a non-existent project.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testDeleteNonExistent() {
+ every { projectService.deleteWithUser("testUser", 1) } returns null
+
+ When {
+ delete("/1")
+ } Then {
+ statusCode(404)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test to delete a project.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testDelete() {
+ every { projectService.deleteWithUser("testUser", 1) } returns dummyProject
+
+ When {
+ delete("/1")
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test to delete a project which the user does not own.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testDeleteNonOwner() {
+ every { projectService.deleteWithUser("testUser", 1) } throws IllegalArgumentException("User does not own project")
+
+ When {
+ delete("/1")
+ } Then {
+ statusCode(403)
+ contentType(ContentType.JSON)
+ }
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/ScenarioResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/ScenarioResourceTest.kt
new file mode 100644
index 00000000..65e6e9a1
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/ScenarioResourceTest.kt
@@ -0,0 +1,178 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest.user
+
+import io.mockk.every
+import io.quarkiverse.test.junit.mockk.InjectMock
+import io.quarkus.test.common.http.TestHTTPEndpoint
+import io.quarkus.test.junit.QuarkusMock
+import io.quarkus.test.junit.QuarkusTest
+import io.quarkus.test.security.TestSecurity
+import io.restassured.http.ContentType
+import io.restassured.module.kotlin.extensions.Given
+import io.restassured.module.kotlin.extensions.Then
+import io.restassured.module.kotlin.extensions.When
+import org.hamcrest.Matchers
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.opendc.web.api.service.ScenarioService
+import org.opendc.web.proto.*
+import org.opendc.web.proto.user.*
+import java.time.Instant
+
+/**
+ * Test suite for [ScenarioResource].
+ */
+@QuarkusTest
+@TestHTTPEndpoint(ScenarioResource::class)
+class ScenarioResourceTest {
+ @InjectMock
+ private lateinit var scenarioService: ScenarioService
+
+ /**
+ * Dummy values
+ */
+ private val dummyProject = Project(0, "test", Instant.now(), Instant.now(), ProjectRole.OWNER)
+ private val dummyPortfolio = Portfolio.Summary(1, 1, "test", Targets(emptySet()))
+ private val dummyJob = Job(1, JobState.PENDING, Instant.now(), Instant.now(), null)
+ private val dummyTrace = Trace("bitbrains", "Bitbrains", "vm")
+ private val dummyTopology = Topology.Summary(1, 1, "test", Instant.now(), Instant.now())
+ private val dummyScenario = Scenario(
+ 1,
+ 1,
+ dummyProject,
+ dummyPortfolio,
+ "test",
+ Workload(dummyTrace, 1.0),
+ dummyTopology,
+ OperationalPhenomena(false, false),
+ "test",
+ dummyJob
+ )
+
+ @BeforeEach
+ fun setUp() {
+ QuarkusMock.installMockForType(scenarioService, ScenarioService::class.java)
+ }
+
+ /**
+ * Test that tries to obtain a scenario without token.
+ */
+ @Test
+ fun testGetWithoutToken() {
+ Given {
+ pathParam("project", "1")
+ } When {
+ get("/1")
+ } Then {
+ statusCode(401)
+ }
+ }
+
+ /**
+ * Test that tries to obtain a scenario with an invalid scope.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["runner"])
+ fun testGetInvalidToken() {
+ Given {
+ pathParam("project", "1")
+ } When {
+ get("/1")
+ } Then {
+ statusCode(403)
+ }
+ }
+
+ /**
+ * Test that tries to obtain a non-existent scenario.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testGetNonExisting() {
+ every { scenarioService.findOne("testUser", 1, 1) } returns null
+
+ Given {
+ pathParam("project", "1")
+ } When {
+ get("/1")
+ } Then {
+ statusCode(404)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test that tries to obtain a scenario.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testGetExisting() {
+ every { scenarioService.findOne("testUser", 1, 1) } returns dummyScenario
+
+ Given {
+ pathParam("project", "1")
+ } When {
+ get("/1")
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ body("id", Matchers.equalTo(1))
+ }
+ }
+
+ /**
+ * Test to delete a non-existent scenario.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testDeleteNonExistent() {
+ every { scenarioService.delete("testUser", 1, 1) } returns null
+
+ Given {
+ pathParam("project", "1")
+ } When {
+ delete("/1")
+ } Then {
+ statusCode(404)
+ }
+ }
+
+ /**
+ * Test to delete a scenario.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testDelete() {
+ every { scenarioService.delete("testUser", 1, 1) } returns dummyScenario
+
+ Given {
+ pathParam("project", "1")
+ } When {
+ delete("/1")
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ }
+ }
+}
diff --git a/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/TopologyResourceTest.kt b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/TopologyResourceTest.kt
new file mode 100644
index 00000000..ececeaca
--- /dev/null
+++ b/opendc-web/opendc-web-api/src/test/kotlin/org/opendc/web/api/rest/user/TopologyResourceTest.kt
@@ -0,0 +1,304 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.api.rest.user
+
+import io.mockk.every
+import io.quarkiverse.test.junit.mockk.InjectMock
+import io.quarkus.test.common.http.TestHTTPEndpoint
+import io.quarkus.test.junit.QuarkusMock
+import io.quarkus.test.junit.QuarkusTest
+import io.quarkus.test.security.TestSecurity
+import io.restassured.http.ContentType
+import io.restassured.module.kotlin.extensions.Given
+import io.restassured.module.kotlin.extensions.Then
+import io.restassured.module.kotlin.extensions.When
+import org.hamcrest.Matchers
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.opendc.web.api.service.TopologyService
+import org.opendc.web.proto.user.Project
+import org.opendc.web.proto.user.ProjectRole
+import org.opendc.web.proto.user.Topology
+import java.time.Instant
+
+/**
+ * Test suite for [TopologyResource].
+ */
+@QuarkusTest
+@TestHTTPEndpoint(TopologyResource::class)
+class TopologyResourceTest {
+ @InjectMock
+ private lateinit var topologyService: TopologyService
+
+ /**
+ * Dummy project and topology.
+ */
+ private val dummyProject = Project(1, "test", Instant.now(), Instant.now(), ProjectRole.OWNER)
+ private val dummyTopology = Topology(1, 1, dummyProject, "test", emptyList(), Instant.now(), Instant.now())
+
+ @BeforeEach
+ fun setUp() {
+ QuarkusMock.installMockForType(topologyService, TopologyService::class.java)
+ }
+
+ /**
+ * Test that tries to obtain the list of topologies belonging to a project.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testGetForProject() {
+ every { topologyService.findAll("testUser", 1) } returns emptyList()
+
+ Given {
+ pathParam("project", "1")
+ } When {
+ get()
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test that tries to create a topology for a project.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testCreateNonExistent() {
+ every { topologyService.create("testUser", 1, any()) } returns null
+
+ Given {
+ pathParam("project", "1")
+
+ body(Topology.Create("test", emptyList()))
+ contentType(ContentType.JSON)
+ } When {
+ post()
+ } Then {
+ statusCode(404)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test that tries to create a topology for a project.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testCreate() {
+ every { topologyService.create("testUser", 1, any()) } returns dummyTopology
+
+ Given {
+ pathParam("project", "1")
+
+ body(Topology.Create("test", emptyList()))
+ contentType(ContentType.JSON)
+ } When {
+ post()
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ body("id", Matchers.equalTo(1))
+ body("name", Matchers.equalTo("test"))
+ }
+ }
+
+ /**
+ * Test to create a topology with an empty body.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testCreateEmpty() {
+ Given {
+ pathParam("project", "1")
+
+ body("{}")
+ contentType(ContentType.JSON)
+ } When {
+ post()
+ } Then {
+ statusCode(400)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test to create a topology with a blank name.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testCreateBlankName() {
+ Given {
+ pathParam("project", "1")
+
+ body(Topology.Create("", emptyList()))
+ contentType(ContentType.JSON)
+ } When {
+ post()
+ } Then {
+ statusCode(400)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test that tries to obtain a topology without token.
+ */
+ @Test
+ fun testGetWithoutToken() {
+ Given {
+ pathParam("project", "1")
+ } When {
+ get("/1")
+ } Then {
+ statusCode(401)
+ }
+ }
+
+ /**
+ * Test that tries to obtain a topology with an invalid scope.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["runner"])
+ fun testGetInvalidToken() {
+ Given {
+ pathParam("project", "1")
+ } When {
+ get("/1")
+ } Then {
+ statusCode(403)
+ }
+ }
+
+ /**
+ * Test that tries to obtain a non-existent topology.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testGetNonExisting() {
+ every { topologyService.findOne("testUser", 1, 1) } returns null
+
+ Given {
+ pathParam("project", "1")
+ } When {
+ get("/1")
+ } Then {
+ statusCode(404)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test that tries to obtain a topology.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testGetExisting() {
+ every { topologyService.findOne("testUser", 1, 1) } returns dummyTopology
+
+ Given {
+ pathParam("project", "1")
+ } When {
+ get("/1")
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ body("id", Matchers.equalTo(1))
+ println(extract().asPrettyString())
+ }
+ }
+
+ /**
+ * Test to delete a non-existent topology.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testUpdateNonExistent() {
+ every { topologyService.update("testUser", any(), any(), any()) } returns null
+
+ Given {
+ pathParam("project", "1")
+ body(Topology.Update(emptyList()))
+ contentType(ContentType.JSON)
+ } When {
+ put("/1")
+ } Then {
+ statusCode(404)
+ }
+ }
+
+ /**
+ * Test to update a topology.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testUpdate() {
+ every { topologyService.update("testUser", any(), any(), any()) } returns dummyTopology
+
+ Given {
+ pathParam("project", "1")
+ body(Topology.Update(emptyList()))
+ contentType(ContentType.JSON)
+ } When {
+ put("/1")
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ }
+ }
+
+ /**
+ * Test to delete a non-existent topology.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testDeleteNonExistent() {
+ every { topologyService.delete("testUser", 1, 1) } returns null
+
+ Given {
+ pathParam("project", "1")
+ } When {
+ delete("/1")
+ } Then {
+ statusCode(404)
+ }
+ }
+
+ /**
+ * Test to delete a topology.
+ */
+ @Test
+ @TestSecurity(user = "testUser", roles = ["openid"])
+ fun testDelete() {
+ every { topologyService.delete("testUser", 1, 1) } returns dummyTopology
+
+ Given {
+ pathParam("project", "1")
+ } When {
+ delete("/1")
+ } Then {
+ statusCode(200)
+ contentType(ContentType.JSON)
+ }
+ }
+}
diff --git a/opendc-web/opendc-web-api/static/schema.yml b/opendc-web/opendc-web-api/static/schema.yml
deleted file mode 100644
index 56cf58e7..00000000
--- a/opendc-web/opendc-web-api/static/schema.yml
+++ /dev/null
@@ -1,1631 +0,0 @@
-openapi: 3.0.0
-info:
- version: 2.1.0
- title: OpenDC REST API v2
- description: OpenDC is an open-source datacenter simulator for education, featuring
- real-time online collaboration, diverse simulation models, and detailed
- performance feedback statistics.
- license:
- name: MIT
- url: https://spdx.org/licenses/MIT
- contact:
- name: Support
- url: https://opendc.org
-servers:
- - url: https://api.opendc.org/v2
-externalDocs:
- description: OpenDC REST API v2
- url: https://api.opendc.com/v2/docs/
-security:
- - auth0:
- - openid
-paths:
- /projects:
- get:
- tags:
- - projects
- description: List Projects of the active user
- responses:
- "200":
- description: Successfully
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- type: array
- items:
- $ref: "#/components/schemas/Project"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- post:
- tags:
- - projects
- description: Add a Project.
- requestBody:
- content:
- application/json:
- schema:
- properties:
- name:
- type: string
- description: The new Project.
- required: true
- responses:
- "200":
- description: Successfully added Project.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Project"
- "400":
- description: Missing or incorrectly typed parameter.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Invalid"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "/projects/{projectId}":
- get:
- tags:
- - projects
- description: Get this Project.
- parameters:
- - name: projectId
- in: path
- description: Project's ID.
- required: true
- schema:
- type: string
- responses:
- "200":
- description: Successfully retrieved Project.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Project"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Project.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Project not found
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- put:
- tags:
- - projects
- description: Update this Project.
- parameters:
- - name: projectId
- in: path
- description: Project's ID.
- required: true
- schema:
- type: string
- requestBody:
- content:
- application/json:
- schema:
- properties:
- project:
- $ref: "#/components/schemas/Project"
- description: Project's new properties.
- required: true
- responses:
- "200":
- description: Successfully updated Project.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Project"
- "400":
- description: Missing or incorrectly typed parameter.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Invalid"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from updating Project.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Project not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- delete:
- tags:
- - projects
- description: Delete this project.
- parameters:
- - name: projectId
- in: path
- description: Project's ID.
- required: true
- schema:
- type: string
- responses:
- "200":
- description: Successfully deleted Project.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Project"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from deleting Project.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Project not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- "/projects/{projectId}/topologies":
- get:
- tags:
- - projects
- description: Get Project Topologies.
- parameters:
- - name: projectId
- in: path
- description: Project's ID.
- required: true
- schema:
- type: string
- responses:
- "200":
- description: Successfully retrieved Project Topologies.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- type: array
- items:
- $ref: "#/components/schemas/Topology"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Project.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Project not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- post:
- tags:
- - projects
- description: Add a Topology.
- parameters:
- - name: projectId
- in: path
- description: Project's ID.
- required: true
- schema:
- type: string
- requestBody:
- content:
- application/json:
- schema:
- properties:
- topology:
- $ref: "#/components/schemas/Topology"
- description: The new Topology.
- required: true
- responses:
- "200":
- description: Successfully added Topology.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Topology"
- "400":
- description: Missing or incorrectly typed parameter.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Invalid"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "404":
- description: Project not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- "/projects/{projectId}/portfolios":
- get:
- tags:
- - projects
- description: Get Project Portfolios.
- parameters:
- - name: projectId
- in: path
- description: Project's ID.
- required: true
- schema:
- type: string
- responses:
- "200":
- description: Successfully retrieved Project Portfolios.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- type: array
- items:
- $ref: "#/components/schemas/Portfolio"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Project.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Project not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- post:
- tags:
- - projects
- description: Add a Portfolio.
- parameters:
- - name: projectId
- in: path
- description: Project's ID.
- required: true
- schema:
- type: string
- requestBody:
- content:
- application/json:
- schema:
- properties:
- topology:
- $ref: "#/components/schemas/Portfolio"
- description: The new Portfolio.
- required: true
- responses:
- "200":
- description: Successfully added Portfolio.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Portfolio"
- "400":
- description: Missing or incorrectly typed parameter.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Invalid"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "404":
- description: Project not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- "/topologies/{topologyId}":
- get:
- tags:
- - topologies
- description: Get this Topology.
- parameters:
- - name: topologyId
- in: path
- description: Topology's ID.
- required: true
- schema:
- type: string
- responses:
- "200":
- description: Successfully retrieved Topology.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Topology"
- "400":
- description: Missing or incorrectly typed parameter.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Invalid"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Topology.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Topology not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- put:
- tags:
- - topologies
- description: Update this Topology's name.
- parameters:
- - name: topologyId
- in: path
- description: Topology's ID.
- required: true
- schema:
- type: string
- requestBody:
- content:
- application/json:
- schema:
- properties:
- topology:
- $ref: "#/components/schemas/Topology"
- description: Topology's new properties.
- required: true
- responses:
- "200":
- description: Successfully updated Topology.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Topology"
- "400":
- description: Missing or incorrectly typed parameter.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Invalid"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Project.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Project not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- delete:
- tags:
- - topologies
- description: Delete this Topology.
- parameters:
- - name: topologyId
- in: path
- description: Topology's ID.
- required: true
- schema:
- type: string
- responses:
- "200":
- description: Successfully deleted Topology.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Topology"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from deleting Topology.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Topology not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- "/portfolios/{portfolioId}":
- get:
- tags:
- - portfolios
- description: Get this Portfolio.
- parameters:
- - name: portfolioId
- in: path
- description: Portfolio's ID.
- required: true
- schema:
- type: string
- responses:
- "200":
- description: Successfully retrieved Portfolio.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Portfolio"
- "400":
- description: Missing or incorrectly typed parameter.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Invalid"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Portfolio.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Portfolio not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- put:
- tags:
- - portfolios
- description: Update this Portfolio.
- parameters:
- - name: portfolioId
- in: path
- description: Portfolio's ID.
- required: true
- schema:
- type: string
- requestBody:
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Portfolio"
- description: Portfolio's new properties.
- required: true
- responses:
- "200":
- description: Successfully updated Portfolio.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Portfolio"
- "400":
- description: Missing or incorrectly typed parameter.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Invalid"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Portfolio.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Portfolio not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- delete:
- tags:
- - portfolios
- description: Delete this Portfolio.
- parameters:
- - name: portfolioId
- in: path
- description: Portfolio's ID.
- required: true
- schema:
- type: string
- responses:
- "200":
- description: Successfully deleted Portfolio.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Portfolio"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Portfolio.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Portfolio not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- "/portfolios/{portfolioId}/scenarios":
- get:
- tags:
- - portfolios
- description: Get Portfolio Scenarios.
- parameters:
- - name: portfolioId
- in: path
- description: Portfolio's ID.
- required: true
- schema:
- type: string
- responses:
- "200":
- description: Successfully retrieved Portfolio Scenarios.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- type: array
- items:
- $ref: "#/components/schemas/Scenario"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Portfolio.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Portfolio not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- post:
- tags:
- - portfolios
- description: Add a Scenario.
- parameters:
- - name: portfolioId
- in: path
- description: Portfolio's ID.
- required: true
- schema:
- type: string
- requestBody:
- content:
- application/json:
- schema:
- properties:
- topology:
- $ref: "#/components/schemas/Scenario"
- description: The new Scenario.
- required: true
- responses:
- "200":
- description: Successfully added Scenario.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Scenario"
- "400":
- description: Missing or incorrectly typed parameter.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Invalid"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "404":
- description: Portfolio not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- "/scenarios/{scenarioId}":
- get:
- tags:
- - scenarios
- description: Get this Scenario.
- parameters:
- - name: scenarioId
- in: path
- description: Scenario's ID.
- required: true
- schema:
- type: string
- responses:
- "200":
- description: Successfully retrieved Scenario.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Scenario"
- "400":
- description: Missing or incorrectly typed parameter.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Invalid"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Scenario.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Scenario not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- put:
- tags:
- - scenarios
- description: Update this Scenario's name (other properties are read-only).
- parameters:
- - name: scenarioId
- in: path
- description: Scenario's ID.
- required: true
- schema:
- type: string
- requestBody:
- content:
- application/json:
- schema:
- $ref: "#/components/schemas/Scenario"
- description: Scenario with new name.
- required: true
- responses:
- "200":
- description: Successfully updated Scenario.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Scenario"
- "400":
- description: Missing or incorrectly typed parameter.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Invalid"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Scenario.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Scenario not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- delete:
- tags:
- - scenarios
- description: Delete this Scenario.
- parameters:
- - name: scenarioId
- in: path
- description: Scenario's ID.
- required: true
- schema:
- type: string
- responses:
- "200":
- description: Successfully deleted Scenario.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Scenario"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Scenario.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Scenario not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- /schedulers:
- get:
- tags:
- - simulation
- description: Get all available Schedulers
- responses:
- "200":
- description: Successfully retrieved Schedulers.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- type: array
- items:
- $ref: "#/components/schemas/Scheduler"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- /traces:
- get:
- tags:
- - simulation
- description: Get all available Traces
- responses:
- "200":
- description: Successfully retrieved Traces.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- type: array
- items:
- type: object
- properties:
- _id:
- type: string
- name:
- type: string
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "/traces/{traceId}":
- get:
- tags:
- - simulation
- description: Get this Trace.
- parameters:
- - name: traceId
- in: path
- description: Trace's ID.
- required: true
- schema:
- type: string
- responses:
- "200":
- description: Successfully retrieved Trace.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Trace"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "404":
- description: Trace not found
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- /prefabs:
- get:
- tags:
- - prefabs
- description: Get all Prefabs the user has rights to view.
- responses:
- "200":
- description: Successfully retrieved prefabs the user is authorized on.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- type: array
- items:
- $ref: "#/components/schemas/Prefab"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- post:
- tags:
- - prefabs
- description: Add a Prefab.
- requestBody:
- content:
- application/json:
- schema:
- properties:
- name:
- type: string
- description: The new Prefab.
- required: true
- responses:
- "200":
- description: Successfully added Prefab.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Prefab"
- "400":
- description: Missing or incorrectly typed parameter.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Invalid"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "/prefabs/{prefabId}":
- get:
- tags:
- - prefabs
- description: Get this Prefab.
- parameters:
- - name: prefabId
- in: path
- description: Prefab's ID.
- required: true
- schema:
- type: string
- responses:
- "200":
- description: Successfully retrieved Prefab.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Prefab"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Prefab.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Prefab not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- put:
- tags:
- - prefabs
- description: Update this Prefab.
- parameters:
- - name: prefabId
- in: path
- description: Prefab's ID.
- required: true
- schema:
- type: string
- requestBody:
- content:
- application/json:
- schema:
- properties:
- prefab:
- $ref: "#/components/schemas/Prefab"
- description: Prefab's new properties.
- required: true
- responses:
- "200":
- description: Successfully updated Prefab.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Prefab"
- "400":
- description: Missing or incorrectly typed parameter.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Invalid"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Prefab.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Prefab not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- delete:
- tags:
- - prefabs
- description: Delete this prefab.
- parameters:
- - name: prefabId
- in: path
- description: Prefab's ID.
- required: true
- schema:
- type: string
- responses:
- "200":
- description: Successfully deleted Prefab.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Prefab"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "404":
- description: Prefab not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- /jobs:
- get:
- tags:
- - jobs
- description: Get all available jobs to run.
- responses:
- "200":
- description: Successfully retrieved available jobs.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- type: array
- items:
- $ref: "#/components/schemas/Job"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "/jobs/{jobId}":
- get:
- tags:
- - jobs
- description: Get this Job.
- parameters:
- - name: jobId
- in: path
- description: Job's ID.
- required: true
- schema:
- type: string
- responses:
- "200":
- description: Successfully retrieved Job.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Job"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Job.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Job not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- post:
- tags:
- - jobs
- description: Update this Job.
- parameters:
- - name: jobId
- in: path
- description: Job's ID.
- required: true
- schema:
- type: string
- requestBody:
- content:
- application/json:
- schema:
- properties:
- job:
- $ref: "#/components/schemas/Job"
- description: Job's new properties.
- required: true
- responses:
- "200":
- description: Successfully updated Job.
- content:
- "application/json":
- schema:
- type: object
- required:
- - data
- properties:
- data:
- $ref: "#/components/schemas/Job"
- "400":
- description: Missing or incorrectly typed parameter.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Invalid"
- "401":
- description: Unauthorized.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Unauthorized"
- "403":
- description: Forbidden from retrieving Job.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Forbidden"
- "404":
- description: Job not found.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/NotFound"
- "409":
- description: State conflict.
- content:
- "application/json":
- schema:
- $ref: "#/components/schemas/Invalid"
-components:
- securitySchemes:
- auth0:
- type: oauth2
- x-token-validation-url: https://opendc.eu.auth0.com/userinfo
- flows:
- authorizationCode:
- authorizationUrl: https://opendc.eu.auth0.com/authorize
- tokenUrl: https://opendc.eu.auth0.com/oauth/token
- scopes:
- openid: Grants access to user_id
- runner: Grants access to runner jobs
- schemas:
- Unauthorized:
- type: object
- required:
- - message
- properties:
- message:
- type: string
- Invalid:
- type: object
- required:
- - message
- - errors
- properties:
- message:
- type: string
- errors:
- type: array
- items:
- type: string
- Forbidden:
- type: object
- required:
- - message
- properties:
- message:
- type: string
- NotFound:
- type: object
- required:
- - message
- properties:
- message:
- type: string
- Scheduler:
- type: object
- properties:
- name:
- type: string
- Project:
- type: object
- properties:
- _id:
- type: string
- name:
- type: string
- datetimeCreated:
- type: string
- format: dateTime
- datetimeLastEdited:
- type: string
- format: dateTime
- topologyIds:
- type: array
- items:
- type: string
- portfolioIds:
- type: array
- items:
- type: string
- authorizations:
- type: array
- items:
- type: object
- properties:
- userId:
- type: string
- level:
- type: string
- enum: ['OWN', 'EDIT', 'VIEW']
- Topology:
- type: object
- properties:
- _id:
- type: string
- projectId:
- type: string
- name:
- type: string
- rooms:
- type: array
- items:
- type: object
- properties:
- _id:
- type: string
- name:
- type: string
- tiles:
- type: array
- items:
- type: object
- properties:
- _id:
- type: string
- positionX:
- type: integer
- positionY:
- type: integer
- object:
- type: object
- properties:
- _id:
- type: string
- name:
- type: string
- capacity:
- type: integer
- powerCapacityW:
- type: integer
- machines:
- type: array
- items:
- type: object
- properties:
- _id:
- type: string
- position:
- type: integer
- cpus:
- type: array
- items:
- type: object
- properties:
- _id:
- type: string
- name:
- type: string
- clockRateMhz:
- type: integer
- numberOfCores:
- type: integer
- energyConsumptionW:
- type: integer
- gpus:
- type: array
- items:
- type: object
- properties:
- _id:
- type: string
- name:
- type: string
- clockRateMhz:
- type: integer
- numberOfCores:
- type: integer
- energyConsumptionW:
- type: integer
- memories:
- type: array
- items:
- type: object
- properties:
- _id:
- type: string
- name:
- type: string
- speedMbPerS:
- type: integer
- sizeMb:
- type: integer
- energyConsumptionW:
- type: integer
- storages:
- type: array
- items:
- type: object
- properties:
- _id:
- type: string
- name:
- type: string
- speedMbPerS:
- type: integer
- sizeMb:
- type: integer
- energyConsumptionW:
- type: integer
- Portfolio:
- type: object
- properties:
- _id:
- type: string
- projectId:
- type: string
- name:
- type: string
- scenarioIds:
- type: array
- items:
- type: string
- targets:
- type: object
- properties:
- enabledMetrics:
- type: array
- items:
- type: string
- repeatsPerScenario:
- type: integer
- Scenario:
- type: object
- properties:
- _id:
- type: string
- portfolioId:
- type: string
- name:
- type: string
- trace:
- type: object
- properties:
- traceId:
- type: string
- loadSamplingFraction:
- type: number
- topology:
- type: object
- properties:
- topologyId:
- type: string
- operational:
- type: object
- properties:
- failuresEnabled:
- type: boolean
- performanceInterferenceEnabled:
- type: boolean
- schedulerName:
- type: string
- Job:
- type: object
- properties:
- _id:
- type: string
- scenarioId:
- type: string
- state:
- type: string
- heartbeat:
- type: string
- results:
- type: object
- Trace:
- type: object
- properties:
- _id:
- type: string
- name:
- type: string
- path:
- type: string
- type:
- type: string
- Prefab:
- type: object
- properties:
- _id:
- type: string
- name:
- type: string
- datetimeCreated:
- type: string
- format: dateTime
- datetimeLastEdited:
- type: string
- format: dateTime
diff --git a/opendc-web/opendc-web-api/tests/api/test_jobs.py b/opendc-web/opendc-web-api/tests/api/test_jobs.py
deleted file mode 100644
index 2efe6933..00000000
--- a/opendc-web/opendc-web-api/tests/api/test_jobs.py
+++ /dev/null
@@ -1,139 +0,0 @@
-# Copyright (c) 2021 AtLarge Research
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in all
-# copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-#
-from datetime import datetime
-
-from opendc.exts import db
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-def test_get_jobs(client, mocker):
- mocker.patch.object(db, 'fetch_all', return_value=[
- {'_id': 'a', 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'}}
- ])
- res = client.get('/jobs/')
- assert '200' in res.status
-
-
-def test_get_job_non_existing(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/jobs/{test_id}').status
-
-
-def test_get_job(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value={
- '_id': 'a', 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'}
- })
- res = client.get(f'/jobs/{test_id}')
- assert '200' in res.status
-
-
-def test_update_job_nop(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value={
- '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'}
- })
- update_mock = mocker.patch.object(db, 'fetch_and_update', return_value={
- '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y',
- 'simulation': {'state': 'QUEUED', 'heartbeat': datetime.now()}
- })
- res = client.post(f'/jobs/{test_id}', json={'state': 'QUEUED'})
- assert '200' in res.status
- update_mock.assert_called_once()
-
-
-def test_update_job_invalid_state(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value={
- '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'}
- })
- res = client.post(f'/jobs/{test_id}', json={'state': 'FINISHED'})
- assert '400' in res.status
-
-
-def test_update_job_claim(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value={
- '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'}
- })
- update_mock = mocker.patch.object(db, 'fetch_and_update', return_value={
- '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y',
- 'simulation': {'state': 'CLAIMED', 'heartbeat': datetime.now()}
- })
- res = client.post(f'/jobs/{test_id}', json={'state': 'CLAIMED'})
- assert '200' in res.status
- update_mock.assert_called_once()
-
-
-def test_update_job_conflict(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value={
- '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'QUEUED'}
- })
- update_mock = mocker.patch.object(db, 'fetch_and_update', return_value=None)
- res = client.post(f'/jobs/{test_id}', json={'state': 'CLAIMED'})
- assert '409' in res.status
- update_mock.assert_called_once()
-
-
-def test_update_job_run(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value={
- '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'CLAIMED'}
- })
- update_mock = mocker.patch.object(db, 'fetch_and_update', return_value={
- '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y',
- 'simulation': {'state': 'RUNNING', 'heartbeat': datetime.now()}
- })
- res = client.post(f'/jobs/{test_id}', json={'state': 'RUNNING'})
- assert '200' in res.status
- update_mock.assert_called_once()
-
-
-def test_update_job_finished(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value={
- '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'RUNNING'}
- })
- update_mock = mocker.patch.object(db, 'fetch_and_update', return_value={
- '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y',
- 'simulation': {'state': 'FINISHED', 'heartbeat': datetime.now()}
- })
- res = client.post(f'/jobs/{test_id}', json={'state': 'FINISHED'})
- assert '200' in res.status
- update_mock.assert_called_once()
-
-
-def test_update_job_failed(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value={
- '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y', 'simulation': {'state': 'RUNNING'}
- })
- update_mock = mocker.patch.object(db, 'fetch_and_update', return_value={
- '_id': test_id, 'scenarioId': 'x', 'portfolioId': 'y',
- 'simulation': {'state': 'FAILED', 'heartbeat': datetime.now()}
- })
- res = client.post(f'/jobs/{test_id}', json={'state': 'FAILED'})
- assert '200' in res.status
- update_mock.assert_called_once()
diff --git a/opendc-web/opendc-web-api/tests/api/test_portfolios.py b/opendc-web/opendc-web-api/tests/api/test_portfolios.py
deleted file mode 100644
index 196fcb1c..00000000
--- a/opendc-web/opendc-web-api/tests/api/test_portfolios.py
+++ /dev/null
@@ -1,340 +0,0 @@
-# Copyright (c) 2021 AtLarge Research
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in all
-# copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-from opendc.exts import db
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-def test_get_portfolio_non_existing(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/portfolios/{test_id}').status
-
-
-def test_get_portfolio_no_authorizations(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value={'projectId': test_id, 'authorizations': []})
- res = client.get(f'/portfolios/{test_id}')
- assert '403' in res.status
-
-
-def test_get_portfolio_not_authorized(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- 'projectId': test_id,
- '_id': test_id,
- 'authorizations': []
- })
- res = client.get(f'/portfolios/{test_id}')
- assert '403' in res.status
-
-
-def test_get_portfolio(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- 'projectId': test_id,
- '_id': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'EDIT'
- }]
- })
- res = client.get(f'/portfolios/{test_id}')
- assert '200' in res.status
-
-
-def test_update_portfolio_missing_parameter(client):
- assert '400' in client.put(f'/portfolios/{test_id}').status
-
-
-def test_update_portfolio_non_existing(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/portfolios/{test_id}', json={
- 'portfolio': {
- 'name': 'test',
- 'targets': {
- 'enabledMetrics': ['test'],
- 'repeatsPerScenario': 2
- }
- }
- }).status
-
-
-def test_update_portfolio_not_authorized(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'VIEW'
- }]
- })
- mocker.patch.object(db, 'update', return_value={})
- assert '403' in client.put(f'/portfolios/{test_id}', json={
- 'portfolio': {
- 'name': 'test',
- 'targets': {
- 'enabledMetrics': ['test'],
- 'repeatsPerScenario': 2
- }
- }
- }).status
-
-
-def test_update_portfolio(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'OWN'
- }],
- 'targets': {
- 'enabledMetrics': [],
- 'repeatsPerScenario': 1
- }
- })
- mocker.patch.object(db, 'update', return_value={})
-
- res = client.put(f'/portfolios/{test_id}', json={'portfolio': {
- 'name': 'test',
- 'targets': {
- 'enabledMetrics': ['test'],
- 'repeatsPerScenario': 2
- }
- }})
- assert '200' in res.status
-
-
-def test_delete_project_non_existing(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.delete(f'/portfolios/{test_id}').status
-
-
-def test_delete_project_different_user(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'googleId': 'other_test',
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'VIEW'
- }]
- })
- mocker.patch.object(db, 'delete_one', return_value=None)
- assert '403' in client.delete(f'/portfolios/{test_id}').status
-
-
-def test_delete_project(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'googleId': 'test',
- 'portfolioIds': [test_id],
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'OWN'
- }]
- })
- mocker.patch.object(db, 'delete_one', return_value={})
- mocker.patch.object(db, 'update', return_value=None)
- res = client.delete(f'/portfolios/{test_id}')
- assert '200' in res.status
-
-
-def test_add_topology_missing_parameter(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'googleId': 'test',
- 'portfolioIds': [test_id],
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'OWN'
- }]
- })
- assert '400' in client.post(f'/projects/{test_id}/topologies').status
-
-
-def test_add_topology(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'OWN'
- }],
- 'topologyIds': []
- })
- mocker.patch.object(db,
- 'insert',
- return_value={
- '_id': test_id,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'topologyIds': []
- })
- mocker.patch.object(db, 'update', return_value={})
- res = client.post(f'/projects/{test_id}/topologies', json={'topology': {'name': 'test project', 'rooms': []}})
- assert 'rooms' in res.json['data']
- assert '200' in res.status
-
-
-def test_add_topology_not_authorized(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'VIEW'
- }]
- })
- assert '403' in client.post(f'/projects/{test_id}/topologies',
- json={
- 'topology': {
- 'name': 'test_topology',
- 'rooms': []
- }
- }).status
-
-
-def test_add_portfolio_missing_parameter(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'googleId': 'test',
- 'portfolioIds': [test_id],
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'OWN'
- }]
- })
- assert '400' in client.post(f'/projects/{test_id}/portfolios').status
-
-
-def test_add_portfolio_non_existing_project(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.post(f'/projects/{test_id}/portfolios',
- json={
- 'portfolio': {
- 'name': 'test',
- 'targets': {
- 'enabledMetrics': ['test'],
- 'repeatsPerScenario': 2
- }
- }
- }).status
-
-
-def test_add_portfolio_not_authorized(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'VIEW'
- }]
- })
- assert '403' in client.post(f'/projects/{test_id}/portfolios',
- json={
- 'portfolio': {
- 'name': 'test',
- 'targets': {
- 'enabledMetrics': ['test'],
- 'repeatsPerScenario': 2
- }
- }
- }).status
-
-
-def test_add_portfolio(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'portfolioIds': [test_id],
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'EDIT'
- }]
- })
- mocker.patch.object(db,
- 'insert',
- return_value={
- '_id': test_id,
- 'name': 'test',
- 'targets': {
- 'enabledMetrics': ['test'],
- 'repeatsPerScenario': 2
- },
- 'projectId': test_id,
- 'scenarioIds': [],
- })
- mocker.patch.object(db, 'update', return_value=None)
- res = client.post(
- f'/projects/{test_id}/portfolios',
- json={
- 'portfolio': {
- 'name': 'test',
- 'targets': {
- 'enabledMetrics': ['test'],
- 'repeatsPerScenario': 2
- }
- }
- })
- assert 'projectId' in res.json['data']
- assert 'scenarioIds' in res.json['data']
- assert '200' in res.status
-
-
-def test_get_portfolio_scenarios(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- 'projectId': test_id,
- '_id': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'EDIT'
- }]
- })
- mocker.patch.object(db, 'fetch_all', return_value=[{'_id': test_id}])
- res = client.get(f'/portfolios/{test_id}/scenarios')
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/tests/api/test_prefabs.py b/opendc-web/opendc-web-api/tests/api/test_prefabs.py
deleted file mode 100644
index ea3d92d6..00000000
--- a/opendc-web/opendc-web-api/tests/api/test_prefabs.py
+++ /dev/null
@@ -1,252 +0,0 @@
-# Copyright (c) 2021 AtLarge Research
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in all
-# copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-from unittest.mock import Mock
-from opendc.exts import db
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-def test_add_prefab_missing_parameter(client):
- assert '400' in client.post('/prefabs/').status
-
-
-def test_add_prefab(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id, 'authorizations': []})
- mocker.patch.object(db,
- 'insert',
- return_value={
- '_id': test_id,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': test_id
- })
- res = client.post('/prefabs/', json={'prefab': {'name': 'test prefab'}})
- assert 'datetimeCreated' in res.json['data']
- assert 'datetimeLastEdited' in res.json['data']
- assert 'authorId' in res.json['data']
- assert '200' in res.status
-
-
-def test_get_prefabs(client, mocker):
- db.fetch_all = Mock()
- mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id})
- db.fetch_all.side_effect = [
- [{
- '_id': test_id,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': test_id,
- 'visibility' : 'private'
- },
- {
- '_id': '2' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': test_id,
- 'visibility' : 'private'
- },
- {
- '_id': '3' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': test_id,
- 'visibility' : 'public'
- },
- {
- '_id': '4' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': test_id,
- 'visibility' : 'public'
- }],
- [{
- '_id': '5' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': '2' * 24,
- 'visibility' : 'public'
- },
- {
- '_id': '6' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': '2' * 24,
- 'visibility' : 'public'
- },
- {
- '_id': '7' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': '2' * 24,
- 'visibility' : 'public'
- },
- {
- '_id': '8' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': '2' * 24,
- 'visibility' : 'public'
- }]
- ]
- mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id})
- res = client.get('/prefabs/')
- assert '200' in res.status
-
-
-def test_get_prefab_non_existing(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/prefabs/{test_id}').status
-
-
-def test_get_private_prefab_not_authorized(client, mocker):
- db.fetch_one = Mock()
- db.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': test_id_2,
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- res = client.get(f'/prefabs/{test_id}')
- assert '403' in res.status
-
-
-def test_get_private_prefab(client, mocker):
- db.fetch_one = Mock()
- db.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': 'test',
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- res = client.get(f'/prefabs/{test_id}')
- assert '200' in res.status
-
-
-def test_get_public_prefab(client, mocker):
- db.fetch_one = Mock()
- db.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': test_id_2,
- 'visibility': 'public',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- res = client.get(f'/prefabs/{test_id}')
- assert '200' in res.status
-
-
-def test_update_prefab_missing_parameter(client):
- assert '400' in client.put(f'/prefabs/{test_id}').status
-
-
-def test_update_prefab_non_existing(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/prefabs/{test_id}', json={'prefab': {'name': 'S'}}).status
-
-
-def test_update_prefab_not_authorized(client, mocker):
- db.fetch_one = Mock()
- db.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': test_id_2,
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- mocker.patch.object(db, 'update', return_value={})
- assert '403' in client.put(f'/prefabs/{test_id}', json={'prefab': {'name': 'test prefab', 'rack': {}}}).status
-
-
-def test_update_prefab(client, mocker):
- db.fetch_one = Mock()
- db.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': 'test',
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- mocker.patch.object(db, 'update', return_value={})
- res = client.put(f'/prefabs/{test_id}', json={'prefab': {'name': 'test prefab', 'rack': {}}})
- assert '200' in res.status
-
-
-def test_delete_prefab_non_existing(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.delete(f'/prefabs/{test_id}').status
-
-
-def test_delete_prefab_different_user(client, mocker):
- db.fetch_one = Mock()
- db.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': test_id_2,
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- mocker.patch.object(db, 'delete_one', return_value=None)
- assert '403' in client.delete(f'/prefabs/{test_id}').status
-
-
-def test_delete_prefab(client, mocker):
- db.fetch_one = Mock()
- db.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': 'test',
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- mocker.patch.object(db, 'delete_one', return_value={'prefab': {'name': 'name'}})
- res = client.delete(f'/prefabs/{test_id}')
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/tests/api/test_projects.py b/opendc-web/opendc-web-api/tests/api/test_projects.py
deleted file mode 100644
index 1cfe4c52..00000000
--- a/opendc-web/opendc-web-api/tests/api/test_projects.py
+++ /dev/null
@@ -1,197 +0,0 @@
-# Copyright (c) 2021 AtLarge Research
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in all
-# copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-from opendc.exts import db
-
-test_id = 24 * '1'
-
-
-def test_get_user_projects(client, mocker):
- mocker.patch.object(db, 'fetch_all', return_value={'_id': test_id, 'authorizations': [{'userId': 'test',
- 'level': 'OWN'}]})
- res = client.get('/projects/')
- assert '200' in res.status
-
-
-def test_get_user_topologies(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'EDIT'
- }]
- })
- mocker.patch.object(db, 'fetch_all', return_value=[{'_id': test_id}])
- res = client.get(f'/projects/{test_id}/topologies')
- assert '200' in res.status
-
-
-def test_get_user_portfolios(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'EDIT'
- }]
- })
- mocker.patch.object(db, 'fetch_all', return_value=[{'_id': test_id}])
- res = client.get(f'/projects/{test_id}/portfolios')
- assert '200' in res.status
-
-
-def test_add_project_missing_parameter(client):
- assert '400' in client.post('/projects/').status
-
-
-def test_add_project(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id, 'authorizations': []})
- mocker.patch.object(db,
- 'insert',
- return_value={
- '_id': test_id,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'topologyIds': []
- })
- mocker.patch.object(db, 'update', return_value={})
- res = client.post('/projects/', json={'project': {'name': 'test project'}})
- assert 'datetimeCreated' in res.json['data']
- assert 'datetimeLastEdited' in res.json['data']
- assert 'topologyIds' in res.json['data']
- assert '200' in res.status
-
-
-def test_get_project_non_existing(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/projects/{test_id}').status
-
-
-def test_get_project_no_authorizations(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value={'authorizations': []})
- res = client.get(f'/projects/{test_id}')
- assert '403' in res.status
-
-
-def test_get_project_not_authorized(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': []
- })
- res = client.get(f'/projects/{test_id}')
- assert '403' in res.status
-
-
-def test_get_project(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'EDIT'
- }]
- })
- res = client.get(f'/projects/{test_id}')
- assert '200' in res.status
-
-
-def test_update_project_missing_parameter(client):
- assert '400' in client.put(f'/projects/{test_id}').status
-
-
-def test_update_project_non_existing(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/projects/{test_id}', json={'project': {'name': 'S'}}).status
-
-
-def test_update_project_not_authorized(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'VIEW'
- }]
- })
- mocker.patch.object(db, 'update', return_value={})
- assert '403' in client.put(f'/projects/{test_id}', json={'project': {'name': 'S'}}).status
-
-
-def test_update_project(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'OWN'
- }]
- })
- mocker.patch.object(db, 'update', return_value={})
-
- res = client.put(f'/projects/{test_id}', json={'project': {'name': 'S'}})
- assert '200' in res.status
-
-
-def test_delete_project_non_existing(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.delete(f'/projects/{test_id}').status
-
-
-def test_delete_project_different_user(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'googleId': 'other_test',
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'VIEW'
- }],
- 'topologyIds': []
- })
- mocker.patch.object(db, 'delete_one', return_value=None)
- assert '403' in client.delete(f'/projects/{test_id}').status
-
-
-def test_delete_project(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'googleId': 'test',
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'OWN'
- }],
- 'topologyIds': [],
- 'portfolioIds': [],
- })
- mocker.patch.object(db, 'update', return_value=None)
- mocker.patch.object(db, 'delete_one', return_value={'googleId': 'test'})
- res = client.delete(f'/projects/{test_id}')
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/tests/api/test_scenarios.py b/opendc-web/opendc-web-api/tests/api/test_scenarios.py
deleted file mode 100644
index bdd5c4a3..00000000
--- a/opendc-web/opendc-web-api/tests/api/test_scenarios.py
+++ /dev/null
@@ -1,135 +0,0 @@
-# Copyright (c) 2021 AtLarge Research
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in all
-# copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-from opendc.exts import db
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-def test_get_scenario_non_existing(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/scenarios/{test_id}').status
-
-
-def test_get_scenario_no_authorizations(client, mocker):
- m = mocker.MagicMock()
- m.side_effect = ({'portfolioId': test_id}, {'projectId': test_id}, {'authorizations': []})
- mocker.patch.object(db, 'fetch_one', m)
- res = client.get(f'/scenarios/{test_id}')
- assert '403' in res.status
-
-
-def test_get_scenario(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- side_effect=[
- {'portfolioId': test_id},
- {'projectId': test_id},
- {'authorizations':
- [{'userId': 'test', 'level': 'OWN'}]
- }])
- res = client.get(f'/scenarios/{test_id}')
- assert '200' in res.status
-
-
-def test_update_scenario_missing_parameter(client):
- assert '400' in client.put(f'/scenarios/{test_id}').status
-
-
-def test_update_scenario_non_existing(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/scenarios/{test_id}', json={
- 'scenario': {
- 'name': 'test',
- }
- }).status
-
-
-def test_update_scenario_not_authorized(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- side_effect=[
- {'portfolioId': test_id},
- {'projectId': test_id},
- {'authorizations':
- [{'userId': 'test', 'level': 'VIEW'}]
- }])
- mocker.patch.object(db, 'update', return_value={})
- assert '403' in client.put(f'/scenarios/{test_id}', json={
- 'scenario': {
- 'name': 'test',
- }
- }).status
-
-
-def test_update_scenario(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- side_effect=[
- {'_id': test_id, 'portfolioId': test_id},
- {'projectId': test_id},
- {'authorizations':
- [{'userId': 'test', 'level': 'OWN'}]
- }])
- mocker.patch.object(db, 'update', return_value={})
-
- res = client.put(f'/scenarios/{test_id}', json={'scenario': {
- 'name': 'test',
- }})
- assert '200' in res.status
-
-
-def test_delete_project_non_existing(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.delete(f'/scenarios/{test_id}').status
-
-
-def test_delete_project_different_user(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- side_effect=[
- {'_id': test_id, 'portfolioId': test_id},
- {'projectId': test_id},
- {'authorizations':
- [{'userId': 'test', 'level': 'VIEW'}]
- }])
- mocker.patch.object(db, 'delete_one', return_value=None)
- assert '403' in client.delete(f'/scenarios/{test_id}').status
-
-
-def test_delete_project(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'portfolioId': test_id,
- 'googleId': 'test',
- 'scenarioIds': [test_id],
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'OWN'
- }]
- })
- mocker.patch.object(db, 'delete_one', return_value={})
- mocker.patch.object(db, 'update', return_value=None)
- res = client.delete(f'/scenarios/{test_id}')
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/tests/api/test_schedulers.py b/opendc-web/opendc-web-api/tests/api/test_schedulers.py
deleted file mode 100644
index 5d9e6995..00000000
--- a/opendc-web/opendc-web-api/tests/api/test_schedulers.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# Copyright (c) 2021 AtLarge Research
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in all
-# copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-def test_get_schedulers(client):
- assert '200' in client.get('/schedulers/').status
diff --git a/opendc-web/opendc-web-api/tests/api/test_topologies.py b/opendc-web/opendc-web-api/tests/api/test_topologies.py
deleted file mode 100644
index 6e7c54ef..00000000
--- a/opendc-web/opendc-web-api/tests/api/test_topologies.py
+++ /dev/null
@@ -1,140 +0,0 @@
-# Copyright (c) 2021 AtLarge Research
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in all
-# copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-from opendc.exts import db
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-def test_get_topology(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'EDIT'
- }]
- })
- res = client.get(f'/topologies/{test_id}')
- assert '200' in res.status
-
-
-def test_get_topology_non_existing(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.get('/topologies/1').status
-
-
-def test_get_topology_not_authorized(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': []
- })
- res = client.get(f'/topologies/{test_id}')
- assert '403' in res.status
-
-
-def test_get_topology_no_authorizations(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value={'projectId': test_id, 'authorizations': []})
- res = client.get(f'/topologies/{test_id}')
- assert '403' in res.status
-
-
-def test_update_topology_missing_parameter(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': []
- })
- assert '400' in client.put(f'/topologies/{test_id}').status
-
-
-def test_update_topology_non_existent(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/topologies/{test_id}', json={'topology': {'name': 'test_topology', 'rooms': []}}).status
-
-
-def test_update_topology_not_authorized(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': []
- })
- mocker.patch.object(db, 'update', return_value={})
- assert '403' in client.put(f'/topologies/{test_id}', json={
- 'topology': {
- 'name': 'updated_topology',
- 'rooms': []
- }
- }).status
-
-
-def test_update_topology(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'OWN'
- }]
- })
- mocker.patch.object(db, 'update', return_value={})
-
- assert '200' in client.put(f'/topologies/{test_id}', json={
- 'topology': {
- 'name': 'updated_topology',
- 'rooms': []
- }
- }).status
-
-
-def test_delete_topology(client, mocker):
- mocker.patch.object(db,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'googleId': 'test',
- 'topologyIds': [test_id],
- 'authorizations': [{
- 'userId': 'test',
- 'level': 'OWN'
- }]
- })
- mocker.patch.object(db, 'delete_one', return_value={})
- mocker.patch.object(db, 'update', return_value=None)
- res = client.delete(f'/topologies/{test_id}')
- assert '200' in res.status
-
-
-def test_delete_nonexistent_topology(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.delete(f'/topologies/{test_id}').status
diff --git a/opendc-web/opendc-web-api/tests/api/test_traces.py b/opendc-web/opendc-web-api/tests/api/test_traces.py
deleted file mode 100644
index 0b252c2f..00000000
--- a/opendc-web/opendc-web-api/tests/api/test_traces.py
+++ /dev/null
@@ -1,40 +0,0 @@
-# Copyright (c) 2021 AtLarge Research
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in all
-# copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-
-from opendc.exts import db
-
-test_id = 24 * '1'
-
-
-def test_get_traces(client, mocker):
- mocker.patch.object(db, 'fetch_all', return_value=[])
- assert '200' in client.get('/traces/').status
-
-
-def test_get_trace_non_existing(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/traces/{test_id}').status
-
-
-def test_get_trace(client, mocker):
- mocker.patch.object(db, 'fetch_one', return_value={'name': 'test trace'})
- res = client.get(f'/traces/{test_id}')
- assert 'name' in res.json['data']
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Job.kt b/opendc-web/opendc-web-client/build.gradle.kts
index eeb65e49..f53b29d8 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Job.kt
+++ b/opendc-web/opendc-web-client/build.gradle.kts
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 AtLarge Research
+ * Copyright (c) 2022 AtLarge Research
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -20,19 +20,18 @@
* SOFTWARE.
*/
-package org.opendc.web.client.model
+description = "Client for the OpenDC web API"
-import com.fasterxml.jackson.annotation.JsonProperty
-import java.time.LocalDateTime
+/* Build configuration */
+plugins {
+ `kotlin-library-conventions`
+ `testing-conventions`
+ `jacoco-conventions`
+}
-/**
- * A description of a simulation job.
- */
-public data class Job(
- @JsonProperty("_id")
- val id: String,
- val scenarioId: String,
- val state: SimulationState,
- val heartbeat: LocalDateTime,
- val results: Map<String, Any>
-)
+dependencies {
+ api(projects.opendcWeb.opendcWebProto)
+ implementation(libs.jackson.module.kotlin)
+ implementation(libs.jackson.datatype.jsr310)
+ implementation(libs.jakarta.validation)
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/OpenDCClient.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/OpenDCClient.kt
new file mode 100644
index 00000000..33f2b41e
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/OpenDCClient.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client
+
+import org.opendc.web.client.auth.AuthController
+import org.opendc.web.client.transport.HttpTransportClient
+import org.opendc.web.client.transport.TransportClient
+import java.net.URI
+
+/**
+ * Client implementation for the user-facing OpenDC REST API (version 2).
+ *
+ * @param client Low-level client for managing the underlying transport.
+ */
+public class OpenDCClient(client: TransportClient) {
+ /**
+ * Construct a new [OpenDCClient].
+ *
+ * @param baseUrl The base url of the API.
+ * @param auth Helper class for managing authentication.
+ */
+ public constructor(baseUrl: URI, auth: AuthController) : this(HttpTransportClient(baseUrl, auth))
+
+ /**
+ * A resource for the available projects.
+ */
+ public val projects: ProjectResource = ProjectResource(client)
+
+ /**
+ * A resource for the topologies available to the user.
+ */
+ public val topologies: TopologyResource = TopologyResource(client)
+
+ /**
+ * A resource for the portfolios available to the user.
+ */
+ public val portfolios: PortfolioResource = PortfolioResource(client)
+
+ /**
+ * A resource for the scenarios available to the user.
+ */
+ public val scenarios: ScenarioResource = ScenarioResource(client)
+
+ /**
+ * A resource for the available schedulers.
+ */
+ public val schedulers: SchedulerResource = SchedulerResource(client)
+
+ /**
+ * A resource for the available workload traces.
+ */
+ public val traces: TraceResource = TraceResource(client)
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/PortfolioResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/PortfolioResource.kt
new file mode 100644
index 00000000..399804e8
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/PortfolioResource.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client
+
+import org.opendc.web.client.internal.delete
+import org.opendc.web.client.internal.get
+import org.opendc.web.client.internal.post
+import org.opendc.web.client.transport.TransportClient
+import org.opendc.web.proto.user.Portfolio
+
+/**
+ * A resource representing the portfolios available to the user.
+ */
+public class PortfolioResource internal constructor(private val client: TransportClient) {
+ /**
+ * List all portfolios that belong to the specified [project].
+ */
+ public fun getAll(project: Long): List<Portfolio> = client.get("projects/$project/portfolios") ?: emptyList()
+
+ /**
+ * Obtain the portfolio for [project] with [number].
+ */
+ public fun get(project: Long, number: Int): Portfolio? = client.get("projects/$project/portfolios/$number")
+
+ /**
+ * Create a new portfolio for [project] with the specified [request].
+ */
+ public fun create(project: Long, request: Portfolio.Create): Portfolio {
+ return checkNotNull(client.post("projects/$project/portfolios", request))
+ }
+
+ /**
+ * Delete the portfolio for [project] with [index].
+ */
+ public fun delete(project: Long, index: Int): Portfolio {
+ return requireNotNull(client.delete("projects/$project/portfolios/$index")) { "Unknown portfolio $index" }
+ }
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ProjectResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ProjectResource.kt
new file mode 100644
index 00000000..12635b89
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ProjectResource.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client
+
+import org.opendc.web.client.internal.*
+import org.opendc.web.client.transport.TransportClient
+import org.opendc.web.proto.user.Project
+
+/**
+ * A resource representing the projects available to the user.
+ */
+public class ProjectResource internal constructor(private val client: TransportClient) {
+ /**
+ * List all projects available to the user.
+ */
+ public fun getAll(): List<Project> = client.get("projects") ?: emptyList()
+
+ /**
+ * Obtain the project with [id].
+ */
+ public fun get(id: Long): Project? = client.get("projects/$id")
+
+ /**
+ * Create a new project.
+ */
+ public fun create(name: String): Project = checkNotNull(client.post("projects", Project.Create(name)))
+
+ /**
+ * Delete the project with the specified [id].
+ */
+ public fun delete(id: Long): Project = requireNotNull(client.delete("projects/$id")) { "Unknown project $id" }
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ScenarioResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ScenarioResource.kt
new file mode 100644
index 00000000..7055e752
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ScenarioResource.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client
+
+import org.opendc.web.client.internal.delete
+import org.opendc.web.client.internal.get
+import org.opendc.web.client.internal.post
+import org.opendc.web.client.transport.TransportClient
+import org.opendc.web.proto.user.Scenario
+
+/**
+ * A resource representing the scenarios available to the user.
+ */
+public class ScenarioResource internal constructor(private val client: TransportClient) {
+ /**
+ * List all scenarios that belong to the specified [project].
+ */
+ public fun getAll(project: Long): List<Scenario> = client.get("projects/$project/scenarios") ?: emptyList()
+
+ /**
+ * List all scenarios that belong to the specified [portfolioNumber].
+ */
+ public fun getAll(project: Long, portfolioNumber: Int): List<Scenario> = client.get("projects/$project/portfolios/$portfolioNumber/scenarios") ?: emptyList()
+
+ /**
+ * Obtain the scenario for [project] with [index].
+ */
+ public fun get(project: Long, index: Int): Scenario? = client.get("projects/$project/scenarios/$index")
+
+ /**
+ * Create a new scenario for [portfolio][portfolioNumber] with the specified [request].
+ */
+ public fun create(project: Long, portfolioNumber: Int, request: Scenario.Create): Scenario {
+ return checkNotNull(client.post("projects/$project/portfolios/$portfolioNumber", request))
+ }
+
+ /**
+ * Delete the scenario for [project] with [index].
+ */
+ public fun delete(project: Long, index: Int): Scenario {
+ return requireNotNull(client.delete("projects/$project/scenarios/$index")) { "Unknown scenario $index" }
+ }
+}
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/AuthConfiguration.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/SchedulerResource.kt
index 5dbf2f59..43b72d88 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/AuthConfiguration.kt
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/SchedulerResource.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 AtLarge Research
+ * Copyright (c) 2022 AtLarge Research
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -22,11 +22,15 @@
package org.opendc.web.client
+import org.opendc.web.client.internal.get
+import org.opendc.web.client.transport.TransportClient
+
/**
- * The authentication configuration for the API client.
+ * A resource representing the schedulers available in the OpenDC instance.
*/
-public data class AuthConfiguration(
- val domain: String,
- val clientId: String,
- val clientSecret: String
-)
+public class SchedulerResource internal constructor(private val client: TransportClient) {
+ /**
+ * List all schedulers available.
+ */
+ public fun getAll(): List<String> = client.get("schedulers") ?: emptyList()
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TopologyResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TopologyResource.kt
new file mode 100644
index 00000000..c37ae8da
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TopologyResource.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client
+
+import org.opendc.web.client.internal.delete
+import org.opendc.web.client.internal.get
+import org.opendc.web.client.internal.post
+import org.opendc.web.client.internal.put
+import org.opendc.web.client.transport.TransportClient
+import org.opendc.web.proto.user.Topology
+
+/**
+ * A resource representing the topologies available to the user.
+ */
+public class TopologyResource internal constructor(private val client: TransportClient) {
+ /**
+ * List all topologies that belong to the specified [project].
+ */
+ public fun getAll(project: Long): List<Topology> = client.get("projects/$project/topologies") ?: emptyList()
+
+ /**
+ * Obtain the topology for [project] with [index].
+ */
+ public fun get(project: Long, index: Int): Topology? = client.get("projects/$project/topologies/$index")
+
+ /**
+ * Create a new topology for [project] with [request].
+ */
+ public fun create(project: Long, request: Topology.Create): Topology {
+ return checkNotNull(client.post("projects/$project/topologies", request))
+ }
+
+ /**
+ * Update the topology with [index] for [project] using the specified [request].
+ */
+ public fun update(project: Long, index: Int, request: Topology.Update): Topology? {
+ return client.put("projects/$project/topologies/$index", request)
+ }
+
+ /**
+ * Delete the topology for [project] with [index].
+ */
+ public fun delete(project: Long, index: Long): Topology {
+ return requireNotNull(client.delete("projects/$project/topologies/$index")) { "Unknown topology $index" }
+ }
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TraceResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TraceResource.kt
new file mode 100644
index 00000000..8201c432
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TraceResource.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client
+
+import org.opendc.web.client.internal.*
+import org.opendc.web.client.transport.TransportClient
+import org.opendc.web.proto.Trace
+
+/**
+ * A resource representing the workload traces available in the OpenDC instance.
+ */
+public class TraceResource internal constructor(private val client: TransportClient) {
+ /**
+ * List all workload traces available.
+ */
+ public fun getAll(): List<Trace> = client.get("traces") ?: emptyList()
+
+ /**
+ * Obtain the workload trace with the specified [id].
+ */
+ public fun get(id: Long): Trace? = client.get("traces/$id")
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/AuthController.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/AuthController.kt
new file mode 100644
index 00000000..a4c66f55
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/AuthController.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.auth
+
+import java.net.http.HttpRequest
+
+/**
+ * Helper interface for managing API authentication.
+ */
+public interface AuthController {
+ /**
+ * Inject the authorization token into the specified [request].
+ */
+ public fun injectToken(request: HttpRequest.Builder)
+
+ /**
+ * Refresh the current auth token.
+ */
+ public fun refreshToken()
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/OpenIdAuthController.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/OpenIdAuthController.kt
new file mode 100644
index 00000000..7f9cbacd
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/OpenIdAuthController.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.auth
+
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.fasterxml.jackson.module.kotlin.readValue
+import org.opendc.web.client.internal.OAuthTokenRequest
+import org.opendc.web.client.internal.OAuthTokenResponse
+import org.opendc.web.client.internal.OpenIdConfiguration
+import java.net.URI
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+
+/**
+ * An [AuthController] for OpenID Connect protected APIs.
+ */
+public class OpenIdAuthController(
+ private val domain: String,
+ private val clientId: String,
+ private val clientSecret: String,
+ private val audience: String = "https://api.opendc.org/v2/",
+ private val client: HttpClient = HttpClient.newHttpClient()
+) : AuthController {
+ /**
+ * The Jackson object mapper to convert messages from/to JSON.
+ */
+ private val mapper = jacksonObjectMapper()
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+
+ /**
+ * The cached [OpenIdConfiguration].
+ */
+ private val openidConfig: OpenIdConfiguration
+ get() {
+ var openidConfig = _openidConfig
+ if (openidConfig == null) {
+ openidConfig = requestConfig()
+ _openidConfig = openidConfig
+ }
+
+ return openidConfig
+ }
+ private var _openidConfig: OpenIdConfiguration? = null
+
+ /**
+ * The cached OAuth token.
+ */
+ private var _token: OAuthTokenResponse? = null
+
+ override fun injectToken(request: HttpRequest.Builder) {
+ var token = _token
+ if (token == null) {
+ token = requestToken()
+ _token = token
+ }
+
+ request.header("Authorization", "Bearer ${token.accessToken}")
+ }
+
+ /**
+ * Refresh the current access token.
+ */
+ override fun refreshToken() {
+ val refreshToken = _token?.refreshToken
+ if (refreshToken == null) {
+ requestToken()
+ return
+ }
+
+ _token = refreshToken(openidConfig, refreshToken)
+ }
+
+ /**
+ * Request the OpenID configuration from the chosen auth domain
+ */
+ private fun requestConfig(): OpenIdConfiguration {
+ val request = HttpRequest.newBuilder(URI("https://$domain/.well-known/openid-configuration"))
+ .GET()
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream())
+ return mapper.readValue(response.body())
+ }
+
+ /**
+ * Request the auth token from the server.
+ */
+ private fun requestToken(openidConfig: OpenIdConfiguration): OAuthTokenResponse {
+ val body = OAuthTokenRequest.ClientCredentials(audience, clientId, clientSecret)
+ val request = HttpRequest.newBuilder(openidConfig.tokenEndpoint)
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(body)))
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream())
+ return mapper.readValue(response.body())
+ }
+
+ /**
+ * Helper method to refresh the auth token.
+ */
+ private fun refreshToken(openidConfig: OpenIdConfiguration, refreshToken: String): OAuthTokenResponse {
+ val body = OAuthTokenRequest.RefreshToken(refreshToken, clientId, clientSecret)
+ val request = HttpRequest.newBuilder(openidConfig.tokenEndpoint)
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(body)))
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream())
+ return mapper.readValue(response.body())
+ }
+
+ /**
+ * Fetch a new access token.
+ */
+ private fun requestToken(): OAuthTokenResponse {
+ val token = requestToken(openidConfig)
+ _token = token
+ return token
+ }
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/ClientUtils.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/ClientUtils.kt
new file mode 100644
index 00000000..29cf09dc
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/ClientUtils.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.internal
+
+import com.fasterxml.jackson.core.type.TypeReference
+import org.opendc.web.client.transport.TransportClient
+
+/**
+ * Perform a GET request for resource at [path] and convert to type [T].
+ */
+internal inline fun <reified T> TransportClient.get(path: String): T? {
+ return get(path, object : TypeReference<T>() {})
+}
+
+/**
+ * Perform a POST request for resource at [path] and convert to type [T].
+ */
+internal inline fun <B, reified T> TransportClient.post(path: String, body: B): T? {
+ return post(path, body, object : TypeReference<T>() {})
+}
+
+/**
+ * Perform a PUT request for resource at [path] and convert to type [T].
+ */
+internal inline fun <B, reified T> TransportClient.put(path: String, body: B): T? {
+ return put(path, body, object : TypeReference<T>() {})
+}
+
+/**
+ * Perform a DELETE request for resource at [path] and convert to type [T].
+ */
+internal inline fun <reified T> TransportClient.delete(path: String): T? {
+ return delete(path, object : TypeReference<T>() {})
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenRequest.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenRequest.kt
new file mode 100644
index 00000000..25341995
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenRequest.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.internal
+
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.fasterxml.jackson.annotation.JsonSubTypes
+import com.fasterxml.jackson.annotation.JsonTypeInfo
+
+/**
+ * Token request sent to the OAuth server.
+ */
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "grant_type")
+@JsonSubTypes(
+ value = [
+ JsonSubTypes.Type(value = OAuthTokenRequest.ClientCredentials::class, name = "client_credentials"),
+ JsonSubTypes.Type(value = OAuthTokenRequest.RefreshToken::class, name = "refresh_token")
+ ]
+)
+internal sealed class OAuthTokenRequest {
+ /**
+ * Client credentials grant for OAuth2
+ */
+ data class ClientCredentials(
+ val audience: String,
+ @JsonProperty("client_id")
+ val clientId: String,
+ @JsonProperty("client_secret")
+ val clientSecret: String
+ ) : OAuthTokenRequest()
+
+ /**
+ * Refresh token grant for OAuth2.
+ */
+ data class RefreshToken(
+ @JsonProperty("refresh_token")
+ val refreshToken: String,
+ @JsonProperty("client_id")
+ val clientId: String,
+ @JsonProperty("client_secret")
+ val clientSecret: String
+ ) : OAuthTokenRequest()
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenResponse.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenResponse.kt
new file mode 100644
index 00000000..cd5ccab0
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenResponse.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.internal
+
+import com.fasterxml.jackson.annotation.JsonProperty
+
+/**
+ * Token response from the OAuth server.
+ */
+internal data class OAuthTokenResponse(
+ @JsonProperty("access_token")
+ val accessToken: String,
+ @JsonProperty("refresh_token")
+ val refreshToken: String? = null,
+ @JsonProperty("token_type")
+ val tokenType: String,
+ val scope: String = "",
+ @JsonProperty("expires_in")
+ val expiresIn: Long
+)
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OpenIdConfiguration.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OpenIdConfiguration.kt
new file mode 100644
index 00000000..23fbf368
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OpenIdConfiguration.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.internal
+
+import com.fasterxml.jackson.annotation.JsonProperty
+import java.net.URI
+
+/**
+ * OpenID configuration exposed by the auth server.
+ */
+internal data class OpenIdConfiguration(
+ val issuer: String,
+ @JsonProperty("authorization_endpoint")
+ val authorizationEndpoint: URI,
+ @JsonProperty("token_endpoint")
+ val tokenEndpoint: URI,
+ @JsonProperty("userinfo_endpoint")
+ val userInfoEndpoint: URI,
+ @JsonProperty("jwks_uri")
+ val jwksUri: URI,
+ @JsonProperty("scopes_supported")
+ val scopesSupported: Set<String>
+)
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/JobResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/JobResource.kt
new file mode 100644
index 00000000..372a92d7
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/JobResource.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.runner
+
+import org.opendc.web.client.internal.*
+import org.opendc.web.client.transport.TransportClient
+import org.opendc.web.proto.runner.Job
+
+/**
+ * A resource representing the available simulation jobs for the runner.
+ */
+public class JobResource internal constructor(private val client: TransportClient) {
+ /**
+ * Query the pending jobs.
+ */
+ public fun queryPending(): List<Job> = client.get("jobs") ?: emptyList()
+
+ /**
+ * Obtain the job with [id].
+ */
+ public fun get(id: Long): Job? = client.get("jobs/$id")
+
+ /**
+ * Update the job with [id].
+ */
+ public fun update(id: Long, update: Job.Update): Job? = client.post("jobs/$id", update)
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/OpenDCRunnerClient.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/OpenDCRunnerClient.kt
new file mode 100644
index 00000000..a3cff6c3
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/OpenDCRunnerClient.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.runner
+
+import org.opendc.web.client.*
+import org.opendc.web.client.auth.AuthController
+import org.opendc.web.client.transport.HttpTransportClient
+import org.opendc.web.client.transport.TransportClient
+import java.net.URI
+
+/**
+ * Client implementation for the runner-facing OpenDC REST API (version 2).
+ *
+ * @param client Low-level client for managing the underlying transport.
+ */
+public class OpenDCRunnerClient(client: TransportClient) {
+ /**
+ * Construct a new [OpenDCRunnerClient].
+ *
+ * @param baseUrl The base url of the API.
+ * @param auth Helper class for managing authentication.
+ */
+ public constructor(baseUrl: URI, auth: AuthController) : this(HttpTransportClient(baseUrl, auth))
+
+ /**
+ * A resource for the available simulation jobs.
+ */
+ public val jobs: JobResource = JobResource(client)
+
+ /**
+ * A resource for the available schedulers.
+ */
+ public val schedulers: SchedulerResource = SchedulerResource(client)
+
+ /**
+ * A resource for the available workload traces.
+ */
+ public val traces: TraceResource = TraceResource(client)
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/HttpTransportClient.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/HttpTransportClient.kt
new file mode 100644
index 00000000..03b3945f
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/HttpTransportClient.kt
@@ -0,0 +1,145 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.transport
+
+import com.fasterxml.jackson.core.type.TypeReference
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import org.opendc.web.client.auth.AuthController
+import java.net.URI
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.nio.file.Paths
+
+/**
+ * A [TransportClient] that accesses the OpenDC API over HTTP.
+ *
+ * @param baseUrl The base url of the API.
+ * @param auth Helper class for managing authentication.
+ * @param client The HTTP client to use.
+ */
+public class HttpTransportClient(
+ private val baseUrl: URI,
+ private val auth: AuthController,
+ private val client: HttpClient = HttpClient.newHttpClient()
+) : TransportClient {
+ /**
+ * The Jackson object mapper to convert messages from/to JSON.
+ */
+ private val mapper = jacksonObjectMapper()
+ .registerModule(JavaTimeModule())
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+
+ /**
+ * Obtain a resource at [path] of [targetType].
+ */
+ override fun <T> get(path: String, targetType: TypeReference<T>): T? {
+ val request = HttpRequest.newBuilder(buildUri(path))
+ .GET()
+ .also { auth.injectToken(it) }
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream())
+
+ return when (val code = response.statusCode()) {
+ in 200..299 -> mapper.readValue(response.body(), targetType)
+ 401 -> {
+ auth.refreshToken()
+ get(path, targetType)
+ }
+ 404 -> null
+ else -> throw IllegalStateException("Invalid response $code")
+ }
+ }
+
+ /**
+ * Update a resource at [path] of [targetType].
+ */
+ override fun <B, T> post(path: String, body: B, targetType: TypeReference<T>): T? {
+ val request = HttpRequest.newBuilder(buildUri(path))
+ .POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(body)))
+ .header("Content-Type", "application/json")
+ .also { auth.injectToken(it) }
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream())
+
+ return when (val code = response.statusCode()) {
+ in 200..299 -> mapper.readValue(response.body(), targetType)
+ 401 -> {
+ auth.refreshToken()
+ post(path, body, targetType)
+ }
+ 404 -> null
+ else -> throw IllegalStateException("Invalid response $code")
+ }
+ }
+
+ /**
+ * Replace a resource at [path] of [targetType].
+ */
+ override fun <B, T> put(path: String, body: B, targetType: TypeReference<T>): T? {
+ val request = HttpRequest.newBuilder(buildUri(path))
+ .PUT(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(body)))
+ .header("Content-Type", "application/json")
+ .also { auth.injectToken(it) }
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream())
+
+ return when (val code = response.statusCode()) {
+ in 200..299 -> mapper.readValue(response.body(), targetType)
+ 401 -> {
+ auth.refreshToken()
+ put(path, body, targetType)
+ }
+ 404 -> null
+ else -> throw IllegalStateException("Invalid response $code")
+ }
+ }
+
+ /**
+ * Delete a resource at [path] of [targetType].
+ */
+ override fun <T> delete(path: String, targetType: TypeReference<T>): T? {
+ val request = HttpRequest.newBuilder(buildUri(path))
+ .DELETE()
+ .also { auth.injectToken(it) }
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream())
+
+ return when (val code = response.statusCode()) {
+ in 200..299 -> mapper.readValue(response.body(), targetType)
+ 401 -> {
+ auth.refreshToken()
+ delete(path, targetType)
+ }
+ 404 -> null
+ else -> throw IllegalStateException("Invalid response $code")
+ }
+ }
+
+ /**
+ * Build the absolute [URI] to which the request should be sent.
+ */
+ private fun buildUri(path: String): URI = baseUrl.resolve(Paths.get(baseUrl.path, path).toString())
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/TransportClient.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/TransportClient.kt
new file mode 100644
index 00000000..af727ca7
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/TransportClient.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.transport
+
+import com.fasterxml.jackson.core.type.TypeReference
+
+/**
+ * Low-level interface for dealing with the transport layer of the API.
+ */
+public interface TransportClient {
+ /**
+ * Obtain a resource at [path] of [targetType].
+ */
+ public fun <T> get(path: String, targetType: TypeReference<T>): T?
+
+ /**
+ * Update a resource at [path] of [targetType].
+ */
+ public fun <B, T> post(path: String, body: B, targetType: TypeReference<T>): T?
+
+ /**
+ * Replace a resource at [path] of [targetType].
+ */
+ public fun <B, T> put(path: String, body: B, targetType: TypeReference<T>): T?
+
+ /**
+ * Delete a resource at [path] of [targetType].
+ */
+ public fun <T> delete(path: String, targetType: TypeReference<T>): T?
+}
diff --git a/opendc-web/opendc-web-ui/src/api/prefabs.js b/opendc-web/opendc-web-proto/build.gradle.kts
index eb9aa23c..4a566346 100644
--- a/opendc-web/opendc-web-ui/src/api/prefabs.js
+++ b/opendc-web/opendc-web-proto/build.gradle.kts
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 AtLarge Research
+ * Copyright (c) 2020 AtLarge Research
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -20,20 +20,20 @@
* SOFTWARE.
*/
-import { request } from './index'
+description = "Web communication protocol for OpenDC"
-export function getPrefab(auth, prefabId) {
- return request(auth, `prefabs/${prefabId}`)
+/* Build configuration */
+plugins {
+ `kotlin-library-conventions`
+ id("org.kordamp.gradle.jandex") // Necessary for Quarkus to process annotations
}
-export function addPrefab(auth, prefab) {
- return request(auth, 'prefabs/', 'POST', { prefab })
+dependencies {
+ implementation(libs.jackson.annotations)
+ implementation(libs.jakarta.validation)
+ implementation(libs.microprofile.openapi.api)
}
-export function updatePrefab(auth, prefab) {
- return request(auth, `prefabs/${prefab._id}`, 'PUT', { prefab })
-}
-
-export function deletePrefab(auth, prefabId) {
- return request(auth, `prefabs/${prefabId}`, 'DELETE')
+tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
+ kotlinOptions.javaParameters = true
}
diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/JobState.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/JobState.kt
new file mode 100644
index 00000000..38b8ca42
--- /dev/null
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/JobState.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.proto
+
+/**
+ * State of a scenario for the simulator runner.
+ */
+public enum class JobState {
+ /**
+ * The job is pending to be claimed by a runner.
+ */
+ PENDING,
+
+ /**
+ * The job is claimed by a runner.
+ */
+ CLAIMED,
+
+ /**
+ * The job is currently running.
+ */
+ RUNNING,
+
+ /**
+ * The job has finished.
+ */
+ FINISHED,
+
+ /**
+ * The job has failed.
+ */
+ FAILED;
+}
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Machine.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Machine.kt
index 86d2d46f..f5c50cc3 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Machine.kt
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Machine.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 AtLarge Research
+ * Copyright (c) 2022 AtLarge Research
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -20,17 +20,14 @@
* SOFTWARE.
*/
-package org.opendc.web.client.model
+package org.opendc.web.proto
-import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
/**
* A machine in a rack.
*/
-@JsonIgnoreProperties("id_legacy")
public data class Machine(
- @JsonProperty("_id")
val id: String,
val position: Int,
val cpus: List<ProcessingUnit> = emptyList(),
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/MemoryUnit.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/MemoryUnit.kt
index 11e794e8..1fc604fa 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/MemoryUnit.kt
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/MemoryUnit.kt
@@ -20,15 +20,12 @@
* SOFTWARE.
*/
-package org.opendc.web.client.model
-
-import com.fasterxml.jackson.annotation.JsonProperty
+package org.opendc.web.proto
/**
* A memory unit in a system.
*/
public data class MemoryUnit(
- @JsonProperty("_id")
val id: String,
val name: String,
val speedMbPerS: Double,
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/OperationalPhenomena.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/OperationalPhenomena.kt
index ef5b4902..f3164f64 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/OperationalPhenomena.kt
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/OperationalPhenomena.kt
@@ -20,13 +20,12 @@
* SOFTWARE.
*/
-package org.opendc.web.client.model
+package org.opendc.web.proto
/**
* Object describing the enabled operational phenomena for a scenario.
*/
public data class OperationalPhenomena(
- val failuresEnabled: Boolean,
- val performanceInterferenceEnabled: Boolean,
- val schedulerName: String
+ val failures: Boolean,
+ val interference: Boolean
)
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ProcessingUnit.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/ProcessingUnit.kt
index 449b5c43..5f79d1bd 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ProcessingUnit.kt
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/ProcessingUnit.kt
@@ -20,15 +20,12 @@
* SOFTWARE.
*/
-package org.opendc.web.client.model
-
-import com.fasterxml.jackson.annotation.JsonProperty
+package org.opendc.web.proto
/**
* A CPU model.
*/
public data class ProcessingUnit(
- @JsonProperty("_id")
val id: String,
val name: String,
val clockRateMhz: Double,
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ScenarioTopology.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/ProtocolError.kt
index 2b90f7ef..e7fe2702 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ScenarioTopology.kt
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/ProtocolError.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 AtLarge Research
+ * Copyright (c) 2022 AtLarge Research
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -20,9 +20,9 @@
* SOFTWARE.
*/
-package org.opendc.web.client.model
+package org.opendc.web.proto
/**
- * The topology details for a scenario.
+ * Container for reporting errors.
*/
-public data class ScenarioTopology(val topologyId: String)
+public data class ProtocolError(val code: Int, val message: String)
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Rack.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Rack.kt
index a0464388..131aa184 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Rack.kt
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Rack.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 AtLarge Research
+ * Copyright (c) 2022 AtLarge Research
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -20,17 +20,12 @@
* SOFTWARE.
*/
-package org.opendc.web.client.model
-
-import com.fasterxml.jackson.annotation.JsonIgnoreProperties
-import com.fasterxml.jackson.annotation.JsonProperty
+package org.opendc.web.proto
/**
* A rack in a datacenter.
*/
-@JsonIgnoreProperties("id_legacy")
-public class Rack(
- @JsonProperty("_id")
+public data class Rack(
val id: String,
val name: String,
val capacity: Int,
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Room.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Room.kt
index f1b8f946..5b305168 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Room.kt
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Room.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 AtLarge Research
+ * Copyright (c) 2022 AtLarge Research
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -20,17 +20,12 @@
* SOFTWARE.
*/
-package org.opendc.web.client.model
-
-import com.fasterxml.jackson.annotation.JsonIgnoreProperties
-import com.fasterxml.jackson.annotation.JsonProperty
+package org.opendc.web.proto
/**
* A room in a datacenter.
*/
-@JsonIgnoreProperties("id_legacy")
public data class Room(
- @JsonProperty("_id")
val id: String,
val name: String,
val tiles: Set<RoomTile>,
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/RoomTile.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/RoomTile.kt
index 0b956262..666d66ee 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/RoomTile.kt
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/RoomTile.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 AtLarge Research
+ * Copyright (c) 2022 AtLarge Research
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -20,17 +20,12 @@
* SOFTWARE.
*/
-package org.opendc.web.client.model
-
-import com.fasterxml.jackson.annotation.JsonIgnoreProperties
-import com.fasterxml.jackson.annotation.JsonProperty
+package org.opendc.web.proto
/**
* A room tile.
*/
-@JsonIgnoreProperties("id_legacy")
public data class RoomTile(
- @JsonProperty("_id")
val id: String,
val positionX: Double,
val positionY: Double,
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/PortfolioTargets.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Targets.kt
index 07c11c19..a0100f72 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/PortfolioTargets.kt
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Targets.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 AtLarge Research
+ * Copyright (c) 2022 AtLarge Research
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -20,9 +20,18 @@
* SOFTWARE.
*/
-package org.opendc.web.client.model
+package org.opendc.web.proto
+
+import javax.validation.constraints.Min
/**
* The targets of a portfolio.
+ *
+ * @param metrics The selected metrics to track during simulation.
+ * @param repeats The number of repetitions per scenario.
*/
-public data class PortfolioTargets(val enabledMetrics: Set<String>, val repeatsPerScenario: Int)
+public data class Targets(
+ val metrics: Set<String>,
+ @field:Min(1)
+ val repeats: Int = 1
+)
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Portfolio.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Trace.kt
index 6904920b..2952a273 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Portfolio.kt
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Trace.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 AtLarge Research
+ * Copyright (c) 2022 AtLarge Research
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -20,19 +20,17 @@
* SOFTWARE.
*/
-package org.opendc.web.client.model
-
-import com.fasterxml.jackson.annotation.JsonProperty
+package org.opendc.web.proto
/**
- * A portfolio in OpenDC.
+ * A workload trace available for simulation.
+ *
+ * @param id The unique identifier of the trace.
+ * @param name The name of the trace.
+ * @param type The type of trace.
*/
-public data class Portfolio(
- @JsonProperty("_id")
+public data class Trace(
val id: String,
- val projectId: String,
val name: String,
- @JsonProperty("scenarioIds")
- val scenarios: Set<String>,
- val targets: PortfolioTargets
+ val type: String,
)
diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Workload.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Workload.kt
new file mode 100644
index 00000000..cc6e0ed8
--- /dev/null
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/Workload.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2021 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.proto
+
+import javax.validation.constraints.DecimalMax
+import javax.validation.constraints.DecimalMin
+
+/**
+ * The workload to simulate for a scenario.
+ */
+public data class Workload(val trace: Trace, val samplingFraction: Double) {
+ /**
+ * Specification for a workload.
+ *
+ * @param trace The unique identifier of the trace.
+ * @param samplingFraction The fraction of the workload to sample.
+ */
+ public data class Spec(
+ val trace: String,
+ @DecimalMin(value = "0.001", message = "Sampling fraction must be non-zero")
+ @DecimalMax(value = "1", message = "Sampling fraction cannot exceed one")
+ val samplingFraction: Double
+ )
+}
diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Job.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Job.kt
new file mode 100644
index 00000000..dfaaa09e
--- /dev/null
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Job.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.proto.runner
+
+import org.eclipse.microprofile.openapi.annotations.media.Schema
+import org.opendc.web.proto.JobState
+import java.time.Instant
+
+/**
+ * A simulation job to be simulated by a runner.
+ */
+@Schema(name = "Runner.Job")
+public data class Job(
+ val id: Long,
+ val scenario: Scenario,
+ val state: JobState,
+ val createdAt: Instant,
+ val updatedAt: Instant,
+ val results: Map<String, Any>? = null
+) {
+ /**
+ * A request to update the state of a job.
+ */
+ @Schema(name = "Runner.Job.Update")
+ public data class Update(val state: JobState, val results: Map<String, Any>? = null)
+}
diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Portfolio.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Portfolio.kt
new file mode 100644
index 00000000..916d8cf0
--- /dev/null
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Portfolio.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.proto.runner
+
+import org.eclipse.microprofile.openapi.annotations.media.Schema
+import org.opendc.web.proto.Targets
+import org.opendc.web.proto.user.Portfolio
+
+/**
+ * A [Portfolio] as seen from the runner's perspective.
+ *
+ * @param id The unique identifier of the portfolio.
+ * @param number The number of the portfolio for the project.
+ * @param name The name of the portfolio.
+ * @param targets The targets of the portfolio.
+ */
+@Schema(name = "Runner.Portfolio")
+public data class Portfolio(
+ val id: Long,
+ val number: Int,
+ val name: String,
+ val targets: Targets,
+)
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Scenario.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Scenario.kt
index 851ff980..c5e609ec 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Scenario.kt
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Scenario.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 AtLarge Research
+ * Copyright (c) 2022 AtLarge Research
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -20,20 +20,22 @@
* SOFTWARE.
*/
-package org.opendc.web.client.model
+package org.opendc.web.proto.runner
-import com.fasterxml.jackson.annotation.JsonProperty
+import org.eclipse.microprofile.openapi.annotations.media.Schema
+import org.opendc.web.proto.*
/**
- * A simulation scenario.
+ * A [Scenario] that is exposed to an OpenDC runner.
*/
+@Schema(name = "Runner.Scenario")
public data class Scenario(
- @JsonProperty("_id")
- val id: String,
- val portfolioId: String,
+ val id: Long,
+ val number: Int,
+ val portfolio: Portfolio,
val name: String,
- val trace: ScenarioTrace,
- val topology: ScenarioTopology,
- @JsonProperty("operational")
- val operationalPhenomena: OperationalPhenomena
+ val workload: Workload,
+ val topology: Topology,
+ val phenomena: OperationalPhenomena,
+ val schedulerName: String
)
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Topology.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Topology.kt
index b59aba42..ea576e71 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/Topology.kt
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/runner/Topology.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2021 AtLarge Research
+ * Copyright (c) 2022 AtLarge Research
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -20,19 +20,21 @@
* SOFTWARE.
*/
-package org.opendc.web.client.model
+package org.opendc.web.proto.runner
-import com.fasterxml.jackson.annotation.JsonIgnoreProperties
-import com.fasterxml.jackson.annotation.JsonProperty
+import org.eclipse.microprofile.openapi.annotations.media.Schema
+import org.opendc.web.proto.*
+import java.time.Instant
/**
- * Model for an OpenDC topology.
+ * A [Topology] that is exposed to an OpenDC runner.
*/
-@JsonIgnoreProperties("id_legacy", "datacenter_id_legacy", "datetimeLastUpdated", "datetimeLastEdited")
+@Schema(name = "Runner.Topology")
public data class Topology(
- @JsonProperty("_id")
- val id: String,
- val projectId: String,
+ val id: Long,
+ val number: Int,
val name: String,
- val rooms: Set<Room>,
+ val rooms: List<Room>,
+ val createdAt: Instant,
+ val updatedAt: Instant,
)
diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Job.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Job.kt
new file mode 100644
index 00000000..de5f8de3
--- /dev/null
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Job.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.proto.user
+
+import org.opendc.web.proto.JobState
+import org.opendc.web.proto.runner.Job
+import java.time.Instant
+
+/**
+ * A simulation job that is associated with a [Scenario].
+ *
+ * This entity is exposed in the runner-facing API via [Job].
+ */
+public data class Job(
+ val id: Long,
+ val state: JobState,
+ val createdAt: Instant,
+ val updatedAt: Instant,
+ val results: Map<String, Any>? = null
+)
diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Portfolio.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Portfolio.kt
new file mode 100644
index 00000000..6f468e79
--- /dev/null
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Portfolio.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.proto.user
+
+import org.eclipse.microprofile.openapi.annotations.media.Schema
+import org.opendc.web.proto.Targets
+import javax.validation.constraints.NotBlank
+
+/**
+ * A portfolio is the composition of multiple scenarios.
+ *
+ * @param id The unique identifier of the portfolio.
+ * @param number The number of the portfolio with respect to the project.
+ * @param project The project to which the portfolio belongs.
+ * @param name The name of the portfolio.
+ * @param targets The targets of the portfolio.
+ * @param scenarios The scenarios in the portfolio.
+ */
+public data class Portfolio(
+ val id: Long,
+ val number: Int,
+ val project: Project,
+ val name: String,
+ val targets: Targets,
+ val scenarios: List<Scenario.Summary>
+) {
+ /**
+ * A request to create a new portfolio.
+ */
+ @Schema(name = "Portfolio.Update")
+ public data class Create(
+ @field:NotBlank(message = "Name must not be empty")
+ val name: String,
+ val targets: Targets
+ )
+
+ /**
+ * A summary view of a [Portfolio] provided for nested relations.
+ *
+ * @param id The unique identifier of the portfolio.
+ * @param number The number of the portfolio for the project.
+ * @param name The name of the portfolio.
+ * @param targets The targets of the portfolio.
+ */
+ @Schema(name = "Portfolio.Summary")
+ public data class Summary(
+ val id: Long,
+ val number: Int,
+ val name: String,
+ val targets: Targets,
+ )
+}
diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Project.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Project.kt
new file mode 100644
index 00000000..3a2807ca
--- /dev/null
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Project.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.proto.user
+
+import org.eclipse.microprofile.openapi.annotations.media.Schema
+import java.time.Instant
+import javax.validation.constraints.NotBlank
+
+/**
+ * A project in OpenDC encapsulates all the datacenter designs and simulation runs for a set of users.
+ */
+public data class Project(
+ val id: Long,
+ val name: String,
+ val createdAt: Instant,
+ val updatedAt: Instant,
+ val role: ProjectRole
+) {
+ /**
+ * A request to create a new project.
+ */
+ @Schema(name = "Project.Create")
+ public data class Create(@field:NotBlank(message = "Name must not be empty") val name: String)
+}
diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/ProjectRole.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/ProjectRole.kt
new file mode 100644
index 00000000..0f6de1fc
--- /dev/null
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/ProjectRole.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.proto.user
+
+/**
+ * The role of a user in a project.
+ */
+public enum class ProjectRole {
+ /**
+ * The user is allowed to view the project.
+ */
+ VIEWER,
+
+ /**
+ * The user is allowed to edit the project.
+ */
+ EDITOR,
+
+ /**
+ * The user owns the project (so he can delete it).
+ */
+ OWNER,
+}
diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Scenario.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Scenario.kt
new file mode 100644
index 00000000..552a4912
--- /dev/null
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Scenario.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.proto.user
+
+import org.eclipse.microprofile.openapi.annotations.media.Schema
+import org.opendc.web.proto.OperationalPhenomena
+import org.opendc.web.proto.Workload
+import javax.validation.constraints.NotBlank
+
+/**
+ * A single scenario to be explored by the simulator.
+ */
+public data class Scenario(
+ val id: Long,
+ val number: Int,
+ val project: Project,
+ val portfolio: Portfolio.Summary,
+ val name: String,
+ val workload: Workload,
+ val topology: Topology.Summary,
+ val phenomena: OperationalPhenomena,
+ val schedulerName: String,
+ val job: Job
+) {
+ /**
+ * Create a new scenario.
+ *
+ * @param name The name of the scenario.
+ * @param workload The workload specification to use for the scenario.
+ * @param topology The number of the topology to use.
+ * @param phenomena The phenomena to model during simulation.
+ * @param schedulerName The name of the scheduler.
+ */
+ @Schema(name = "Scenario.Create")
+ public data class Create(
+ @field:NotBlank(message = "Name must not be empty")
+ val name: String,
+ val workload: Workload.Spec,
+ val topology: Long,
+ val phenomena: OperationalPhenomena,
+ val schedulerName: String,
+ )
+
+ /**
+ * A summary view of a [Scenario] provided for nested relations.
+ *
+ * @param id The unique identifier of the scenario.
+ * @param number The number of the scenario for the project.
+ * @param name The name of the scenario.
+ * @param workload The workload to be modeled by the scenario.
+ * @param phenomena The phenomena simulated for this scenario.
+ * @param schedulerName The scheduler name to use for the experiment.
+ * @param job The simulation job associated with the scenario.
+ */
+ @Schema(name = "Scenario.Summary")
+ public data class Summary(
+ val id: Long,
+ val number: Int,
+ val name: String,
+ val workload: Workload,
+ val topology: Topology.Summary,
+ val phenomena: OperationalPhenomena,
+ val schedulerName: String,
+ val job: Job
+ )
+}
diff --git a/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Topology.kt b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Topology.kt
new file mode 100644
index 00000000..a144a2e6
--- /dev/null
+++ b/opendc-web/opendc-web-proto/src/main/kotlin/org/opendc/web/proto/user/Topology.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.proto.user
+
+import org.eclipse.microprofile.openapi.annotations.media.Schema
+import org.opendc.web.proto.Room
+import java.time.Instant
+import javax.validation.constraints.NotBlank
+
+/**
+ * Model for an OpenDC topology.
+ */
+public data class Topology(
+ val id: Long,
+ val number: Int,
+ val project: Project,
+ val name: String,
+ val rooms: List<Room>,
+ val createdAt: Instant,
+ val updatedAt: Instant,
+) {
+ /**
+ * Create a new topology for a project.
+ */
+ @Schema(name = "Topology.Create")
+ public data class Create(
+ @field:NotBlank(message = "Name must not be empty")
+ val name: String,
+ val rooms: List<Room>
+ )
+
+ /**
+ * Update an existing topology.
+ */
+ @Schema(name = "Topology.Update")
+ public data class Update(val rooms: List<Room>)
+
+ /**
+ * A summary view of a [Topology] provided for nested relations.
+ *
+ * @param id The unique identifier of the topology.
+ * @param number The number of the topology for the project.
+ * @param name The name of the topology.
+ * @param createdAt The instant at which the topology was created.
+ * @param updatedAt The instant at which the topology was updated.
+ */
+ @Schema(name = "Topology.Summary")
+ public data class Summary(
+ val id: Long,
+ val number: Int,
+ val name: String,
+ val createdAt: Instant,
+ val updatedAt: Instant,
+ )
+}
diff --git a/opendc-web/opendc-web-runner/Dockerfile b/opendc-web/opendc-web-runner/Dockerfile
new file mode 100644
index 00000000..771ed2ed
--- /dev/null
+++ b/opendc-web/opendc-web-runner/Dockerfile
@@ -0,0 +1,18 @@
+FROM openjdk:17-slim
+MAINTAINER OpenDC Maintainers <opendc@atlarge-research.com>
+
+# Obtain (cache) Gradle wrapper
+COPY gradlew /app/
+COPY gradle /app/gradle
+WORKDIR /app
+RUN ./gradlew --version
+
+# Build project
+COPY ./ /app/
+RUN ./gradlew --no-daemon :installDist
+
+FROM openjdk:17-slim
+COPY --from=0 /app/build/install /opt/
+COPY --from=0 /app/traces /opt/opendc/traces
+WORKDIR /opt/opendc
+CMD bin/opendc-web-runner
diff --git a/opendc-web/opendc-web-runner/build.gradle.kts b/opendc-web/opendc-web-runner/build.gradle.kts
index a73ca6b3..3c80f605 100644
--- a/opendc-web/opendc-web-runner/build.gradle.kts
+++ b/opendc-web/opendc-web-runner/build.gradle.kts
@@ -40,18 +40,13 @@ dependencies {
implementation(projects.opendcTelemetry.opendcTelemetrySdk)
implementation(projects.opendcTelemetry.opendcTelemetryCompute)
implementation(projects.opendcTrace.opendcTraceApi)
+ implementation(projects.opendcWeb.opendcWebClient)
implementation(libs.kotlin.logging)
implementation(libs.clikt)
implementation(libs.sentry.log4j2)
- implementation(libs.ktor.client.cio)
- implementation(libs.ktor.client.auth)
- implementation(libs.ktor.client.jackson)
- implementation(libs.jackson.datatype.jsr310)
implementation(kotlin("reflect"))
runtimeOnly(projects.opendcTrace.opendcTraceOpendc)
runtimeOnly(libs.log4j.slf4j)
-
- testImplementation(libs.ktor.client.mock)
}
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/ApiClient.kt b/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/ApiClient.kt
deleted file mode 100644
index 9f2656c4..00000000
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/ApiClient.kt
+++ /dev/null
@@ -1,179 +0,0 @@
-/*
- * Copyright (c) 2021 AtLarge Research
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all
- * copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- * SOFTWARE.
- */
-
-package org.opendc.web.client
-
-import com.fasterxml.jackson.annotation.JsonProperty
-import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
-import io.ktor.client.*
-import io.ktor.client.features.auth.*
-import io.ktor.client.features.auth.providers.*
-import io.ktor.client.features.json.*
-import io.ktor.client.request.*
-import io.ktor.client.statement.*
-import io.ktor.http.*
-import org.opendc.web.client.model.*
-import java.net.URI
-
-/**
- * Client implementation for the OpenDC REST API (version 2).
- *
- * @param baseUrl The base url of the API.
- * @param auth The authentication configuration for the client.
- * @param client The HTTP client to use.
- */
-public class ApiClient(
- private val baseUrl: URI,
- private val auth: AuthConfiguration,
- private val audience: String = "https://api.opendc.org/v2/",
- client: HttpClient = HttpClient {}
-) : AutoCloseable {
- /**
- * The Ktor [HttpClient] that is used to communicate with the REST API.
- */
- private val client = client.config {
- install(JsonFeature) {
- serializer = JacksonSerializer {
- registerModule(JavaTimeModule())
- }
- }
- install(Auth) {
- bearer {
- loadTokens { requestToken() }
- refreshTokens { requestToken() }
- }
- }
- expectSuccess = false
- }
-
- /**
- * Retrieve the topology with the specified [id].
- */
- public suspend fun getPortfolio(id: String): Portfolio? {
- val url = URLBuilder(Url(baseUrl))
- .path("portfolios", id)
- .build()
- return when (val result = client.get<ApiResult<Portfolio>>(url)) {
- is ApiResult.Success -> result.data
- else -> null
- }
- }
-
- /**
- * Retrieve the scenario with the specified [id].
- */
- public suspend fun getScenario(id: String): Scenario? {
- val url = URLBuilder(Url(baseUrl))
- .path("scenarios", id)
- .build()
- return when (val result = client.get<ApiResult<Scenario>>(url)) {
- is ApiResult.Success -> result.data
- else -> null
- }
- }
-
- /**
- * Retrieve the topology with the specified [id].
- */
- public suspend fun getTopology(id: String): Topology? {
- val url = URLBuilder(Url(baseUrl))
- .path("topologies", id)
- .build()
- return when (val result = client.get<ApiResult<Topology>>(url)) {
- is ApiResult.Success -> result.data
- else -> null
- }
- }
-
- /**
- * Retrieve the available jobs.
- */
- public suspend fun getJobs(): List<Job> {
- val url = URLBuilder(Url(baseUrl))
- .path("jobs")
- .build()
- return when (val result = client.get<ApiResult<List<Job>>>(url)) {
- is ApiResult.Success -> result.data
- else -> emptyList()
- }
- }
-
- /**
- * Update the specified job.
- *
- * @param id The identifier of the job.
- * @param state The new state of the job.
- * @param results The results of the job.
- */
- public suspend fun updateJob(id: String, state: SimulationState, results: Map<String, Any> = emptyMap()): Boolean {
- val url = URLBuilder(Url(baseUrl))
- .path("jobs", id)
- .build()
-
- data class Request(
- val state: SimulationState,
- val results: Map<String, Any>
- )
-
- val res = client.post<HttpResponse> {
- url(url)
- contentType(ContentType.Application.Json)
- body = Request(state, results)
- }
- return res.status.isSuccess()
- }
-
- /**
- * Request the auth token for the API.
- */
- private suspend fun requestToken(): BearerTokens {
- data class Request(
- val audience: String,
- @JsonProperty("grant_type")
- val grantType: String,
- @JsonProperty("client_id")
- val clientId: String,
- @JsonProperty("client_secret")
- val clientSecret: String
- )
-
- data class Response(
- @JsonProperty("access_token")
- val accessToken: String,
- @JsonProperty("token_type")
- val tokenType: String,
- val scope: String = "",
- @JsonProperty("expires_in")
- val expiresIn: Long
- )
-
- val result = client.post<Response> {
- url(Url("https://${auth.domain}/oauth/token"))
- contentType(ContentType.Application.Json)
- body = Request(audience, "client_credentials", auth.clientId, auth.clientSecret)
- }
-
- return BearerTokens(result.accessToken, "")
- }
-
- override fun close() = client.close()
-}
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ScenarioTrace.kt b/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ScenarioTrace.kt
deleted file mode 100644
index adff6d97..00000000
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/client/model/ScenarioTrace.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * Copyright (c) 2021 AtLarge Research
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all
- * copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- * SOFTWARE.
- */
-
-package org.opendc.web.client.model
-
-/**
- * The trace details of a scenario.
- */
-public data class ScenarioTrace(val traceId: String, val loadSamplingFraction: Double)
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/Main.kt b/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/Main.kt
index 94ef8f8e..561dcd59 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/Main.kt
+++ b/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/Main.kt
@@ -44,15 +44,15 @@ import org.opendc.simulator.compute.power.SimplePowerDriver
import org.opendc.simulator.core.runBlockingSimulation
import org.opendc.telemetry.compute.collectServiceMetrics
import org.opendc.telemetry.sdk.metrics.export.CoroutineMetricReader
-import org.opendc.web.client.ApiClient
-import org.opendc.web.client.AuthConfiguration
-import org.opendc.web.client.model.Scenario
+import org.opendc.web.client.auth.OpenIdAuthController
+import org.opendc.web.client.runner.OpenDCRunnerClient
+import org.opendc.web.proto.runner.Job
+import org.opendc.web.proto.runner.Scenario
import java.io.File
import java.net.URI
import java.time.Duration
import java.util.*
-import org.opendc.web.client.model.Portfolio as ClientPortfolio
-import org.opendc.web.client.model.Topology as ClientTopology
+import org.opendc.web.proto.runner.Topology as ClientTopology
private val logger = KotlinLogging.logger {}
@@ -134,18 +134,18 @@ class RunnerCli : CliktCommand(name = "runner") {
.default(60L * 3) // Experiment may run for a maximum of three minutes
/**
- * Converge a single scenario.
+ * Run a simulation job.
*/
- private suspend fun runScenario(portfolio: ClientPortfolio, scenario: Scenario, topology: Topology): List<WebComputeMetricExporter.Result> {
- val id = scenario.id
+ private suspend fun runJob(job: Job, topology: Topology): List<WebComputeMetricExporter.Result> {
+ val id = job.id
+ val scenario = job.scenario
logger.info { "Constructing performance interference model" }
val workloadLoader = ComputeWorkloadLoader(tracePath)
val interferenceModel = let {
- val path = tracePath.resolve(scenario.trace.traceId).resolve("performance-interference-model.json")
- val operational = scenario.operationalPhenomena
- val enabled = operational.performanceInterferenceEnabled
+ val path = tracePath.resolve(scenario.workload.trace.id).resolve("performance-interference-model.json")
+ val enabled = scenario.phenomena.interference
if (!enabled || !path.exists()) {
return@let null
@@ -154,8 +154,7 @@ class RunnerCli : CliktCommand(name = "runner") {
VmInterferenceModelReader().read(path.inputStream())
}
- val targets = portfolio.targets
- val results = (0 until targets.repeatsPerScenario).map { repeat ->
+ val results = (0 until scenario.portfolio.targets.repeats).map { repeat ->
logger.info { "Starting repeat $repeat" }
withTimeout(runTimeout * 1000) {
runRepeat(scenario, repeat, topology, workloadLoader, interferenceModel?.withSeed(repeat.toLong()))
@@ -168,7 +167,7 @@ class RunnerCli : CliktCommand(name = "runner") {
}
/**
- * Converge a single repeat.
+ * Run a single repeat.
*/
private suspend fun runRepeat(
scenario: Scenario,
@@ -181,17 +180,17 @@ class RunnerCli : CliktCommand(name = "runner") {
try {
runBlockingSimulation {
- val workloadName = scenario.trace.traceId
- val workloadFraction = scenario.trace.loadSamplingFraction
+ val workloadName = scenario.workload.trace.id
+ val workloadFraction = scenario.workload.samplingFraction
val seeder = Random(repeat.toLong())
- val operational = scenario.operationalPhenomena
- val computeScheduler = createComputeScheduler(operational.schedulerName, seeder)
+ val phenomena = scenario.phenomena
+ val computeScheduler = createComputeScheduler(scenario.schedulerName, seeder)
val workload = trace(workloadName).sampleByLoad(workloadFraction)
val failureModel =
- if (operational.failuresEnabled)
+ if (phenomena.failures)
grid5000(Duration.ofDays(7))
else
null
@@ -203,7 +202,7 @@ class RunnerCli : CliktCommand(name = "runner") {
telemetry,
computeScheduler,
failureModel,
- interferenceModel.takeIf { operational.performanceInterferenceEnabled }
+ interferenceModel
)
telemetry.registerMetricReader(CoroutineMetricReader(this, exporter, exportInterval = Duration.ofHours(1)))
@@ -241,20 +240,19 @@ class RunnerCli : CliktCommand(name = "runner") {
override fun run(): Unit = runBlocking(Dispatchers.Default) {
logger.info { "Starting OpenDC web runner" }
- val client = ApiClient(baseUrl = apiUrl, AuthConfiguration(authDomain, authClientId, authClientSecret), authAudience)
+ val client = OpenDCRunnerClient(baseUrl = apiUrl, OpenIdAuthController(authDomain, authClientId, authClientSecret, authAudience))
val manager = ScenarioManager(client)
logger.info { "Watching for queued scenarios" }
while (true) {
- val scenario = manager.findNext()
-
- if (scenario == null) {
+ val job = manager.findNext()
+ if (job == null) {
delay(POLL_INTERVAL)
continue
}
- val id = scenario.id
+ val id = job.id
logger.info { "Found queued scenario $id: attempting to claim" }
@@ -273,10 +271,8 @@ class RunnerCli : CliktCommand(name = "runner") {
}
try {
- val scenarioModel = client.getScenario(id)!!
- val portfolio = client.getPortfolio(scenarioModel.portfolioId)!!
- val environment = convert(client.getTopology(scenarioModel.topology.topologyId)!!)
- val results = runScenario(portfolio, scenarioModel, environment)
+ val environment = convert(job.scenario.topology)
+ val results = runJob(job, environment)
logger.info { "Writing results to database" }
@@ -306,7 +302,8 @@ class RunnerCli : CliktCommand(name = "runner") {
val machines = topology.rooms.asSequence()
.flatMap { room ->
room.tiles.flatMap { tile ->
- tile.rack?.machines?.map { machine -> tile.rack to machine } ?: emptyList()
+ val rack = tile.rack
+ rack?.machines?.map { machine -> rack to machine } ?: emptyList()
}
}
for ((rack, machine) in machines) {
diff --git a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/ScenarioManager.kt b/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/ScenarioManager.kt
index 1ee835a6..7374f0c9 100644
--- a/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/ScenarioManager.kt
+++ b/opendc-web/opendc-web-runner/src/main/kotlin/org/opendc/web/runner/ScenarioManager.kt
@@ -22,64 +22,68 @@
package org.opendc.web.runner
-import org.opendc.web.client.ApiClient
-import org.opendc.web.client.model.Job
-import org.opendc.web.client.model.SimulationState
+import org.opendc.web.client.runner.OpenDCRunnerClient
+import org.opendc.web.proto.JobState
+import org.opendc.web.proto.runner.Job
/**
* Manages the queue of scenarios that need to be processed.
*/
-public class ScenarioManager(private val client: ApiClient) {
+class ScenarioManager(private val client: OpenDCRunnerClient) {
/**
* Find the next job that the simulator needs to process.
*/
- public suspend fun findNext(): Job? {
- return client.getJobs().firstOrNull()
+ fun findNext(): Job? {
+ return client.jobs.queryPending().firstOrNull()
}
/**
* Claim the simulation job with the specified id.
*/
- public suspend fun claim(id: String): Boolean {
- return client.updateJob(id, SimulationState.CLAIMED)
+ fun claim(id: Long): Boolean {
+ client.jobs.update(id, Job.Update(JobState.CLAIMED)) // TODO Handle conflict
+ return true
}
/**
* Update the heartbeat of the specified scenario.
*/
- public suspend fun heartbeat(id: String) {
- client.updateJob(id, SimulationState.RUNNING)
+ fun heartbeat(id: Long) {
+ client.jobs.update(id, Job.Update(JobState.RUNNING))
}
/**
* Mark the scenario as failed.
*/
- public suspend fun fail(id: String) {
- client.updateJob(id, SimulationState.FAILED)
+ fun fail(id: Long) {
+ client.jobs.update(id, Job.Update(JobState.FAILED))
}
/**
* Persist the specified results.
*/
- public suspend fun finish(id: String, results: List<WebComputeMetricExporter.Result>) {
- client.updateJob(
- id, SimulationState.FINISHED,
- mapOf(
- "total_requested_burst" to results.map { it.totalActiveTime + it.totalIdleTime },
- "total_granted_burst" to results.map { it.totalActiveTime },
- "total_overcommitted_burst" to results.map { it.totalStealTime },
- "total_interfered_burst" to results.map { it.totalLostTime },
- "mean_cpu_usage" to results.map { it.meanCpuUsage },
- "mean_cpu_demand" to results.map { it.meanCpuDemand },
- "mean_num_deployed_images" to results.map { it.meanNumDeployedImages },
- "max_num_deployed_images" to results.map { it.maxNumDeployedImages },
- "total_power_draw" to results.map { it.totalPowerDraw },
- "total_failure_slices" to results.map { it.totalFailureSlices },
- "total_failure_vm_slices" to results.map { it.totalFailureVmSlices },
- "total_vms_submitted" to results.map { it.totalVmsSubmitted },
- "total_vms_queued" to results.map { it.totalVmsQueued },
- "total_vms_finished" to results.map { it.totalVmsFinished },
- "total_vms_failed" to results.map { it.totalVmsFailed }
+ public fun finish(id: Long, results: List<WebComputeMetricExporter.Result>) {
+ client.jobs.update(
+ id,
+ Job.Update(
+ JobState.FINISHED,
+ mapOf(
+ "total_requested_burst" to results.map { it.totalActiveTime + it.totalIdleTime },
+ "total_granted_burst" to results.map { it.totalActiveTime },
+ "total_overcommitted_burst" to results.map { it.totalStealTime },
+ "total_interfered_burst" to results.map { it.totalLostTime },
+ "mean_cpu_usage" to results.map { it.meanCpuUsage },
+ "mean_cpu_demand" to results.map { it.meanCpuDemand },
+ "mean_num_deployed_images" to results.map { it.meanNumDeployedImages },
+ "max_num_deployed_images" to results.map { it.maxNumDeployedImages },
+ "total_power_draw" to results.map { it.totalPowerDraw },
+ "total_failure_slices" to results.map { it.totalFailureSlices },
+ "total_failure_vm_slices" to results.map { it.totalFailureVmSlices },
+ "total_vms_submitted" to results.map { it.totalVmsSubmitted },
+ "total_vms_queued" to results.map { it.totalVmsQueued },
+ "total_vms_finished" to results.map { it.totalVmsFinished },
+ "total_vms_failed" to results.map { it.totalVmsFailed }
+ )
)
)
}
diff --git a/opendc-web/opendc-web-runner/src/test/kotlin/org/opendc/web/client/ApiClientTest.kt b/opendc-web/opendc-web-runner/src/test/kotlin/org/opendc/web/client/ApiClientTest.kt
deleted file mode 100644
index 3a0730a6..00000000
--- a/opendc-web/opendc-web-runner/src/test/kotlin/org/opendc/web/client/ApiClientTest.kt
+++ /dev/null
@@ -1,264 +0,0 @@
-/*
- * Copyright (c) 2021 AtLarge Research
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all
- * copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- * SOFTWARE.
- */
-
-package org.opendc.web.client
-
-import io.ktor.client.*
-import io.ktor.client.engine.mock.*
-import io.ktor.http.*
-import kotlinx.coroutines.runBlocking
-import org.junit.jupiter.api.Assertions.assertNotNull
-import org.junit.jupiter.api.Assertions.assertNull
-import org.junit.jupiter.api.Test
-import java.net.URI
-
-/**
- * Test suite for the [ApiClient] class.
- */
-class ApiClientTest {
- /**
- * The Ktor [HttpClient] instance.
- */
- private val ktor = HttpClient(MockEngine) {
- engine {
- addHandler { request ->
- when (request.url.fullPath) {
- "/oauth/token" -> {
- val responseHeaders = headersOf("Content-Type" to listOf(ContentType.Application.Json.toString()))
- respond(
- """
- {
- "access_token": "eyJz93a...k4laUWw",
- "token_type": "Bearer",
- "expires_in": 86400
- }
- """.trimIndent(),
- headers = responseHeaders
- )
- }
- "/portfolios/5fda5daa97dca438e7cb0a4c" -> {
- val responseHeaders = headersOf("Content-Type" to listOf(ContentType.Application.Json.toString()))
- respond(
- """
- {
- "data": {
- "_id": "string",
- "projectId": "string",
- "name": "string",
- "scenarioIds": [
- "string"
- ],
- "targets": {
- "enabledMetrics": [
- "string"
- ],
- "repeatsPerScenario": 0
- }
- }
- }
- """.trimIndent(),
- headers = responseHeaders
- )
- }
- "/portfolios/x" -> {
- val responseHeaders = headersOf("Content-Type" to listOf(ContentType.Application.Json.toString()))
- respond(
- """
- {
- "message": "Not Found"
- }
- """.trimIndent(),
- headers = responseHeaders, status = HttpStatusCode.NotFound
- )
- }
- "/scenarios/5fda5db297dca438e7cb0a4d" -> {
- val responseHeaders = headersOf("Content-Type" to listOf(ContentType.Application.Json.toString()))
- respond(
- """
- {
- "data": {
- "_id": "string",
- "portfolioId": "string",
- "name": "string",
- "trace": {
- "traceId": "string",
- "loadSamplingFraction": 0
- },
- "topology": {
- "topologyId": "string"
- },
- "operational": {
- "failuresEnabled": true,
- "performanceInterferenceEnabled": true,
- "schedulerName": "string"
- }
- }
- }
- """.trimIndent(),
- headers = responseHeaders
- )
- }
- "/scenarios/x" -> {
- val responseHeaders = headersOf("Content-Type" to listOf(ContentType.Application.Json.toString()))
- respond(
- """
- {
- "message": "Not Found"
- }
- """.trimIndent(),
- headers = responseHeaders, status = HttpStatusCode.NotFound
- )
- }
- "/topologies/5f9825a6cf6e4c24e380b86f" -> {
- val responseHeaders = headersOf("Content-Type" to listOf(ContentType.Application.Json.toString()))
- respond(
- """
- {
- "data": {
- "_id": "string",
- "projectId": "string",
- "name": "string",
- "rooms": [
- {
- "_id": "string",
- "name": "string",
- "tiles": [
- {
- "_id": "string",
- "positionX": 0,
- "positionY": 0,
- "rack": {
- "_id": "string",
- "name": "string",
- "capacity": 0,
- "powerCapacityW": 0,
- "machines": [
- {
- "_id": "string",
- "position": 0,
- "cpus": [
- {
- "_id": "string",
- "name": "string",
- "clockRateMhz": 0,
- "numberOfCores": 0
- }
- ],
- "gpus": [
- {
- "_id": "string",
- "name": "string",
- "clockRateMhz": 0,
- "numberOfCores": 0
- }
- ],
- "memories": [
- {
- "_id": "string",
- "name": "string",
- "speedMbPerS": 0,
- "sizeMb": 0
- }
- ],
- "storages": [
- {
- "_id": "string",
- "name": "string",
- "speedMbPerS": 0,
- "sizeMb": 0
- }
- ]
- }
- ]
- }
- }
- ]
- }
- ]
- }
- }
- """.trimIndent(),
- headers = responseHeaders
- )
- }
- "/topologies/x" -> {
- val responseHeaders =
- headersOf("Content-Type" to listOf(ContentType.Application.Json.toString()))
- respond(
- """
- {
- "message": "Not Found"
- }
- """.trimIndent(),
- headers = responseHeaders, status = HttpStatusCode.NotFound
- )
- }
- else -> error("Unhandled ${request.url}")
- }
- }
- }
- }
-
- private val auth = AuthConfiguration("auth.opendc.org", "a", "b")
-
- @Test
- fun testPortfolioExists(): Unit = runBlocking {
- val client = ApiClient(URI("http://localhost:8081"), auth, client = ktor)
- val portfolio = client.getPortfolio("5fda5daa97dca438e7cb0a4c")
- assertNotNull(portfolio)
- }
-
- @Test
- fun testPortfolioDoesNotExists(): Unit = runBlocking {
- val client = ApiClient(URI("http://localhost:8081"), auth, client = ktor)
- val portfolio = client.getPortfolio("x")
- assertNull(portfolio)
- }
-
- @Test
- fun testScenarioExists(): Unit = runBlocking {
- val client = ApiClient(URI("http://localhost:8081"), auth, client = ktor)
- val scenario = client.getScenario("5fda5db297dca438e7cb0a4d")
- assertNotNull(scenario)
- }
-
- @Test
- fun testScenarioDoesNotExists(): Unit = runBlocking {
- val client = ApiClient(URI("http://localhost:8081"), auth, client = ktor)
- val scenario = client.getScenario("x")
- assertNull(scenario)
- }
-
- @Test
- fun testTopologyExists(): Unit = runBlocking {
- val client = ApiClient(URI("http://localhost:8081"), auth, client = ktor)
- val topology = client.getTopology("5f9825a6cf6e4c24e380b86f")
- assertNotNull(topology)
- }
-
- @Test
- fun testTopologyDoesNotExists(): Unit = runBlocking {
- val client = ApiClient(URI("http://localhost:8081"), auth, client = ktor)
- val topology = client.getTopology("x")
- assertNull(topology)
- }
-}
diff --git a/opendc-web/opendc-web-ui/src/api/index.js b/opendc-web/opendc-web-ui/src/api/index.js
index 680d49ce..1a9877d0 100644
--- a/opendc-web/opendc-web-ui/src/api/index.js
+++ b/opendc-web/opendc-web-ui/src/api/index.js
@@ -47,5 +47,5 @@ export async function request(auth, path, method = 'GET', body) {
throw response.message
}
- return json.data
+ return json
}
diff --git a/opendc-web/opendc-web-ui/src/api/portfolios.js b/opendc-web/opendc-web-ui/src/api/portfolios.js
index 82ac0ced..d818876f 100644
--- a/opendc-web/opendc-web-ui/src/api/portfolios.js
+++ b/opendc-web/opendc-web-ui/src/api/portfolios.js
@@ -22,22 +22,18 @@
import { request } from './index'
-export function fetchPortfolio(auth, portfolioId) {
- return request(auth, `portfolios/${portfolioId}`)
+export function fetchPortfolio(auth, projectId, number) {
+ return request(auth, `projects/${projectId}/portfolios/${number}`)
}
-export function fetchPortfoliosOfProject(auth, projectId) {
+export function fetchPortfolios(auth, projectId) {
return request(auth, `projects/${projectId}/portfolios`)
}
-export function addPortfolio(auth, portfolio) {
- return request(auth, `projects/${portfolio.projectId}/portfolios`, 'POST', { portfolio })
+export function addPortfolio(auth, projectId, portfolio) {
+ return request(auth, `projects/${projectId}/portfolios`, 'POST', portfolio)
}
-export function updatePortfolio(auth, portfolioId, portfolio) {
- return request(auth, `portfolios/${portfolioId}`, 'PUT', { portfolio })
-}
-
-export function deletePortfolio(auth, portfolioId) {
- return request(auth, `portfolios/${portfolioId}`, 'DELETE')
+export function deletePortfolio(auth, projectId, number) {
+ return request(auth, `projects/${projectId}/portfolios/${number}`, 'DELETE')
}
diff --git a/opendc-web/opendc-web-ui/src/api/projects.js b/opendc-web/opendc-web-ui/src/api/projects.js
index 4123b371..e7e095da 100644
--- a/opendc-web/opendc-web-ui/src/api/projects.js
+++ b/opendc-web/opendc-web-ui/src/api/projects.js
@@ -31,11 +31,7 @@ export function fetchProject(auth, projectId) {
}
export function addProject(auth, project) {
- return request(auth, 'projects/', 'POST', { project })
-}
-
-export function updateProject(auth, project) {
- return request(auth, `projects/${project._id}`, 'PUT', { project })
+ return request(auth, 'projects/', 'POST', project)
}
export function deleteProject(auth, projectId) {
diff --git a/opendc-web/opendc-web-ui/src/api/scenarios.js b/opendc-web/opendc-web-ui/src/api/scenarios.js
index 88516caa..7eeb8f28 100644
--- a/opendc-web/opendc-web-ui/src/api/scenarios.js
+++ b/opendc-web/opendc-web-ui/src/api/scenarios.js
@@ -22,22 +22,18 @@
import { request } from './index'
-export function fetchScenario(auth, scenarioId) {
- return request(auth, `scenarios/${scenarioId}`)
+export function fetchScenario(auth, projectId, scenarioId) {
+ return request(auth, `projects/${projectId}/scenarios/${scenarioId}`)
}
-export function fetchScenariosOfPortfolio(auth, portfolioId) {
- return request(auth, `portfolios/${portfolioId}/scenarios`)
+export function fetchScenariosOfPortfolio(auth, projectId, portfolioId) {
+ return request(auth, `projects/${projectId}/portfolios/${portfolioId}/scenarios`)
}
-export function addScenario(auth, scenario) {
- return request(auth, `portfolios/${scenario.portfolioId}/scenarios`, 'POST', { scenario })
+export function addScenario(auth, projectId, portfolioId, scenario) {
+ return request(auth, `projects/${projectId}/portfolios/${portfolioId}/scenarios`, 'POST', scenario)
}
-export function updateScenario(auth, scenarioId, scenario) {
- return request(auth, `scenarios/${scenarioId}`, 'PUT', { scenario })
-}
-
-export function deleteScenario(auth, scenarioId) {
- return request(auth, `scenarios/${scenarioId}`, 'DELETE')
+export function deleteScenario(auth, projectId, scenarioId) {
+ return request(auth, `projects/${projectId}/scenarios/${scenarioId}`, 'DELETE')
}
diff --git a/opendc-web/opendc-web-ui/src/api/topologies.js b/opendc-web/opendc-web-ui/src/api/topologies.js
index bd4e3bc4..0509c6d0 100644
--- a/opendc-web/opendc-web-ui/src/api/topologies.js
+++ b/opendc-web/opendc-web-ui/src/api/topologies.js
@@ -22,24 +22,23 @@
import { request } from './index'
-export function fetchTopology(auth, topologyId) {
- return request(auth, `topologies/${topologyId}`)
+export function fetchTopology(auth, projectId, number) {
+ return request(auth, `projects/${projectId}/topologies/${number}`)
}
-export function fetchTopologiesOfProject(auth, projectId) {
+export function fetchTopologies(auth, projectId) {
return request(auth, `projects/${projectId}/topologies`)
}
-export function addTopology(auth, topology) {
- return request(auth, `projects/${topology.projectId}/topologies`, 'POST', { topology })
+export function addTopology(auth, projectId, topology) {
+ return request(auth, `projects/${projectId}/topologies`, 'POST', topology)
}
export function updateTopology(auth, topology) {
- // eslint-disable-next-line no-unused-vars
- const { _id, ...data } = topology
- return request(auth, `topologies/${topology._id}`, 'PUT', { topology: data })
+ const { project, number, rooms } = topology
+ return request(auth, `projects/${project.id}/topologies/${number}`, 'PUT', { rooms })
}
-export function deleteTopology(auth, topologyId) {
- return request(auth, `topologies/${topologyId}`, 'DELETE')
+export function deleteTopology(auth, projectId, number) {
+ return request(auth, `projects/${projectId}/topologies/${number}`, 'DELETE')
}
diff --git a/opendc-web/opendc-web-ui/src/components/AppNavigation.js b/opendc-web/opendc-web-ui/src/components/AppNavigation.js
index 178c3ec0..77c683a2 100644
--- a/opendc-web/opendc-web-ui/src/components/AppNavigation.js
+++ b/opendc-web/opendc-web-ui/src/components/AppNavigation.js
@@ -23,15 +23,9 @@
import { Nav, NavItem, NavList } from '@patternfly/react-core'
import { useRouter } from 'next/router'
import NavItemLink from './util/NavItemLink'
-import { useProject } from '../data/project'
export function AppNavigation() {
- const { pathname, query } = useRouter()
- const { project: projectId } = query
- const { data: project } = useProject(projectId)
-
- const nextTopologyId = project?.topologyIds?.[0]
- const nextPortfolioId = project?.portfolioIds?.[0]
+ const { pathname } = useRouter()
return (
<Nav variant="horizontal">
@@ -45,28 +39,6 @@ export function AppNavigation() {
>
Projects
</NavItem>
- {pathname.startsWith('/projects/[project]') && (
- <>
- <NavItem
- id="topologies"
- to={nextTopologyId ? `/projects/${projectId}/topologies/${nextTopologyId}` : '/projects'}
- itemId={1}
- component={NavItemLink}
- isActive={pathname === '/projects/[project]/topologies/[topology]'}
- >
- Topologies
- </NavItem>
- <NavItem
- id="portfolios"
- to={nextPortfolioId ? `/projects/${projectId}/portfolios/${nextPortfolioId}` : '/projects'}
- itemId={2}
- component={NavItemLink}
- isActive={pathname === '/projects/[project]/portfolios/[portfolio]'}
- >
- Portfolios
- </NavItem>
- </>
- )}
</NavList>
</Nav>
)
diff --git a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js
index 3712cfa0..a99b60c0 100644
--- a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js
+++ b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js
@@ -22,13 +22,11 @@
import PropTypes from 'prop-types'
import { ContextSelector as PFContextSelector, ContextSelectorItem } from '@patternfly/react-core'
-import { useMemo, useState, useReducer } from 'react'
+import { useMemo, useState } from 'react'
import { contextSelector } from './ContextSelector.module.scss'
-function ContextSelector({ activeItem, items, onSelect, label }) {
- const [isOpen, toggle] = useReducer((isOpen) => !isOpen, false)
+function ContextSelector({ activeItem, items, onSelect, onToggle, isOpen, label }) {
const [searchValue, setSearchValue] = useState('')
-
const filteredItems = useMemo(
() => items.filter(({ name }) => name.toLowerCase().indexOf(searchValue.toLowerCase()) !== -1) || items,
[items, searchValue]
@@ -36,23 +34,22 @@ function ContextSelector({ activeItem, items, onSelect, label }) {
return (
<PFContextSelector
- menuAppendTo={global.document?.body}
className={contextSelector}
toggleText={activeItem ? `${label}: ${activeItem.name}` : label}
onSearchInputChange={(value) => setSearchValue(value)}
searchInputValue={searchValue}
isOpen={isOpen}
- onToggle={toggle}
+ onToggle={(_, isOpen) => onToggle(isOpen)}
onSelect={(event) => {
- const targetId = event.target.value
- const target = items.find((item) => item._id === targetId)
+ const targetId = +event.target.value
+ const target = items.find((item) => item.id === targetId)
- toggle()
onSelect(target)
+ onToggle(!isOpen)
}}
>
{filteredItems.map((item) => (
- <ContextSelectorItem key={item._id} value={item._id}>
+ <ContextSelectorItem key={item.id} value={item.id}>
{item.name}
</ContextSelectorItem>
))}
@@ -61,7 +58,7 @@ function ContextSelector({ activeItem, items, onSelect, label }) {
}
const Item = PropTypes.shape({
- _id: PropTypes.string.isRequired,
+ id: PropTypes.any.isRequired,
name: PropTypes.string.isRequired,
})
@@ -69,6 +66,8 @@ ContextSelector.propTypes = {
activeItem: Item,
items: PropTypes.arrayOf(Item).isRequired,
onSelect: PropTypes.func.isRequired,
+ onToggle: PropTypes.func.isRequired,
+ isOpen: PropTypes.bool,
label: PropTypes.string,
}
diff --git a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss
index fefba41f..0aa63ee6 100644
--- a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss
+++ b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss
@@ -21,7 +21,6 @@
*/
.contextSelector {
- width: auto;
margin-right: 20px;
--pf-c-context-selector__toggle--PaddingTop: var(--pf-global--spacer--sm);
diff --git a/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js b/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js
index 694681ac..c4f2d50e 100644
--- a/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js
+++ b/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js
@@ -21,27 +21,31 @@
*/
import { useRouter } from 'next/router'
-import { useMemo } from 'react'
-import { useProjectPortfolios } from '../../data/project'
+import { useState } from 'react'
+import { usePortfolios } from '../../data/project'
+import { Portfolio } from '../../shapes'
import ContextSelector from './ContextSelector'
-function PortfolioSelector() {
+function PortfolioSelector({ activePortfolio }) {
const router = useRouter()
- const { project, portfolio: activePortfolioId } = router.query
- const { data: portfolios = [] } = useProjectPortfolios(project)
- const activePortfolio = useMemo(() => portfolios.find((portfolio) => portfolio._id === activePortfolioId), [
- activePortfolioId,
- portfolios,
- ])
+
+ const [isOpen, setOpen] = useState(false)
+ const { data: portfolios = [] } = usePortfolios(activePortfolio?.project?.id, { enabled: isOpen })
return (
<ContextSelector
label="Portfolio"
activeItem={activePortfolio}
items={portfolios}
- onSelect={(portfolio) => router.push(`/projects/${portfolio.projectId}/portfolios/${portfolio._id}`)}
+ onSelect={(portfolio) => router.push(`/projects/${portfolio.project.id}/portfolios/${portfolio.number}`)}
+ onToggle={setOpen}
+ isOpen={isOpen}
/>
)
}
+PortfolioSelector.propTypes = {
+ activePortfolio: Portfolio,
+}
+
export default PortfolioSelector
diff --git a/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js b/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js
index 753632ab..7721e04c 100644
--- a/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js
+++ b/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js
@@ -20,29 +20,32 @@
* SOFTWARE.
*/
-import PropTypes from 'prop-types'
import { useRouter } from 'next/router'
-import { useMemo } from 'react'
+import { useState } from 'react'
import { useProjects } from '../../data/project'
+import { Project } from '../../shapes'
import ContextSelector from './ContextSelector'
-function ProjectSelector({ projectId }) {
+function ProjectSelector({ activeProject }) {
const router = useRouter()
- const { data: projects = [] } = useProjects()
- const activeProject = useMemo(() => projects.find((project) => project._id === projectId), [projectId, projects])
+
+ const [isOpen, setOpen] = useState(false)
+ const { data: projects = [] } = useProjects({ enabled: isOpen })
return (
<ContextSelector
label="Project"
activeItem={activeProject}
items={projects}
- onSelect={(project) => router.push(`/projects/${project._id}`)}
+ onSelect={(project) => router.push(`/projects/${project.id}`)}
+ onToggle={setOpen}
+ isOpen={isOpen}
/>
)
}
ProjectSelector.propTypes = {
- projectId: PropTypes.string,
+ activeProject: Project,
}
export default ProjectSelector
diff --git a/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js b/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js
index d5e51c6c..9cae4cbf 100644
--- a/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js
+++ b/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js
@@ -20,33 +20,32 @@
* SOFTWARE.
*/
-import PropTypes from 'prop-types'
import { useRouter } from 'next/router'
-import { useMemo } from 'react'
-import { useProjectTopologies } from '../../data/topology'
+import { useState } from 'react'
+import { useTopologies } from '../../data/topology'
+import { Topology } from '../../shapes'
import ContextSelector from './ContextSelector'
-function TopologySelector({ projectId, topologyId }) {
+function TopologySelector({ activeTopology }) {
const router = useRouter()
- const { data: topologies = [] } = useProjectTopologies(projectId)
- const activeTopology = useMemo(() => topologies.find((topology) => topology._id === topologyId), [
- topologyId,
- topologies,
- ])
+
+ const [isOpen, setOpen] = useState(false)
+ const { data: topologies = [] } = useTopologies(activeTopology?.project?.id, { enabled: isOpen })
return (
<ContextSelector
label="Topology"
activeItem={activeTopology}
items={topologies}
- onSelect={(topology) => router.push(`/projects/${topology.projectId}/topologies/${topology._id}`)}
+ onSelect={(topology) => router.push(`/projects/${topology.project.id}/topologies/${topology.number}`)}
+ onToggle={setOpen}
+ isOpen={isOpen}
/>
)
}
TopologySelector.propTypes = {
- projectId: PropTypes.string,
- topologyId: PropTypes.string,
+ activeTopology: Topology,
}
export default TopologySelector
diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/NewScenario.js b/opendc-web/opendc-web-ui/src/components/portfolios/NewScenario.js
index 856282a7..fd9a72d2 100644
--- a/opendc-web/opendc-web-ui/src/components/portfolios/NewScenario.js
+++ b/opendc-web/opendc-web-ui/src/components/portfolios/NewScenario.js
@@ -24,21 +24,15 @@ import PropTypes from 'prop-types'
import { PlusIcon } from '@patternfly/react-icons'
import { Button } from '@patternfly/react-core'
import { useState } from 'react'
-import { useMutation } from 'react-query'
+import { useNewScenario } from '../../data/project'
import NewScenarioModal from './NewScenarioModal'
-function NewScenario({ portfolioId }) {
+function NewScenario({ projectId, portfolioId }) {
const [isVisible, setVisible] = useState(false)
- const { mutate: addScenario } = useMutation('addScenario')
+ const { mutate: addScenario } = useNewScenario()
- const onSubmit = (name, portfolioId, trace, topology, operational) => {
- addScenario({
- portfolioId,
- name,
- trace,
- topology,
- operational,
- })
+ const onSubmit = (projectId, portfolioNumber, data) => {
+ addScenario({ projectId, portfolioNumber, data })
setVisible(false)
}
@@ -48,6 +42,7 @@ function NewScenario({ portfolioId }) {
New Scenario
</Button>
<NewScenarioModal
+ projectId={projectId}
portfolioId={portfolioId}
isOpen={isVisible}
onSubmit={onSubmit}
@@ -58,7 +53,8 @@ function NewScenario({ portfolioId }) {
}
NewScenario.propTypes = {
- portfolioId: PropTypes.string,
+ projectId: PropTypes.number,
+ portfolioId: PropTypes.number,
}
export default NewScenario
diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/NewScenarioModal.js b/opendc-web/opendc-web-ui/src/components/portfolios/NewScenarioModal.js
index 7f620c8c..ed35c163 100644
--- a/opendc-web/opendc-web-ui/src/components/portfolios/NewScenarioModal.js
+++ b/opendc-web/opendc-web-ui/src/components/portfolios/NewScenarioModal.js
@@ -12,14 +12,14 @@ import {
TextInput,
} from '@patternfly/react-core'
import { useSchedulers, useTraces } from '../../data/experiments'
-import { useProjectTopologies } from '../../data/topology'
+import { useTopologies } from '../../data/topology'
import { usePortfolio } from '../../data/project'
-const NewScenarioModal = ({ portfolioId, isOpen, onSubmit: onSubmitUpstream, onCancel: onCancelUpstream }) => {
- const { data: portfolio } = usePortfolio(portfolioId)
- const { data: topologies = [] } = useProjectTopologies(portfolio?.projectId)
- const { data: traces = [] } = useTraces()
- const { data: schedulers = [] } = useSchedulers()
+function NewScenarioModal({ projectId, portfolioId, isOpen, onSubmit: onSubmitUpstream, onCancel: onCancelUpstream }) {
+ const { data: portfolio } = usePortfolio(projectId, portfolioId)
+ const { data: topologies = [] } = useTopologies(projectId, { enabled: isOpen })
+ const { data: traces = [] } = useTraces({ enabled: isOpen })
+ const { data: schedulers = [] } = useSchedulers({ enabled: isOpen })
// eslint-disable-next-line no-unused-vars
const [isSubmitted, setSubmitted] = useState(false)
@@ -51,22 +51,19 @@ const NewScenarioModal = ({ portfolioId, isOpen, onSubmit: onSubmitUpstream, onC
const name = nameInput.current.value
- onSubmitUpstream(
+ onSubmitUpstream(portfolio.project.id, portfolio.number, {
name,
- portfolio._id,
- {
- traceId: trace || traces[0]._id,
- loadSamplingFraction: traceLoad / 100,
+ workload: {
+ trace: trace || traces[0].id,
+ samplingFraction: traceLoad / 100,
},
- {
- topologyId: topology || topologies[0]._id,
+ topology: topology || topologies[0].number,
+ phenomena: {
+ failures: failuresEnabled,
+ interference: opPhenEnabled,
},
- {
- failuresEnabled,
- performanceInterferenceEnabled: opPhenEnabled,
- schedulerName: scheduler || schedulers[0].name,
- }
- )
+ schedulerName: scheduler || schedulers[0],
+ })
resetState()
return true
@@ -84,8 +81,8 @@ const NewScenarioModal = ({ portfolioId, isOpen, onSubmit: onSubmitUpstream, onC
id="name"
name="name"
type="text"
- isDisabled={portfolio?.scenarioIds?.length === 0}
- defaultValue={portfolio?.scenarioIds?.length === 0 ? 'Base scenario' : ''}
+ isDisabled={portfolio?.scenarios?.length === 0}
+ defaultValue={portfolio?.scenarios?.length === 0 ? 'Base scenario' : ''}
ref={nameInput}
/>
</FormGroup>
@@ -93,7 +90,7 @@ const NewScenarioModal = ({ portfolioId, isOpen, onSubmit: onSubmitUpstream, onC
<FormGroup label="Trace" fieldId="trace" isRequired>
<FormSelect id="trace" name="trace" value={trace} onChange={setTrace}>
{traces.map((trace) => (
- <FormSelectOption value={trace._id} key={trace._id} label={trace.name} />
+ <FormSelectOption value={trace.id} key={trace.id} label={trace.name} />
))}
</FormSelect>
</FormGroup>
@@ -115,7 +112,7 @@ const NewScenarioModal = ({ portfolioId, isOpen, onSubmit: onSubmitUpstream, onC
<FormGroup label="Topology" fieldId="topology" isRequired>
<FormSelect id="topology" name="topology" value={topology} onChange={setTopology}>
{topologies.map((topology) => (
- <FormSelectOption value={topology._id} key={topology._id} label={topology.name} />
+ <FormSelectOption value={topology.number} key={topology.number} label={topology.name} />
))}
</FormSelect>
</FormGroup>
@@ -123,7 +120,7 @@ const NewScenarioModal = ({ portfolioId, isOpen, onSubmit: onSubmitUpstream, onC
<FormGroup label="Scheduler" fieldId="scheduler" isRequired>
<FormSelect id="scheduler" name="scheduler" value={scheduler} onChange={setScheduler}>
{schedulers.map((scheduler) => (
- <FormSelectOption value={scheduler.name} key={scheduler.name} label={scheduler.name} />
+ <FormSelectOption value={scheduler} key={scheduler} label={scheduler} />
))}
</FormSelect>
</FormGroup>
@@ -150,7 +147,8 @@ const NewScenarioModal = ({ portfolioId, isOpen, onSubmit: onSubmitUpstream, onC
}
NewScenarioModal.propTypes = {
- portfolioId: PropTypes.string,
+ projectId: PropTypes.number,
+ portfolioId: PropTypes.number,
isOpen: PropTypes.bool.isRequired,
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioOverview.js b/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioOverview.js
index 580b0a29..e561b655 100644
--- a/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioOverview.js
+++ b/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioOverview.js
@@ -43,8 +43,8 @@ import { METRIC_NAMES } from '../../util/available-metrics'
import NewScenario from './NewScenario'
import ScenarioTable from './ScenarioTable'
-function PortfolioOverview({ portfolioId }) {
- const { data: portfolio } = usePortfolio(portfolioId)
+function PortfolioOverview({ projectId, portfolioId }) {
+ const { status, data: portfolio } = usePortfolio(projectId, portfolioId)
return (
<Grid hasGutter>
@@ -62,16 +62,16 @@ function PortfolioOverview({ portfolioId }) {
<DescriptionListGroup>
<DescriptionListTerm>Scenarios</DescriptionListTerm>
<DescriptionListDescription>
- {portfolio?.scenarioIds.length ?? <Skeleton screenreaderText="Loading portfolio" />}
+ {portfolio?.scenarios?.length ?? <Skeleton screenreaderText="Loading portfolio" />}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>Metrics</DescriptionListTerm>
<DescriptionListDescription>
- {portfolio?.targets?.enabledMetrics ? (
- portfolio.targets.enabledMetrics.length > 0 ? (
+ {portfolio ? (
+ portfolio.targets.metrics.length > 0 ? (
<ChipGroup>
- {portfolio.targets.enabledMetrics.map((metric) => (
+ {portfolio.targets.metrics.map((metric) => (
<Chip isReadOnly key={metric}>
{METRIC_NAMES[metric]}
</Chip>
@@ -88,9 +88,7 @@ function PortfolioOverview({ portfolioId }) {
<DescriptionListGroup>
<DescriptionListTerm>Repeats per Scenario</DescriptionListTerm>
<DescriptionListDescription>
- {portfolio?.targets?.repeatsPerScenario ?? (
- <Skeleton screenreaderText="Loading portfolio" />
- )}
+ {portfolio?.targets?.repeats ?? <Skeleton screenreaderText="Loading portfolio" />}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
@@ -101,12 +99,12 @@ function PortfolioOverview({ portfolioId }) {
<Card>
<CardHeader>
<CardActions>
- <NewScenario portfolioId={portfolioId} />
+ <NewScenario projectId={projectId} portfolioId={portfolioId} />
</CardActions>
<CardTitle>Scenarios</CardTitle>
</CardHeader>
<CardBody>
- <ScenarioTable portfolioId={portfolioId} />
+ <ScenarioTable portfolio={portfolio} status={status} />
</CardBody>
</Card>
</GridItem>
@@ -115,7 +113,8 @@ function PortfolioOverview({ portfolioId }) {
}
PortfolioOverview.propTypes = {
- portfolioId: PropTypes.string,
+ projectId: PropTypes.number,
+ portfolioId: PropTypes.number,
}
export default PortfolioOverview
diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js b/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js
index 00023d9e..f63f0c7f 100644
--- a/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js
+++ b/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js
@@ -42,12 +42,14 @@ import {
Title,
} from '@patternfly/react-core'
import { ErrorCircleOIcon, CubesIcon } from '@patternfly/react-icons'
-import { usePortfolioScenarios } from '../../data/project'
+import { usePortfolio } from '../../data/project'
import PortfolioResultInfo from './PortfolioResultInfo'
import NewScenario from './NewScenario'
-const PortfolioResults = ({ portfolioId }) => {
- const { status, data: scenarios = [] } = usePortfolioScenarios(portfolioId)
+const PortfolioResults = ({ projectId, portfolioId }) => {
+ const { status, data: scenarios = [] } = usePortfolio(projectId, portfolioId, {
+ select: (portfolio) => portfolio.scenarios,
+ })
if (status === 'loading') {
return (
@@ -86,7 +88,7 @@ const PortfolioResults = ({ portfolioId }) => {
No results are currently available for this portfolio. Run a scenario to obtain simulation
results.
</EmptyStateBody>
- <NewScenario portfolioId={portfolioId} />
+ <NewScenario projectId={projectId} portfolioId={portfolioId} />
</EmptyState>
</Bullseye>
)
@@ -96,11 +98,11 @@ const PortfolioResults = ({ portfolioId }) => {
AVAILABLE_METRICS.forEach((metric) => {
dataPerMetric[metric] = scenarios
- .filter((scenario) => scenario.results)
+ .filter((scenario) => scenario.job?.results)
.map((scenario) => ({
name: scenario.name,
- value: mean(scenario.results[metric]),
- errorX: std(scenario.results[metric]),
+ value: mean(scenario.job.results[metric]),
+ errorX: std(scenario.job.results[metric]),
}))
})
@@ -150,7 +152,8 @@ const PortfolioResults = ({ portfolioId }) => {
}
PortfolioResults.propTypes = {
- portfolioId: PropTypes.string,
+ projectId: PropTypes.number,
+ portfolioId: PropTypes.number,
}
export default PortfolioResults
diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioState.js b/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioState.js
index 66691580..99d83f64 100644
--- a/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioState.js
+++ b/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioState.js
@@ -20,13 +20,13 @@
* SOFTWARE.
*/
-import PropTypes from 'prop-types'
import { ClockIcon, CheckCircleIcon, ErrorCircleOIcon } from '@patternfly/react-icons'
+import { JobState } from '../../shapes'
function ScenarioState({ state }) {
switch (state) {
+ case 'PENDING':
case 'CLAIMED':
- case 'QUEUED':
return (
<span>
<ClockIcon color="blue" /> Queued
@@ -56,7 +56,7 @@ function ScenarioState({ state }) {
}
ScenarioState.propTypes = {
- state: PropTypes.oneOf(['QUEUED', 'CLAIMED', 'RUNNING', 'FINISHED', 'FAILED']),
+ state: JobState.isRequired,
}
export default ScenarioState
diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js b/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js
index 9966e3ba..68647957 100644
--- a/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js
+++ b/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js
@@ -20,44 +20,38 @@
* SOFTWARE.
*/
-import PropTypes from 'prop-types'
import Link from 'next/link'
import { Table, TableBody, TableHeader } from '@patternfly/react-table'
import React from 'react'
+import { Portfolio, Status } from '../../shapes'
import TableEmptyState from '../util/TableEmptyState'
import ScenarioState from './ScenarioState'
-import { usePortfolio, usePortfolioScenarios } from '../../data/project'
-import { useProjectTopologies } from '../../data/topology'
-import { useMutation } from 'react-query'
+import { useDeleteScenario } from '../../data/project'
-const ScenarioTable = ({ portfolioId }) => {
- const { data: portfolio } = usePortfolio(portfolioId)
- const { status, data: scenarios = [] } = usePortfolioScenarios(portfolioId)
- const { data: topologies } = useProjectTopologies(portfolio?.projectId, {
- select: (topologies) => new Map(topologies.map((topology) => [topology._id, topology])),
- })
-
- const { mutate: deleteScenario } = useMutation('deleteScenario')
+function ScenarioTable({ portfolio, status }) {
+ const { mutate: deleteScenario } = useDeleteScenario()
+ const projectId = portfolio?.project?.id
+ const scenarios = portfolio?.scenarios ?? []
const columns = ['Name', 'Topology', 'Trace', 'State']
const rows =
scenarios.length > 0
? scenarios.map((scenario) => {
- const topology = topologies?.get(scenario.topology.topologyId)
+ const topology = scenario.topology
return [
scenario.name,
{
title: topology ? (
- <Link href={`/projects/${topology.projectId}/topologies/${topology._id}`}>
+ <Link href={`/projects/${projectId}/topologies/${topology.number}`}>
<a>{topology.name}</a>
</Link>
) : (
'Unknown Topology'
),
},
- scenario.trace.traceId,
- { title: <ScenarioState state={scenario.simulation.state} /> },
+ `${scenario.workload.trace.name} (${scenario.workload.samplingFraction * 100}%)`,
+ { title: <ScenarioState state={scenario.job.state} /> },
]
})
: [
@@ -82,7 +76,7 @@ const ScenarioTable = ({ portfolioId }) => {
const actionResolver = (_, { rowIndex }) => [
{
title: 'Delete Scenario',
- onClick: (_, rowId) => deleteScenario(scenarios[rowId]._id),
+ onClick: (_, rowId) => deleteScenario({ projectId: projectId, number: scenarios[rowId].number }),
isDisabled: rowIndex === 0,
},
]
@@ -102,7 +96,8 @@ const ScenarioTable = ({ portfolioId }) => {
}
ScenarioTable.propTypes = {
- portfolioId: PropTypes.string,
+ portfolio: Portfolio,
+ status: Status.isRequired,
}
export default ScenarioTable
diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewPortfolio.js b/opendc-web/opendc-web-ui/src/components/projects/NewPortfolio.js
index 87ea059d..aebcc3c9 100644
--- a/opendc-web/opendc-web-ui/src/components/projects/NewPortfolio.js
+++ b/opendc-web/opendc-web-ui/src/components/projects/NewPortfolio.js
@@ -24,12 +24,12 @@ import PropTypes from 'prop-types'
import { PlusIcon } from '@patternfly/react-icons'
import { Button } from '@patternfly/react-core'
import { useState } from 'react'
-import { useMutation } from 'react-query'
+import { useNewPortfolio } from '../../data/project'
import NewPortfolioModal from './NewPortfolioModal'
function NewPortfolio({ projectId }) {
const [isVisible, setVisible] = useState(false)
- const { mutate: addPortfolio } = useMutation('addPortfolio')
+ const { mutate: addPortfolio } = useNewPortfolio()
const onSubmit = (name, targets) => {
addPortfolio({ projectId, name, targets })
@@ -47,7 +47,7 @@ function NewPortfolio({ projectId }) {
}
NewPortfolio.propTypes = {
- projectId: PropTypes.string,
+ projectId: PropTypes.number,
}
export default NewPortfolio
diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewPortfolioModal.js b/opendc-web/opendc-web-ui/src/components/projects/NewPortfolioModal.js
index 4276d7d4..ba4bc819 100644
--- a/opendc-web/opendc-web-ui/src/components/projects/NewPortfolioModal.js
+++ b/opendc-web/opendc-web-ui/src/components/projects/NewPortfolioModal.js
@@ -67,7 +67,7 @@ const NewPortfolioModal = ({ isOpen, onSubmit: onSubmitUpstream, onCancel: onUps
setErrors({ name: true })
return false
} else {
- onSubmitUpstream(name, { enabledMetrics: selectedMetrics, repeatsPerScenario: repeats })
+ onSubmitUpstream(name, { metrics: selectedMetrics, repeats })
}
clearState()
diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewProject.js b/opendc-web/opendc-web-ui/src/components/projects/NewProject.js
index 984264dc..bfa7c01a 100644
--- a/opendc-web/opendc-web-ui/src/components/projects/NewProject.js
+++ b/opendc-web/opendc-web-ui/src/components/projects/NewProject.js
@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import { Button } from '@patternfly/react-core'
-import { useMutation } from 'react-query'
import { PlusIcon } from '@patternfly/react-icons'
+import { useNewProject } from '../../data/project'
import { buttonContainer } from './NewProject.module.scss'
import TextInputModal from '../util/modals/TextInputModal'
@@ -10,7 +10,7 @@ import TextInputModal from '../util/modals/TextInputModal'
*/
const NewProject = () => {
const [isVisible, setVisible] = useState(false)
- const { mutate: addProject } = useMutation('addProject')
+ const { mutate: addProject } = useNewProject()
const onSubmit = (name) => {
if (name) {
diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewTopology.js b/opendc-web/opendc-web-ui/src/components/projects/NewTopology.js
index 77c57d26..4c569c56 100644
--- a/opendc-web/opendc-web-ui/src/components/projects/NewTopology.js
+++ b/opendc-web/opendc-web-ui/src/components/projects/NewTopology.js
@@ -21,25 +21,17 @@
*/
import PropTypes from 'prop-types'
-import produce from 'immer'
import { PlusIcon } from '@patternfly/react-icons'
import { Button } from '@patternfly/react-core'
import { useState } from 'react'
-import { useMutation } from "react-query";
-import { useProjectTopologies } from "../../data/topology";
+import { useNewTopology } from '../../data/topology'
import NewTopologyModal from './NewTopologyModal'
function NewTopology({ projectId }) {
const [isVisible, setVisible] = useState(false)
- const { data: topologies = [] } = useProjectTopologies(projectId)
- const { mutate: addTopology } = useMutation('addTopology')
+ const { mutate: addTopology } = useNewTopology()
- const onSubmit = (name, duplicateId) => {
- const candidate = topologies.find((topology) => topology._id === duplicateId) || { projectId, rooms: [] }
- const topology = produce(candidate, (draft) => {
- delete draft._id
- draft.name = name
- })
+ const onSubmit = (topology) => {
addTopology(topology)
setVisible(false)
}
@@ -59,7 +51,7 @@ function NewTopology({ projectId }) {
}
NewTopology.propTypes = {
- projectId: PropTypes.string,
+ projectId: PropTypes.number,
}
export default NewTopology
diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js b/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js
index a495f73e..be4256e3 100644
--- a/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js
+++ b/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js
@@ -20,10 +20,11 @@
* SOFTWARE.
*/
+import produce from 'immer'
import PropTypes from 'prop-types'
import React, { useRef, useState } from 'react'
import { Form, FormGroup, FormSelect, FormSelectOption, TextInput } from '@patternfly/react-core'
-import { useProjectTopologies } from '../../data/topology'
+import { useTopologies } from '../../data/topology'
import Modal from '../util/modals/Modal'
const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCancel: onCancelUpstream }) => {
@@ -32,10 +33,12 @@ const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCan
const [originTopology, setOriginTopology] = useState(-1)
const [errors, setErrors] = useState({})
- const { data: topologies = [] } = useProjectTopologies(projectId)
+ const { data: topologies = [] } = useTopologies(projectId, { enabled: isOpen })
const clearState = () => {
- nameInput.current.value = ''
+ if (nameInput.current) {
+ nameInput.current.value = ''
+ }
setSubmitted(false)
setOriginTopology(-1)
setErrors({})
@@ -53,10 +56,13 @@ const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCan
if (!name) {
setErrors({ name: true })
return false
- } else if (originTopology === -1) {
- onSubmitUpstream(name)
} else {
- onSubmitUpstream(name, originTopology)
+ const candidate = topologies.find((topology) => topology.id === originTopology) || { projectId, rooms: [] }
+ const topology = produce(candidate, (draft) => {
+ delete draft.id
+ draft.name = name
+ })
+ onSubmitUpstream(topology)
}
clearState()
@@ -84,7 +90,7 @@ const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCan
<FormSelect id="origin" name="origin" value={originTopology} onChange={setOriginTopology}>
<FormSelectOption value={-1} key={-1} label="None - start from scratch" />
{topologies.map((topology) => (
- <FormSelectOption value={topology._id} key={topology._id} label={topology.name} />
+ <FormSelectOption value={topology.id} key={topology.id} label={topology.name} />
))}
</FormSelect>
</FormGroup>
@@ -94,7 +100,7 @@ const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCan
}
NewTopologyModal.propTypes = {
- projectId: PropTypes.string,
+ projectId: PropTypes.number,
isOpen: PropTypes.bool.isRequired,
onSubmit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
diff --git a/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js b/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js
index 45e399ed..aa679843 100644
--- a/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js
+++ b/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js
@@ -25,12 +25,11 @@ import Link from 'next/link'
import { Table, TableBody, TableHeader } from '@patternfly/react-table'
import React from 'react'
import TableEmptyState from '../util/TableEmptyState'
-import { useProjectPortfolios } from '../../data/project'
-import { useMutation } from 'react-query'
+import { usePortfolios, useDeletePortfolio } from '../../data/project'
const PortfolioTable = ({ projectId }) => {
- const { status, data: portfolios = [] } = useProjectPortfolios(projectId)
- const { mutate: deletePortfolio } = useMutation('deletePortfolio')
+ const { status, data: portfolios = [] } = usePortfolios(projectId)
+ const { mutate: deletePortfolio } = useDeletePortfolio()
const columns = ['Name', 'Scenarios', 'Metrics', 'Repeats']
const rows =
@@ -38,20 +37,12 @@ const PortfolioTable = ({ projectId }) => {
? portfolios.map((portfolio) => [
{
title: (
- <Link href={`/projects/${portfolio.projectId}/portfolios/${portfolio._id}`}>
- {portfolio.name}
- </Link>
+ <Link href={`/projects/${projectId}/portfolios/${portfolio.number}`}>{portfolio.name}</Link>
),
},
-
- portfolio.scenarioIds.length === 1 ? '1 scenario' : `${portfolio.scenarioIds.length} scenarios`,
-
- portfolio.targets.enabledMetrics.length === 1
- ? '1 metric'
- : `${portfolio.targets.enabledMetrics.length} metrics`,
- portfolio.targets.repeatsPerScenario === 1
- ? '1 repeat'
- : `${portfolio.targets.repeatsPerScenario} repeats`,
+ portfolio.scenarios.length === 1 ? '1 scenario' : `${portfolio.scenarios.length} scenarios`,
+ portfolio.targets.metrics.length === 1 ? '1 metric' : `${portfolio.targets.metrics.length} metrics`,
+ portfolio.targets.repeats === 1 ? '1 repeat' : `${portfolio.targets.repeats} repeats`,
])
: [
{
@@ -77,7 +68,7 @@ const PortfolioTable = ({ projectId }) => {
? [
{
title: 'Delete Portfolio',
- onClick: (_, rowId) => deletePortfolio(portfolios[rowId]._id),
+ onClick: (_, rowId) => deletePortfolio({ projectId, number: portfolios[rowId].number }),
},
]
: []
@@ -91,7 +82,7 @@ const PortfolioTable = ({ projectId }) => {
}
PortfolioTable.propTypes = {
- projectId: PropTypes.string,
+ projectId: PropTypes.number,
}
export default PortfolioTable
diff --git a/opendc-web/opendc-web-ui/src/components/projects/ProjectOverview.js b/opendc-web/opendc-web-ui/src/components/projects/ProjectOverview.js
index 65b8f5a0..3e1656f6 100644
--- a/opendc-web/opendc-web-ui/src/components/projects/ProjectOverview.js
+++ b/opendc-web/opendc-web-ui/src/components/projects/ProjectOverview.js
@@ -92,7 +92,7 @@ function ProjectOverview({ projectId }) {
}
ProjectOverview.propTypes = {
- projectId: PropTypes.string,
+ projectId: PropTypes.number,
}
export default ProjectOverview
diff --git a/opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js b/opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js
index a7290259..6921578c 100644
--- a/opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js
+++ b/opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js
@@ -5,26 +5,23 @@ import { Project, Status } from '../../shapes'
import { Table, TableBody, TableHeader } from '@patternfly/react-table'
import { parseAndFormatDateTime } from '../../util/date-time'
import { AUTH_DESCRIPTION_MAP, AUTH_ICON_MAP } from '../../util/authorizations'
-import { useAuth } from '../../auth'
import TableEmptyState from '../util/TableEmptyState'
const ProjectTable = ({ status, projects, onDelete, isFiltering }) => {
- const { user } = useAuth()
const columns = ['Project name', 'Last edited', 'Access Rights']
const rows =
projects.length > 0
? projects.map((project) => {
- const { level } = project.authorizations.find((auth) => auth.userId === user.sub)
- const Icon = AUTH_ICON_MAP[level]
+ const Icon = AUTH_ICON_MAP[project.role]
return [
{
- title: <Link href={`/projects/${project._id}`}>{project.name}</Link>,
+ title: <Link href={`/projects/${project.id}`}>{project.name}</Link>,
},
- parseAndFormatDateTime(project.datetimeLastEdited),
+ parseAndFormatDateTime(project.updatedAt),
{
title: (
<>
- <Icon className="pf-u-mr-md" key="auth" /> {AUTH_DESCRIPTION_MAP[level]}
+ <Icon className="pf-u-mr-md" key="auth" /> {AUTH_DESCRIPTION_MAP[project.role]}
</>
),
},
diff --git a/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js b/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js
index 80099ece..ced5304a 100644
--- a/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js
+++ b/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js
@@ -26,26 +26,21 @@ import { Table, TableBody, TableHeader } from '@patternfly/react-table'
import React from 'react'
import TableEmptyState from '../util/TableEmptyState'
import { parseAndFormatDateTime } from '../../util/date-time'
-import { useMutation } from 'react-query'
-import { useProjectTopologies } from '../../data/topology'
+import { useTopologies, useDeleteTopology } from '../../data/topology'
const TopologyTable = ({ projectId }) => {
- const { status, data: topologies = [] } = useProjectTopologies(projectId)
- const { mutate: deleteTopology } = useMutation('deleteTopology')
+ const { status, data: topologies = [] } = useTopologies(projectId)
+ const { mutate: deleteTopology } = useDeleteTopology()
const columns = ['Name', 'Rooms', 'Last Edited']
const rows =
topologies.length > 0
? topologies.map((topology) => [
{
- title: (
- <Link href={`/projects/${topology.projectId}/topologies/${topology._id}`}>
- {topology.name}
- </Link>
- ),
+ title: <Link href={`/projects/${projectId}/topologies/${topology.number}`}>{topology.name}</Link>,
},
topology.rooms.length === 1 ? '1 room' : `${topology.rooms.length} rooms`,
- parseAndFormatDateTime(topology.datetimeLastEdited),
+ parseAndFormatDateTime(topology.updatedAt),
])
: [
{
@@ -69,7 +64,7 @@ const TopologyTable = ({ projectId }) => {
const actionResolver = (_, { rowIndex }) => [
{
title: 'Delete Topology',
- onClick: (_, rowId) => deleteTopology(topologies[rowId]._id),
+ onClick: (_, rowId) => deleteTopology({ projectId, number: topologies[rowId].number }),
isDisabled: rowIndex === 0,
},
]
@@ -89,7 +84,7 @@ const TopologyTable = ({ projectId }) => {
}
TopologyTable.propTypes = {
- projectId: PropTypes.string,
+ projectId: PropTypes.number,
}
export default TopologyTable
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js b/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js
index 9bf369e9..49e5f095 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js
@@ -7,11 +7,11 @@ import { Table, TableBody, TableHeader } from '@patternfly/react-table'
import { deleteRoom } from '../../redux/actions/topology/room'
import TableEmptyState from '../util/TableEmptyState'
-function RoomTable({ topologyId, onSelect }) {
+function RoomTable({ projectId, topologyId, onSelect }) {
const dispatch = useDispatch()
- const { status, data: topology } = useTopology(topologyId)
+ const { status, data: topology } = useTopology(projectId, topologyId)
- const onDelete = (room) => dispatch(deleteRoom(room._id))
+ const onDelete = (room) => dispatch(deleteRoom(room.id))
const columns = ['Name', 'Tiles', 'Racks']
const rows =
@@ -62,7 +62,8 @@ function RoomTable({ topologyId, onSelect }) {
}
RoomTable.propTypes = {
- topologyId: PropTypes.string,
+ projectId: PropTypes.number,
+ topologyId: PropTypes.number,
onSelect: PropTypes.func,
}
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/TopologyOverview.js b/opendc-web/opendc-web-ui/src/components/topologies/TopologyOverview.js
index 213a4868..f8ee4990 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/TopologyOverview.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/TopologyOverview.js
@@ -38,8 +38,8 @@ import { useTopology } from '../../data/topology'
import { parseAndFormatDateTime } from '../../util/date-time'
import RoomTable from './RoomTable'
-function TopologyOverview({ topologyId, onSelect }) {
- const { data: topology } = useTopology(topologyId)
+function TopologyOverview({ projectId, topologyNumber, onSelect }) {
+ const { data: topology } = useTopology(projectId, topologyNumber)
return (
<Grid hasGutter>
<GridItem md={2}>
@@ -57,7 +57,7 @@ function TopologyOverview({ topologyId, onSelect }) {
<DescriptionListTerm>Last edited</DescriptionListTerm>
<DescriptionListDescription>
{topology ? (
- parseAndFormatDateTime(topology.datetimeLastEdited)
+ parseAndFormatDateTime(topology.updatedAt)
) : (
<Skeleton screenreaderText="Loading topology" />
)}
@@ -71,7 +71,11 @@ function TopologyOverview({ topologyId, onSelect }) {
<Card>
<CardTitle>Rooms</CardTitle>
<CardBody>
- <RoomTable topologyId={topologyId} onSelect={(room) => onSelect('room', room)} />
+ <RoomTable
+ projectId={projectId}
+ topologyId={topologyNumber}
+ onSelect={(room) => onSelect('room', room)}
+ />
</CardBody>
</Card>
</GridItem>
@@ -80,7 +84,8 @@ function TopologyOverview({ topologyId, onSelect }) {
}
TopologyOverview.propTypes = {
- topologyId: PropTypes.string,
+ projectId: PropTypes.number,
+ topologyNumber: PropTypes.number,
onSelect: PropTypes.func,
}
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js
index 411a5ca7..21be3c79 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js
@@ -33,7 +33,7 @@ function TileContainer({ tileId, ...props }) {
const dispatch = useDispatch()
const onClick = (tile) => {
if (tile.rack) {
- dispatch(goFromRoomToRack(tile._id))
+ dispatch(goFromRoomToRack(tile.id))
}
}
return <TileGroup {...props} onClick={onClick} tile={tile} interactionLevel={interactionLevel} />
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/elements/ImageComponent.js b/opendc-web/opendc-web-ui/src/components/topologies/map/elements/ImageComponent.js
index 7d304b6b..fdae53f2 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/map/elements/ImageComponent.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/map/elements/ImageComponent.js
@@ -21,6 +21,7 @@ function ImageComponent({ src, x, y, width, height, opacity }) {
}
}, [src])
+ // eslint-disable-next-line jsx-a11y/alt-text
return <Image image={image} x={x} y={y} width={width} height={height} opacity={opacity} />
}
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js b/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js
index 46030135..dad2d62d 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js
@@ -11,8 +11,8 @@ function RackGroup({ tile }) {
<Group>
<TileObject positionX={tile.positionX} positionY={tile.positionY} color={RACK_BACKGROUND_COLOR} />
<Group>
- <RackSpaceFillContainer tileId={tile._id} positionX={tile.positionX} positionY={tile.positionY} />
- <RackEnergyFillContainer tileId={tile._id} positionX={tile.positionX} positionY={tile.positionY} />
+ <RackSpaceFillContainer tileId={tile.id} positionX={tile.positionX} positionY={tile.positionY} />
+ <RackEnergyFillContainer tileId={tile.id} positionX={tile.positionX} positionY={tile.positionY} />
</Group>
</Group>
)
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RoomGroup.js b/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RoomGroup.js
index a42e7bb7..3f8b3089 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RoomGroup.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RoomGroup.js
@@ -7,7 +7,7 @@ import TileContainer from '../TileContainer'
import WallContainer from '../WallContainer'
function RoomGroup({ room, interactionLevel, currentRoomInConstruction, onClick }) {
- if (currentRoomInConstruction === room._id) {
+ if (currentRoomInConstruction === room.id) {
return (
<Group onClick={onClick}>
{room.tiles.map((tileId) => (
@@ -22,7 +22,7 @@ function RoomGroup({ room, interactionLevel, currentRoomInConstruction, onClick
{(() => {
if (
(interactionLevel.mode === 'RACK' || interactionLevel.mode === 'MACHINE') &&
- interactionLevel.roomId === room._id
+ interactionLevel.roomId === room.id
) {
return [
room.tiles
@@ -37,7 +37,7 @@ function RoomGroup({ room, interactionLevel, currentRoomInConstruction, onClick
return room.tiles.map((tileId) => <TileContainer key={tileId} tileId={tileId} />)
}
})()}
- <WallContainer roomId={room._id} />
+ <WallContainer roomId={room.id} />
</Group>
)
}
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/layers/RoomHoverLayer.js b/opendc-web/opendc-web-ui/src/components/topologies/map/layers/RoomHoverLayer.js
index 5e351691..727f4e25 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/map/layers/RoomHoverLayer.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/map/layers/RoomHoverLayer.js
@@ -40,8 +40,8 @@ function RoomHoverLayer() {
.map((id) => ({ ...state.topology.rooms[id] }))
.filter(
(room) =>
- state.topology.root.rooms.indexOf(room._id) !== -1 &&
- room._id !== state.construction.currentRoomInConstruction
+ state.topology.root.rooms.indexOf(room.id) !== -1 &&
+ room.id !== state.construction.currentRoomInConstruction
)
;[...oldRooms, newRoom].forEach((room) => {
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/MachineSidebar.js b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/MachineSidebar.js
index 9268f615..6f89e10b 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/MachineSidebar.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/MachineSidebar.js
@@ -17,7 +17,7 @@ function MachineSidebar({ tileId, position }) {
const rack = topology.racks[topology.tiles[tileId].rack]
return topology.machines[rack.machines[position - 1]]
})
- const machineId = machine._id
+ const machineId = machine.id
return (
<div>
<TextContent>
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitAddComponent.js b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitAddComponent.js
index 88591208..4507b409 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitAddComponent.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitAddComponent.js
@@ -22,7 +22,7 @@ function UnitAddComponent({ units, onAdd }) {
selections={selected}
>
{units.map((unit) => (
- <SelectOption value={unit._id} key={unit._id}>
+ <SelectOption value={unit.id} key={unit.id}>
{unit.name}
</SelectOption>
))}
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitListContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitListContainer.js
index 6dcc414f..25e750c4 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitListContainer.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/machine/UnitListContainer.js
@@ -33,7 +33,7 @@ function UnitListContainer({ machineId, unitType }) {
return machine[unitType].map((id) => state.topology[unitType][id])
})
- const onDelete = (unit) => dispatch(deleteUnit(machineId, unitType, unit._id))
+ const onDelete = (unit) => dispatch(deleteUnit(machineId, unitType, unit.id))
return <UnitListComponent units={units} unitType={unitType} onDelete={onDelete} />
}
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/AddPrefab.js b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/AddPrefab.js
index e944c2e8..6a0c3ff3 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/AddPrefab.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/AddPrefab.js
@@ -22,14 +22,11 @@
import PropTypes from 'prop-types'
import React from 'react'
-import { useDispatch } from 'react-redux'
import { Button } from '@patternfly/react-core'
import { SaveIcon } from '@patternfly/react-icons'
-import { addPrefab } from '../../../../api/prefabs'
-function AddPrefab({ tileId }) {
- const dispatch = useDispatch()
- const onClick = () => dispatch(addPrefab('name', tileId))
+function AddPrefab() {
+ const onClick = () => {} // TODO
return (
<Button variant="primary" icon={<SaveIcon />} isBlock onClick={onClick} className="pf-u-mb-sm">
Save this rack to a prefab
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/MachineListContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/MachineListContainer.js
index 619bb4e2..e1914730 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/MachineListContainer.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/MachineListContainer.js
@@ -43,7 +43,7 @@ function MachineListContainer({ tileId, ...props }) {
<MachineListComponent
{...props}
machines={machinesNull}
- onAdd={(index) => dispatch(addMachine(rack._id, index))}
+ onAdd={(index) => dispatch(addMachine(rack.id, index))}
onSelect={(index) => dispatch(goFromRackToMachine(index))}
/>
)
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackNameContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackNameContainer.js
index 30f38cce..c3422318 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackNameContainer.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackNameContainer.js
@@ -5,11 +5,11 @@ import NameComponent from '../NameComponent'
import { editRackName } from '../../../../redux/actions/topology/rack'
const RackNameContainer = ({ tileId }) => {
- const { name: rackName, _id } = useSelector((state) => state.topology.racks[state.topology.tiles[tileId].rack])
+ const { name: rackName, id } = useSelector((state) => state.topology.racks[state.topology.tiles[tileId].rack])
const dispatch = useDispatch()
const callback = (name) => {
if (name) {
- dispatch(editRackName(_id, name))
+ dispatch(editRackName(id, name))
}
}
return <NameComponent name={rackName} onEdit={callback} />
diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/room/RoomName.js b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/room/RoomName.js
index fb52d826..72d45bea 100644
--- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/room/RoomName.js
+++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/room/RoomName.js
@@ -27,11 +27,11 @@ import NameComponent from '../NameComponent'
import { editRoomName } from '../../../../redux/actions/topology/room'
function RoomName({ roomId }) {
- const { name: roomName, _id } = useSelector((state) => state.topology.rooms[roomId])
+ const { name: roomName, id } = useSelector((state) => state.topology.rooms[roomId])
const dispatch = useDispatch()
const callback = (name) => {
if (name) {
- dispatch(editRoomName(_id, name))
+ dispatch(editRoomName(id, name))
}
}
return <NameComponent name={roomName} onEdit={callback} />
diff --git a/opendc-web/opendc-web-ui/src/data/experiments.js b/opendc-web/opendc-web-ui/src/data/experiments.js
index a76ea53f..ca8912a2 100644
--- a/opendc-web/opendc-web-ui/src/data/experiments.js
+++ b/opendc-web/opendc-web-ui/src/data/experiments.js
@@ -35,13 +35,13 @@ export function configureExperimentClient(queryClient, auth) {
/**
* Return the available traces to experiment with.
*/
-export function useTraces() {
- return useQuery('traces')
+export function useTraces(options) {
+ return useQuery('traces', options)
}
/**
* Return the available schedulers to experiment with.
*/
-export function useSchedulers() {
- return useQuery('schedulers')
+export function useSchedulers(options) {
+ return useQuery('schedulers', options)
}
diff --git a/opendc-web/opendc-web-ui/src/data/project.js b/opendc-web/opendc-web-ui/src/data/project.js
index 9dcd8532..b1db3da5 100644
--- a/opendc-web/opendc-web-ui/src/data/project.js
+++ b/opendc-web/opendc-web-ui/src/data/project.js
@@ -20,9 +20,9 @@
* SOFTWARE.
*/
-import { useQuery } from 'react-query'
+import { useQuery, useMutation } from 'react-query'
import { addProject, deleteProject, fetchProject, fetchProjects } from '../api/projects'
-import { addPortfolio, deletePortfolio, fetchPortfolio, fetchPortfoliosOfProject } from '../api/portfolios'
+import { addPortfolio, deletePortfolio, fetchPortfolio, fetchPortfolios } from '../api/portfolios'
import { addScenario, deleteScenario, fetchScenario, fetchScenariosOfPortfolio } from '../api/scenarios'
/**
@@ -37,79 +37,60 @@ export function configureProjectClient(queryClient, auth) {
mutationFn: (data) => addProject(auth, data),
onSuccess: async (result) => {
queryClient.setQueryData('projects', (old = []) => [...old, result])
- queryClient.setQueryData(['projects', result._id], result)
+ queryClient.setQueryData(['projects', result.id], result)
},
})
queryClient.setMutationDefaults('deleteProject', {
mutationFn: (id) => deleteProject(auth, id),
onSuccess: async (result) => {
- queryClient.setQueryData('projects', (old = []) => old.filter((project) => project._id !== result._id))
- queryClient.removeQueries(['projects', result._id])
+ queryClient.setQueryData('projects', (old = []) => old.filter((project) => project.id !== result.id))
+ queryClient.removeQueries(['projects', result.id])
},
})
queryClient.setQueryDefaults('portfolios', {
- queryFn: ({ queryKey }) => fetchPortfolio(auth, queryKey[1]),
- })
- queryClient.setQueryDefaults('project-portfolios', {
- queryFn: ({ queryKey }) => fetchPortfoliosOfProject(auth, queryKey[1]),
+ queryFn: ({ queryKey }) =>
+ queryKey.length === 2 ? fetchPortfolios(auth, queryKey[1]) : fetchPortfolio(auth, queryKey[1], queryKey[2]),
})
queryClient.setMutationDefaults('addPortfolio', {
- mutationFn: (data) => addPortfolio(auth, data),
+ mutationFn: ({ projectId, ...data }) => addPortfolio(auth, projectId, data),
onSuccess: async (result) => {
- queryClient.setQueryData(['projects', result.projectId], (old) => ({
- ...old,
- portfolioIds: [...old.portfolioIds, result._id],
- }))
- queryClient.setQueryData(['project-portfolios', result.projectId], (old = []) => [...old, result])
- queryClient.setQueryData(['portfolios', result._id], result)
+ queryClient.setQueryData(['portfolios', result.project.id], (old = []) => [...old, result])
+ queryClient.setQueryData(['portfolios', result.project.id, result.number], result)
},
})
queryClient.setMutationDefaults('deletePortfolio', {
- mutationFn: (id) => deletePortfolio(auth, id),
+ mutationFn: ({ projectId, number }) => deletePortfolio(auth, projectId, number),
onSuccess: async (result) => {
- queryClient.setQueryData(['projects', result.projectId], (old) => ({
- ...old,
- portfolioIds: old.portfolioIds.filter((id) => id !== result._id),
- }))
- queryClient.setQueryData(['project-portfolios', result.projectId], (old = []) =>
- old.filter((portfolio) => portfolio._id !== result._id)
+ queryClient.setQueryData(['portfolios', result.project.id], (old = []) =>
+ old.filter((portfolio) => portfolio.id !== result.id)
)
- queryClient.removeQueries(['portfolios', result._id])
+ queryClient.removeQueries(['portfolios', result.project.id, result.number])
},
})
queryClient.setQueryDefaults('scenarios', {
- queryFn: ({ queryKey }) => fetchScenario(auth, queryKey[1]),
- })
- queryClient.setQueryDefaults('portfolio-scenarios', {
- queryFn: ({ queryKey }) => fetchScenariosOfPortfolio(auth, queryKey[1]),
+ queryFn: ({ queryKey }) => fetchScenario(auth, queryKey[1], queryKey[2]),
})
queryClient.setMutationDefaults('addScenario', {
- mutationFn: (data) => addScenario(auth, data),
+ mutationFn: ({ projectId, portfolioNumber, data }) => addScenario(auth, projectId, portfolioNumber, data),
onSuccess: async (result) => {
// Register updated scenario in cache
- queryClient.setQueryData(['scenarios', result._id], result)
- queryClient.setQueryData(['portfolio-scenarios', result.portfolioId], (old = []) => [...old, result])
-
- // Add scenario id to portfolio
- queryClient.setQueryData(['portfolios', result.portfolioId], (old) => ({
+ queryClient.setQueryData(['scenarios', result.project.id, result.id], result)
+ queryClient.setQueryData(['portfolios', result.project.id, result.portfolio.number], (old) => ({
...old,
- scenarioIds: [...old.scenarioIds, result._id],
+ scenarios: [...old.scenarios, result],
}))
},
})
queryClient.setMutationDefaults('deleteScenario', {
- mutationFn: (id) => deleteScenario(auth, id),
+ mutationFn: ({ projectId, number }) => deleteScenario(auth, projectId, number),
onSuccess: async (result) => {
- queryClient.setQueryData(['portfolios', result.portfolioId], (old) => ({
+ queryClient.removeQueries(['scenarios', result.project.id, result.id])
+ queryClient.setQueryData(['portfolios', result.project.id, result.portfolio.number], (old) => ({
...old,
- scenarioIds: old.scenarioIds.filter((id) => id !== result._id),
+ scenarios: old?.scenarios?.filter((scenario) => scenario.id !== result.id),
}))
- queryClient.setQueryData(['portfolio-scenarios', result.portfolioId], (old = []) =>
- old.filter((scenario) => scenario._id !== result._id)
- )
- queryClient.removeQueries(['scenarios', result._id])
},
})
}
@@ -129,22 +110,57 @@ export function useProject(projectId, options = {}) {
}
/**
+ * Create a mutation for a new project.
+ */
+export function useNewProject() {
+ return useMutation('addProject')
+}
+
+/**
+ * Create a mutation for deleting a project.
+ */
+export function useDeleteProject() {
+ return useMutation('deleteProject')
+}
+
+/**
* Return the portfolio with the specified identifier.
*/
-export function usePortfolio(portfolioId, options = {}) {
- return useQuery(['portfolios', portfolioId], { enabled: !!portfolioId, ...options })
+export function usePortfolio(projectId, portfolioId, options = {}) {
+ return useQuery(['portfolios', projectId, portfolioId], { enabled: !!(projectId && portfolioId), ...options })
}
/**
* Return the portfolios of the specified project.
*/
-export function useProjectPortfolios(projectId, options = {}) {
- return useQuery(['project-portfolios', projectId], { enabled: !!projectId, ...options })
+export function usePortfolios(projectId, options = {}) {
+ return useQuery(['portfolios', projectId], { enabled: !!projectId, ...options })
+}
+
+/**
+ * Create a mutation for a new portfolio.
+ */
+export function useNewPortfolio() {
+ return useMutation('addPortfolio')
+}
+
+/**
+ * Create a mutation for deleting a portfolio.
+ */
+export function useDeletePortfolio() {
+ return useMutation('deletePortfolio')
+}
+
+/**
+ * Create a mutation for a new scenario.
+ */
+export function useNewScenario() {
+ return useMutation('addScenario')
}
/**
- * Return the scenarios of the specified portfolio.
+ * Create a mutation for deleting a scenario.
*/
-export function usePortfolioScenarios(portfolioId, options = {}) {
- return useQuery(['portfolio-scenarios', portfolioId], { enabled: !!portfolioId, ...options })
+export function useDeleteScenario() {
+ return useMutation('deleteScenario')
}
diff --git a/opendc-web/opendc-web-ui/src/data/topology.js b/opendc-web/opendc-web-ui/src/data/topology.js
index e068ed8e..cf098c56 100644
--- a/opendc-web/opendc-web-ui/src/data/topology.js
+++ b/opendc-web/opendc-web-ui/src/data/topology.js
@@ -20,58 +20,69 @@
* SOFTWARE.
*/
-import { useQuery } from 'react-query'
-import { addTopology, deleteTopology, fetchTopologiesOfProject, fetchTopology, updateTopology } from '../api/topologies'
+import { useQuery, useMutation } from 'react-query'
+import { addTopology, deleteTopology, fetchTopologies, fetchTopology, updateTopology } from '../api/topologies'
/**
* Configure the query defaults for the topology endpoints.
*/
export function configureTopologyClient(queryClient, auth) {
- queryClient.setQueryDefaults('topologies', { queryFn: ({ queryKey }) => fetchTopology(auth, queryKey[1]) })
- queryClient.setQueryDefaults('project-topologies', {
- queryFn: ({ queryKey }) => fetchTopologiesOfProject(auth, queryKey[1]),
+ queryClient.setQueryDefaults('topologies', {
+ queryFn: ({ queryKey }) =>
+ queryKey.length === 2 ? fetchTopologies(auth, queryKey[1]) : fetchTopology(auth, queryKey[1], queryKey[2]),
})
queryClient.setMutationDefaults('addTopology', {
- mutationFn: (data) => addTopology(auth, data),
- onSuccess: async (result) => {
- queryClient.setQueryData(['projects', result.projectId], (old) => ({
- ...old,
- topologyIds: [...old.topologyIds, result._id],
- }))
- queryClient.setQueryData(['project-topologies', result.projectId], (old = []) => [...old, result])
- queryClient.setQueryData(['topologies', result._id], result)
+ mutationFn: ({ projectId, ...data }) => addTopology(auth, projectId, data),
+ onSuccess: (result) => {
+ queryClient.setQueryData(['topologies', result.project.id], (old = []) => [...old, result])
+ queryClient.setQueryData(['topologies', result.project.id, result.number], result)
},
})
queryClient.setMutationDefaults('updateTopology', {
mutationFn: (data) => updateTopology(auth, data),
- onSuccess: (result) => queryClient.setQueryData(['topologies', result._id], result),
+ onSuccess: (result) => {
+ queryClient.setQueryData(['topologies', result.project.id], (old = []) =>
+ old.map((topology) => (topology.id === result.id ? result : topology))
+ )
+ queryClient.setQueryData(['topologies', result.project.id, result.number], result)
+ },
})
queryClient.setMutationDefaults('deleteTopology', {
- mutationFn: (id) => deleteTopology(auth, id),
- onSuccess: async (result) => {
- queryClient.setQueryData(['projects', result.projectId], (old) => ({
- ...old,
- topologyIds: old.topologyIds.filter((id) => id !== result._id),
- }))
- queryClient.setQueryData(['project-topologies', result.projectId], (old = []) =>
- old.filter((topology) => topology._id !== result._id)
+ mutationFn: ({ projectId, id }) => deleteTopology(auth, projectId, id),
+ onSuccess: (result) => {
+ queryClient.setQueryData(['topologies', result.project.id], (old = []) =>
+ old.filter((topology) => topology.id !== result.id)
)
- queryClient.removeQueries(['topologies', result._id])
+ queryClient.removeQueries(['topologies', result.project.id, result.number])
},
})
}
/**
- * Return the current active topology.
+ * Fetch the topology with the specified identifier for the specified project.
+ */
+export function useTopology(projectId, topologyId, options = {}) {
+ return useQuery(['topologies', projectId, topologyId], { enabled: !!(projectId && topologyId), ...options })
+}
+
+/**
+ * Fetch all topologies of the specified project.
+ */
+export function useTopologies(projectId, options = {}) {
+ return useQuery(['topologies', projectId], { enabled: !!projectId, ...options })
+}
+
+/**
+ * Create a mutation for a new topology.
*/
-export function useTopology(topologyId, options = {}) {
- return useQuery(['topologies', topologyId], { enabled: !!topologyId, ...options })
+export function useNewTopology() {
+ return useMutation('addTopology')
}
/**
- * Return the topologies of the specified project.
+ * Create a mutation for deleting a topology.
*/
-export function useProjectTopologies(projectId, options = {}) {
- return useQuery(['project-topologies', projectId], { enabled: !!projectId, ...options })
+export function useDeleteTopology() {
+ return useMutation('deleteTopology')
}
diff --git a/opendc-web/opendc-web-ui/src/pages/_app.js b/opendc-web/opendc-web-ui/src/pages/_app.js
index 900ff405..4861f5c1 100644
--- a/opendc-web/opendc-web-ui/src/pages/_app.js
+++ b/opendc-web/opendc-web-ui/src/pages/_app.js
@@ -22,6 +22,7 @@
import PropTypes from 'prop-types'
import Head from 'next/head'
+import Script from 'next/script'
import { Provider } from 'react-redux'
import { useNewQueryClient } from '../data/query'
import { useStore } from '../redux'
@@ -91,6 +92,19 @@ export default function App(props) {
<Inner {...props} />
</AuthProvider>
</Sentry.ErrorBoundary>
+ {/* Google Analytics */}
+ <Script async src="https://www.googletagmanager.com/gtag/js?id=UA-84285092-3" />
+ <Script
+ id="gtag"
+ dangerouslySetInnerHTML={{
+ __html: `
+ window.dataLayer = window.dataLayer || [];
+ function gtag(){dataLayer.push(arguments);}
+ gtag('js', new Date());
+ gtag('config', 'UA-84285092-3');
+ `,
+ }}
+ />
</>
)
}
diff --git a/opendc-web/opendc-web-ui/src/pages/_document.js b/opendc-web/opendc-web-ui/src/pages/_document.js
index 51d8d3e0..011bf4da 100644
--- a/opendc-web/opendc-web-ui/src/pages/_document.js
+++ b/opendc-web/opendc-web-ui/src/pages/_document.js
@@ -69,19 +69,6 @@ class OpenDCDocument extends Document {
href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap"
rel="stylesheet"
/>
-
- {/* Google Analytics */}
- <script async src="https://www.googletagmanager.com/gtag/js?id=UA-84285092-3" />
- <script
- dangerouslySetInnerHTML={{
- __html: `
- window.dataLayer = window.dataLayer || [];
- function gtag(){dataLayer.push(arguments);}
- gtag('js', new Date());
- gtag('config', 'UA-84285092-3');
- `,
- }}
- />
</Head>
<body>
<Main />
diff --git a/opendc-web/opendc-web-ui/src/pages/projects/[project]/index.js b/opendc-web/opendc-web-ui/src/pages/projects/[project]/index.js
index c07a2c31..39fcb4f3 100644
--- a/opendc-web/opendc-web-ui/src/pages/projects/[project]/index.js
+++ b/opendc-web/opendc-web-ui/src/pages/projects/[project]/index.js
@@ -40,9 +40,9 @@ import BreadcrumbLink from '../../../components/util/BreadcrumbLink'
function Project() {
const router = useRouter()
- const { project: projectId } = router.query
+ const projectId = +router.query['project']
- const { data: project } = useProject(projectId)
+ const { data: project } = useProject(+projectId)
const breadcrumb = (
<Breadcrumb>
@@ -57,7 +57,7 @@ function Project() {
const contextSelectors = (
<ContextSelectionSection>
- <ProjectSelector projectId={projectId} />
+ <ProjectSelector activeProject={project} />
</ContextSelectionSection>
)
diff --git a/opendc-web/opendc-web-ui/src/pages/projects/[project]/portfolios/[portfolio].js b/opendc-web/opendc-web-ui/src/pages/projects/[project]/portfolios/[portfolio].js
index d1533d98..68345d0b 100644
--- a/opendc-web/opendc-web-ui/src/pages/projects/[project]/portfolios/[portfolio].js
+++ b/opendc-web/opendc-web-ui/src/pages/projects/[project]/portfolios/[portfolio].js
@@ -20,6 +20,7 @@
* SOFTWARE.
*/
+import dynamic from 'next/dynamic'
import { useRouter } from 'next/router'
import Head from 'next/head'
import React, { useRef } from 'react'
@@ -42,18 +43,24 @@ import PortfolioSelector from '../../../../components/context/PortfolioSelector'
import ProjectSelector from '../../../../components/context/ProjectSelector'
import BreadcrumbLink from '../../../../components/util/BreadcrumbLink'
import PortfolioOverview from '../../../../components/portfolios/PortfolioOverview'
-import PortfolioResults from '../../../../components/portfolios/PortfolioResults'
+import { usePortfolio } from '../../../../data/project'
+
+const PortfolioResults = dynamic(() => import('../../../../components/portfolios/PortfolioResults'))
/**
* Page that displays the results in a portfolio.
*/
function Portfolio() {
const router = useRouter()
- const { project: projectId, portfolio: portfolioId } = router.query
+ const projectId = +router.query['project']
+ const portfolioNumber = +router.query['portfolio']
const overviewRef = useRef(null)
const resultsRef = useRef(null)
+ const { data: portfolio } = usePortfolio(projectId, portfolioNumber)
+ const project = portfolio?.project
+
const breadcrumb = (
<Breadcrumb>
<BreadcrumbItem to="/projects" component={BreadcrumbLink}>
@@ -62,7 +69,11 @@ function Portfolio() {
<BreadcrumbItem to={`/projects/${projectId}`} component={BreadcrumbLink}>
Project details
</BreadcrumbItem>
- <BreadcrumbItem to={`/projects/${projectId}/portfolios/${portfolioId}`} component={BreadcrumbLink} isActive>
+ <BreadcrumbItem
+ to={`/projects/${projectId}/portfolios/${portfolioNumber}`}
+ component={BreadcrumbLink}
+ isActive
+ >
Portfolio
</BreadcrumbItem>
</Breadcrumb>
@@ -70,8 +81,8 @@ function Portfolio() {
const contextSelectors = (
<ContextSelectionSection>
- <ProjectSelector projectId={projectId} />
- <PortfolioSelector projectId={projectId} portfolioId={portfolioId} />
+ <ProjectSelector activeProject={project} />
+ <PortfolioSelector activePortfolio={portfolio} />
</ContextSelectionSection>
)
@@ -104,10 +115,10 @@ function Portfolio() {
</PageSection>
<PageSection isFilled>
<TabContent eventKey={0} id="overview" ref={overviewRef} aria-label="Overview tab">
- <PortfolioOverview portfolioId={portfolioId} />
+ <PortfolioOverview projectId={projectId} portfolioId={portfolioNumber} />
</TabContent>
<TabContent eventKey={1} id="results" ref={resultsRef} aria-label="Results tab" hidden>
- <PortfolioResults portfolioId={portfolioId} />
+ <PortfolioResults projectId={projectId} portfolioId={portfolioNumber} />
</TabContent>
</PageSection>
</AppPage>
diff --git a/opendc-web/opendc-web-ui/src/pages/projects/[project]/topologies/[topology].js b/opendc-web/opendc-web-ui/src/pages/projects/[project]/topologies/[topology].js
index f7188d9f..6297b8c3 100644
--- a/opendc-web/opendc-web-ui/src/pages/projects/[project]/topologies/[topology].js
+++ b/opendc-web/opendc-web-ui/src/pages/projects/[project]/topologies/[topology].js
@@ -26,7 +26,6 @@ import ContextSelectionSection from '../../../../components/context/ContextSelec
import ProjectSelector from '../../../../components/context/ProjectSelector'
import TopologySelector from '../../../../components/context/TopologySelector'
import TopologyOverview from '../../../../components/topologies/TopologyOverview'
-import { useProject } from '../../../../data/project'
import { useDispatch } from 'react-redux'
import React, { useEffect, useState } from 'react'
import Head from 'next/head'
@@ -45,6 +44,7 @@ import {
TextContent,
} from '@patternfly/react-core'
import BreadcrumbLink from '../../../../components/util/BreadcrumbLink'
+import { useTopology } from '../../../../data/topology'
import { goToRoom } from '../../../../redux/actions/interaction-level'
import { openTopology } from '../../../../redux/actions/topology'
@@ -55,16 +55,18 @@ const TopologyMap = dynamic(() => import('../../../../components/topologies/Topo
*/
function Topology() {
const router = useRouter()
- const { project: projectId, topology: topologyId } = router.query
+ const projectId = +router.query['project']
+ const topologyNumber = +router.query['topology']
- const { data: project } = useProject(projectId)
+ const { data: topology } = useTopology(projectId, topologyNumber)
+ const project = topology?.project
const dispatch = useDispatch()
useEffect(() => {
- if (topologyId) {
- dispatch(openTopology(topologyId))
+ if (topologyNumber) {
+ dispatch(openTopology(projectId, topologyNumber))
}
- }, [topologyId, dispatch])
+ }, [projectId, topologyNumber, dispatch])
const [activeTab, setActiveTab] = useState('overview')
@@ -76,7 +78,11 @@ function Topology() {
<BreadcrumbItem to={`/projects/${projectId}`} component={BreadcrumbLink}>
Project details
</BreadcrumbItem>
- <BreadcrumbItem to={`/projects/${projectId}/topologies/${topologyId}`} component={BreadcrumbLink} isActive>
+ <BreadcrumbItem
+ to={`/projects/${projectId}/topologies/${topologyNumber}`}
+ component={BreadcrumbLink}
+ isActive
+ >
Topology
</BreadcrumbItem>
</Breadcrumb>
@@ -84,8 +90,8 @@ function Topology() {
const contextSelectors = (
<ContextSelectionSection>
- <ProjectSelector projectId={projectId} />
- <TopologySelector projectId={projectId} topologyId={topologyId} />
+ <ProjectSelector activeProject={project} />
+ <TopologySelector activeTopology={topology} />
</ContextSelectionSection>
)
@@ -117,16 +123,22 @@ function Topology() {
<PageSection padding={activeTab === 'floor-plan' && { default: 'noPadding' }} isFilled>
<TabContent id="overview" aria-label="Overview tab" hidden={activeTab !== 'overview'}>
<TopologyOverview
- topologyId={topologyId}
+ projectId={projectId}
+ topologyNumber={topologyNumber}
onSelect={(type, obj) => {
if (type === 'room') {
- dispatch(goToRoom(obj._id))
+ dispatch(goToRoom(obj.id))
setActiveTab('floor-plan')
}
}}
/>
</TabContent>
- <TabContent id="floor-plan" aria-label="Floor Plan tab" className="pf-u-h-100" hidden={activeTab !== 'floor-plan'}>
+ <TabContent
+ id="floor-plan"
+ aria-label="Floor Plan tab"
+ className="pf-u-h-100"
+ hidden={activeTab !== 'floor-plan'}
+ >
<TopologyMap />
</TabContent>
</PageSection>
diff --git a/opendc-web/opendc-web-ui/src/pages/projects/index.js b/opendc-web/opendc-web-ui/src/pages/projects/index.js
index eb77701e..bb1fbd69 100644
--- a/opendc-web/opendc-web-ui/src/pages/projects/index.js
+++ b/opendc-web/opendc-web-ui/src/pages/projects/index.js
@@ -26,9 +26,8 @@ import ProjectFilterPanel from '../../components/projects/FilterPanel'
import { useAuth } from '../../auth'
import { AppPage } from '../../components/AppPage'
import { PageSection, PageSectionVariants, Text, TextContent } from '@patternfly/react-core'
-import { useProjects } from '../../data/project'
+import { useProjects, useDeleteProject } from '../../data/project'
import ProjectTable from '../../components/projects/ProjectTable'
-import { useMutation } from 'react-query'
import NewProject from '../../components/projects/NewProject'
const getVisibleProjects = (projects, filter, userId) => {
@@ -52,13 +51,12 @@ function Projects() {
const { user } = useAuth()
const { status, data: projects } = useProjects()
const [filter, setFilter] = useState('SHOW_ALL')
- const visibleProjects = useMemo(() => getVisibleProjects(projects ?? [], filter, user?.sub), [
- projects,
- filter,
- user?.sub,
- ])
+ const visibleProjects = useMemo(
+ () => getVisibleProjects(projects ?? [], filter, user?.sub),
+ [projects, filter, user?.sub]
+ )
- const { mutate: deleteProject } = useMutation('deleteProject')
+ const { mutate: deleteProject } = useDeleteProject()
return (
<AppPage>
@@ -76,7 +74,7 @@ function Projects() {
status={status}
isFiltering={filter !== 'SHOW_ALL'}
projects={visibleProjects}
- onDelete={(project) => deleteProject(project._id)}
+ onDelete={(project) => deleteProject(project.id)}
/>
<NewProject />
</PageSection>
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/topology/building.js b/opendc-web/opendc-web-ui/src/redux/actions/topology/building.js
index 939c24a4..e430da2e 100644
--- a/opendc-web/opendc-web-ui/src/redux/actions/topology/building.js
+++ b/opendc-web/opendc-web-ui/src/redux/actions/topology/building.js
@@ -14,16 +14,16 @@ export const DELETE_TILE = 'DELETE_TILE'
export function startNewRoomConstruction() {
return (dispatch, getState) => {
const { topology } = getState()
- const topologyId = topology.root._id
+ const topologyId = topology.root.id
const room = {
- _id: uuid(),
+ id: uuid(),
name: 'Room',
topologyId,
tiles: [],
}
dispatch(addRoom(topologyId, room))
- dispatch(startNewRoomConstructionSucceeded(room._id))
+ dispatch(startNewRoomConstructionSucceeded(room.id))
}
}
@@ -97,7 +97,7 @@ export function addTile(roomId, positionX, positionY) {
return {
type: ADD_TILE,
tile: {
- _id: uuid(),
+ id: uuid(),
roomId,
positionX,
positionY,
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/topology/index.js b/opendc-web/opendc-web-ui/src/redux/actions/topology/index.js
index 94a712c4..d48af37a 100644
--- a/opendc-web/opendc-web-ui/src/redux/actions/topology/index.js
+++ b/opendc-web/opendc-web-ui/src/redux/actions/topology/index.js
@@ -23,9 +23,10 @@
export const OPEN_TOPOLOGY = 'OPEN_TOPOLOGY'
export const STORE_TOPOLOGY = 'STORE_TOPOLOGY'
-export function openTopology(id) {
+export function openTopology(projectId, id) {
return {
type: OPEN_TOPOLOGY,
+ projectId,
id,
}
}
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/topology/rack.js b/opendc-web/opendc-web-ui/src/redux/actions/topology/rack.js
index c319d966..308acaa6 100644
--- a/opendc-web/opendc-web-ui/src/redux/actions/topology/rack.js
+++ b/opendc-web/opendc-web-ui/src/redux/actions/topology/rack.js
@@ -24,7 +24,7 @@ export function addMachine(rackId, position) {
return {
type: ADD_MACHINE,
machine: {
- _id: uuid(),
+ id: uuid(),
rackId,
position,
cpus: [],
diff --git a/opendc-web/opendc-web-ui/src/redux/actions/topology/room.js b/opendc-web/opendc-web-ui/src/redux/actions/topology/room.js
index bd447db5..fd2d8cdc 100644
--- a/opendc-web/opendc-web-ui/src/redux/actions/topology/room.js
+++ b/opendc-web/opendc-web-ui/src/redux/actions/topology/room.js
@@ -16,7 +16,7 @@ export function addRoom(topologyId, room) {
return {
type: ADD_ROOM,
room: {
- _id: uuid(),
+ id: uuid(),
topologyId,
...room,
},
@@ -54,9 +54,9 @@ export function addRackToTile(positionX, positionY) {
dispatch({
type: ADD_RACK_TO_TILE,
rack: {
- _id: uuid(),
+ id: uuid(),
name: 'Rack',
- tileId: tile._id,
+ tileId: tile.id,
capacity: DEFAULT_RACK_SLOT_CAPACITY,
powerCapacityW: DEFAULT_RACK_POWER_CAPACITY,
machines: [],
diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/topology/machine.js b/opendc-web/opendc-web-ui/src/redux/reducers/topology/machine.js
index 47af53cf..1789257b 100644
--- a/opendc-web/opendc-web-ui/src/redux/reducers/topology/machine.js
+++ b/opendc-web/opendc-web-ui/src/redux/reducers/topology/machine.js
@@ -10,7 +10,7 @@ function machine(state = {}, action, { racks }) {
case ADD_MACHINE:
return produce(state, (draft) => {
const { machine } = action
- draft[machine._id] = machine
+ draft[machine.id] = machine
})
case DELETE_MACHINE:
return produce(state, (draft) => {
diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/topology/rack.js b/opendc-web/opendc-web-ui/src/redux/reducers/topology/rack.js
index 155837cb..ca79348a 100644
--- a/opendc-web/opendc-web-ui/src/redux/reducers/topology/rack.js
+++ b/opendc-web/opendc-web-ui/src/redux/reducers/topology/rack.js
@@ -33,7 +33,7 @@ function rack(state = {}, action, { machines }) {
case ADD_RACK_TO_TILE:
return produce(state, (draft) => {
const { rack } = action
- draft[rack._id] = rack
+ draft[rack.id] = rack
})
case EDIT_RACK_NAME:
return produce(state, (draft) => {
@@ -48,7 +48,7 @@ function rack(state = {}, action, { machines }) {
case ADD_MACHINE:
return produce(state, (draft) => {
const { machine } = action
- draft[machine.rackId].machines.push(machine._id)
+ draft[machine.rackId].machines.push(machine.id)
})
case DELETE_MACHINE:
return produce(state, (draft) => {
diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/topology/room.js b/opendc-web/opendc-web-ui/src/redux/reducers/topology/room.js
index d6cc51c1..c05c8bfa 100644
--- a/opendc-web/opendc-web-ui/src/redux/reducers/topology/room.js
+++ b/opendc-web/opendc-web-ui/src/redux/reducers/topology/room.js
@@ -32,7 +32,7 @@ function room(state = {}, action, { tiles }) {
case ADD_ROOM:
return produce(state, (draft) => {
const { room } = action
- draft[room._id] = room
+ draft[room.id] = room
})
case DELETE_ROOM:
return produce(state, (draft) => {
@@ -47,7 +47,7 @@ function room(state = {}, action, { tiles }) {
case ADD_TILE:
return produce(state, (draft) => {
const { tile } = action
- draft[tile.roomId].tiles.push(tile._id)
+ draft[tile.roomId].tiles.push(tile.id)
})
case DELETE_TILE:
return produce(state, (draft) => {
diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/topology/tile.js b/opendc-web/opendc-web-ui/src/redux/reducers/topology/tile.js
index 6dbccb66..8e5ecd6e 100644
--- a/opendc-web/opendc-web-ui/src/redux/reducers/topology/tile.js
+++ b/opendc-web/opendc-web-ui/src/redux/reducers/topology/tile.js
@@ -33,7 +33,7 @@ function tile(state = {}, action, { racks }) {
case ADD_TILE:
return produce(state, (draft) => {
const { tile } = action
- draft[tile._id] = tile
+ draft[tile.id] = tile
})
case DELETE_TILE:
return produce(state, (draft) => {
@@ -43,7 +43,7 @@ function tile(state = {}, action, { racks }) {
case ADD_RACK_TO_TILE:
return produce(state, (draft) => {
const { rack } = action
- draft[rack.tileId].rack = rack._id
+ draft[rack.tileId].rack = rack.id
})
case DELETE_RACK:
return produce(state, (draft) => {
diff --git a/opendc-web/opendc-web-ui/src/redux/reducers/topology/topology.js b/opendc-web/opendc-web-ui/src/redux/reducers/topology/topology.js
index cd9b5efd..dff0a69e 100644
--- a/opendc-web/opendc-web-ui/src/redux/reducers/topology/topology.js
+++ b/opendc-web/opendc-web-ui/src/redux/reducers/topology/topology.js
@@ -21,7 +21,7 @@
*/
import produce from 'immer'
-import { STORE_TOPOLOGY } from "../../actions/topology";
+import { STORE_TOPOLOGY } from '../../actions/topology'
import { ADD_ROOM, DELETE_ROOM } from '../../actions/topology/room'
function topology(state = undefined, action) {
@@ -31,7 +31,7 @@ function topology(state = undefined, action) {
case ADD_ROOM:
return produce(state, (draft) => {
const { room } = action
- draft.rooms.push(room._id)
+ draft.rooms.push(room.id)
})
case DELETE_ROOM:
return produce(state, (draft) => {
diff --git a/opendc-web/opendc-web-ui/src/redux/sagas/topology.js b/opendc-web/opendc-web-ui/src/redux/sagas/topology.js
index 4c8ff5da..15147bcf 100644
--- a/opendc-web/opendc-web-ui/src/redux/sagas/topology.js
+++ b/opendc-web/opendc-web-ui/src/redux/sagas/topology.js
@@ -32,14 +32,15 @@ export function* updateServer() {
* Watch the topology on the server for changes.
*/
export function* watchServer() {
- let { id } = yield take(OPEN_TOPOLOGY)
+ let { projectId, id } = yield take(OPEN_TOPOLOGY)
while (true) {
- const channel = yield queryObserver(id)
+ const channel = yield queryObserver(projectId, id)
while (true) {
const [action, response] = yield race([take(OPEN_TOPOLOGY), take(channel)])
if (action) {
+ projectId = action.projectId
id = action.id
break
}
@@ -57,9 +58,9 @@ export function* watchServer() {
/**
* Observe changes for the topology with the specified identifier.
*/
-function* queryObserver(id) {
+function* queryObserver(projectId, id) {
const queryClient = yield getContext('queryClient')
- const observer = new QueryObserver(queryClient, { queryKey: ['topologies', id] })
+ const observer = new QueryObserver(queryClient, { queryKey: ['topologies', projectId, id] })
return eventChannel((emitter) => {
const unsubscribe = observer.subscribe((result) => {
diff --git a/opendc-web/opendc-web-ui/src/shapes.js b/opendc-web/opendc-web-ui/src/shapes.js
index abdf146e..6c93f458 100644
--- a/opendc-web/opendc-web-ui/src/shapes.js
+++ b/opendc-web/opendc-web-ui/src/shapes.js
@@ -22,26 +22,18 @@
import PropTypes from 'prop-types'
-export const User = PropTypes.shape({
- _id: PropTypes.string.isRequired,
- googleId: PropTypes.string.isRequired,
- email: PropTypes.string.isRequired,
- givenName: PropTypes.string.isRequired,
- familyName: PropTypes.string.isRequired,
- authorizations: PropTypes.array.isRequired,
-})
+export const ProjectRole = PropTypes.oneOf(['VIEWER', 'EDITOR', 'OWNER'])
export const Project = PropTypes.shape({
- _id: PropTypes.string.isRequired,
+ id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
- datetimeCreated: PropTypes.string.isRequired,
- datetimeLastEdited: PropTypes.string.isRequired,
- topologyIds: PropTypes.array.isRequired,
- portfolioIds: PropTypes.array.isRequired,
+ createdAt: PropTypes.string.isRequired,
+ updatedAt: PropTypes.string.isRequired,
+ role: ProjectRole,
})
export const ProcessingUnit = PropTypes.shape({
- _id: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
clockRateMhz: PropTypes.number.isRequired,
numberOfCores: PropTypes.number.isRequired,
@@ -49,7 +41,7 @@ export const ProcessingUnit = PropTypes.shape({
})
export const StorageUnit = PropTypes.shape({
- _id: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
speedMbPerS: PropTypes.number.isRequired,
sizeMb: PropTypes.number.isRequired,
@@ -57,38 +49,45 @@ export const StorageUnit = PropTypes.shape({
})
export const Machine = PropTypes.shape({
- _id: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
position: PropTypes.number.isRequired,
- cpus: PropTypes.arrayOf(PropTypes.string),
- gpus: PropTypes.arrayOf(PropTypes.string),
- memories: PropTypes.arrayOf(PropTypes.string),
- storages: PropTypes.arrayOf(PropTypes.string),
+ cpus: PropTypes.arrayOf(PropTypes.oneOfType([ProcessingUnit, PropTypes.string])),
+ gpus: PropTypes.arrayOf(PropTypes.oneOfType([ProcessingUnit, PropTypes.string])),
+ memories: PropTypes.arrayOf(PropTypes.oneOfType([StorageUnit, PropTypes.string])),
+ storages: PropTypes.arrayOf(PropTypes.oneOfType([StorageUnit, PropTypes.string])),
})
export const Rack = PropTypes.shape({
- _id: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
capacity: PropTypes.number.isRequired,
powerCapacityW: PropTypes.number.isRequired,
- machines: PropTypes.arrayOf(PropTypes.string),
+ machines: PropTypes.arrayOf(PropTypes.oneOfType([Machine, PropTypes.string])),
})
export const Tile = PropTypes.shape({
- _id: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
positionX: PropTypes.number.isRequired,
positionY: PropTypes.number.isRequired,
- rack: PropTypes.string,
+ rack: PropTypes.oneOfType([Rack, PropTypes.string]),
})
export const Room = PropTypes.shape({
- _id: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
- tiles: PropTypes.arrayOf(PropTypes.string),
+ tiles: PropTypes.arrayOf(PropTypes.oneOfType([Tile, PropTypes.string])),
})
export const Topology = PropTypes.shape({
- _id: PropTypes.string.isRequired,
+ id: PropTypes.number.isRequired,
+ number: PropTypes.number.isRequired,
+ project: Project.isRequired,
name: PropTypes.string.isRequired,
- rooms: PropTypes.arrayOf(PropTypes.string),
+ rooms: PropTypes.arrayOf(PropTypes.oneOfType([Room, PropTypes.string])),
+})
+
+export const Phenomena = PropTypes.shape({
+ failures: PropTypes.bool.isRequired,
+ interference: PropTypes.bool.isRequired,
})
export const Scheduler = PropTypes.shape({
@@ -96,47 +95,82 @@ export const Scheduler = PropTypes.shape({
})
export const Trace = PropTypes.shape({
- _id: PropTypes.string.isRequired,
+ id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
})
-export const Portfolio = PropTypes.shape({
- _id: PropTypes.string.isRequired,
- projectId: PropTypes.string.isRequired,
+export const Workload = PropTypes.shape({
+ trace: Trace.isRequired,
+ samplingFraction: PropTypes.number.isRequired,
+})
+
+export const Targets = PropTypes.shape({
+ repeats: PropTypes.number.isRequired,
+ metrics: PropTypes.arrayOf(PropTypes.string).isRequired,
+})
+
+export const TopologySummary = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ number: PropTypes.number.isRequired,
+ project: Project.isRequired,
+ name: PropTypes.string.isRequired,
+})
+
+export const PortfolioSummary = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ number: PropTypes.number.isRequired,
+ project: Project.isRequired,
name: PropTypes.string.isRequired,
- scenarioIds: PropTypes.arrayOf(PropTypes.string).isRequired,
targets: PropTypes.shape({
- enabledMetrics: PropTypes.arrayOf(PropTypes.string).isRequired,
- repeatsPerScenario: PropTypes.number.isRequired,
+ repeats: PropTypes.number.isRequired,
+ metrics: PropTypes.arrayOf(PropTypes.string).isRequired,
}).isRequired,
})
-export const Scenario = PropTypes.shape({
- _id: PropTypes.string.isRequired,
- portfolioId: PropTypes.string.isRequired,
+export const ScenarioSummary = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ number: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
- simulation: PropTypes.shape({
- state: PropTypes.string.isRequired,
- }).isRequired,
- trace: PropTypes.shape({
- traceId: PropTypes.string.isRequired,
- trace: Trace,
- loadSamplingFraction: PropTypes.number.isRequired,
- }).isRequired,
- topology: PropTypes.shape({
- topologyId: PropTypes.string.isRequired,
- topology: Topology,
- }).isRequired,
- operational: PropTypes.shape({
- failuresEnabled: PropTypes.bool.isRequired,
- performanceInterferenceEnabled: PropTypes.bool.isRequired,
- schedulerName: PropTypes.string.isRequired,
- scheduler: Scheduler,
- }).isRequired,
+ workload: Workload.isRequired,
+ topology: TopologySummary.isRequired,
+ phenomena: Phenomena.isRequired,
+ schedulerName: PropTypes.string.isRequired,
results: PropTypes.object,
})
+export const JobState = PropTypes.oneOf(['PENDING', 'CLAIMED', 'RUNNING', 'FAILED', 'FINISHED'])
+
+export const Job = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ state: JobState.isRequired,
+ createdAt: PropTypes.string.isRequired,
+ updatedAt: PropTypes.string.isRequired,
+ results: PropTypes.object,
+})
+
+export const Scenario = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ number: PropTypes.number.isRequired,
+ project: Project.isRequired,
+ portfolio: PortfolioSummary.isRequired,
+ name: PropTypes.string.isRequired,
+ workload: Workload.isRequired,
+ topology: TopologySummary.isRequired,
+ phenomena: Phenomena.isRequired,
+ schedulerName: PropTypes.string.isRequired,
+ job: Job.isRequired,
+})
+
+export const Portfolio = PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ number: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ project: Project.isRequired,
+ targets: Targets.isRequired,
+ scenarios: PropTypes.arrayOf(ScenarioSummary).isRequired,
+})
+
export const WallSegment = PropTypes.shape({
startPosX: PropTypes.number.isRequired,
startPosY: PropTypes.number.isRequired,
diff --git a/opendc-web/opendc-web-ui/src/util/authorizations.js b/opendc-web/opendc-web-ui/src/util/authorizations.js
index ce5d34b6..fffcefeb 100644
--- a/opendc-web/opendc-web-ui/src/util/authorizations.js
+++ b/opendc-web/opendc-web-ui/src/util/authorizations.js
@@ -3,13 +3,13 @@ import EditIcon from '@patternfly/react-icons/dist/js/icons/edit-icon'
import EyeIcon from '@patternfly/react-icons/dist/js/icons/eye-icon'
export const AUTH_ICON_MAP = {
- OWN: HomeIcon,
- EDIT: EditIcon,
- VIEW: EyeIcon,
+ OWNER: HomeIcon,
+ EDITOR: EditIcon,
+ VIEWER: EyeIcon,
}
export const AUTH_DESCRIPTION_MAP = {
- OWN: 'Own',
- EDIT: 'Can Edit',
- VIEW: 'Can View',
+ OWNER: 'Own',
+ EDITOR: 'Can Edit',
+ VIEWER: 'Can View',
}
diff --git a/opendc-web/opendc-web-ui/src/util/topology-schema.js b/opendc-web/opendc-web-ui/src/util/topology-schema.js
index 7779ccfe..ff672dd6 100644
--- a/opendc-web/opendc-web-ui/src/util/topology-schema.js
+++ b/opendc-web/opendc-web-ui/src/util/topology-schema.js
@@ -22,10 +22,10 @@
import { schema } from 'normalizr'
-const Cpu = new schema.Entity('cpus', {}, { idAttribute: '_id' })
-const Gpu = new schema.Entity('gpus', {}, { idAttribute: '_id' })
-const Memory = new schema.Entity('memories', {}, { idAttribute: '_id' })
-const Storage = new schema.Entity('storages', {}, { idAttribute: '_id' })
+const Cpu = new schema.Entity('cpus', {}, { idAttribute: 'id' })
+const Gpu = new schema.Entity('gpus', {}, { idAttribute: 'id' })
+const Memory = new schema.Entity('memories', {}, { idAttribute: 'id' })
+const Storage = new schema.Entity('storages', {}, { idAttribute: 'id' })
export const Machine = new schema.Entity(
'machines',
@@ -35,13 +35,13 @@ export const Machine = new schema.Entity(
memories: [Memory],
storages: [Storage],
},
- { idAttribute: '_id' }
+ { idAttribute: 'id' }
)
-export const Rack = new schema.Entity('racks', { machines: [Machine] }, { idAttribute: '_id' })
+export const Rack = new schema.Entity('racks', { machines: [Machine] }, { idAttribute: 'id' })
-export const Tile = new schema.Entity('tiles', { rack: Rack }, { idAttribute: '_id' })
+export const Tile = new schema.Entity('tiles', { rack: Rack }, { idAttribute: 'id' })
-export const Room = new schema.Entity('rooms', { tiles: [Tile] }, { idAttribute: '_id' })
+export const Room = new schema.Entity('rooms', { tiles: [Tile] }, { idAttribute: 'id' })
-export const Topology = new schema.Entity('topologies', { rooms: [Room] }, { idAttribute: '_id' })
+export const Topology = new schema.Entity('topologies', { rooms: [Room] }, { idAttribute: 'id' })
diff --git a/opendc-web/opendc-web-ui/src/util/unit-specifications.js b/opendc-web/opendc-web-ui/src/util/unit-specifications.js
index 28479edd..3e3671cd 100644
--- a/opendc-web/opendc-web-ui/src/util/unit-specifications.js
+++ b/opendc-web/opendc-web-ui/src/util/unit-specifications.js
@@ -1,34 +1,34 @@
export const CPU_UNITS = {
'cpu-1': {
- _id: 'cpu-1',
+ id: 'cpu-1',
name: 'Intel i7 v6 6700k',
clockRateMhz: 4100,
numberOfCores: 4,
energyConsumptionW: 70,
},
'cpu-2': {
- _id: 'cpu-2',
+ id: 'cpu-2',
name: 'Intel i5 v6 6700k',
clockRateMhz: 3500,
numberOfCores: 2,
energyConsumptionW: 50,
},
'cpu-3': {
- _id: 'cpu-3',
+ id: 'cpu-3',
name: 'Intel® Xeon® E-2224G',
clockRateMhz: 3500,
numberOfCores: 4,
energyConsumptionW: 71,
},
'cpu-4': {
- _id: 'cpu-4',
+ id: 'cpu-4',
name: 'Intel® Xeon® E-2244G',
clockRateMhz: 3800,
numberOfCores: 8,
energyConsumptionW: 71,
},
'cpu-5': {
- _id: 'cpu-5',
+ id: 'cpu-5',
name: 'Intel® Xeon® E-2246G',
clockRateMhz: 3600,
numberOfCores: 12,
@@ -38,14 +38,14 @@ export const CPU_UNITS = {
export const GPU_UNITS = {
'gpu-1': {
- _id: 'gpu-1',
+ id: 'gpu-1',
name: 'NVIDIA GTX 4 1080',
clockRateMhz: 1200,
numberOfCores: 200,
energyConsumptionW: 250,
},
'gpu-2': {
- _id: 'gpu-2',
+ id: 'gpu-2',
name: 'NVIDIA Tesla V100',
clockRateMhz: 1200,
numberOfCores: 5120,
@@ -55,28 +55,28 @@ export const GPU_UNITS = {
export const MEMORY_UNITS = {
'memory-1': {
- _id: 'memory-1',
+ id: 'memory-1',
name: 'Samsung PC DRAM K4A4G045WD',
speedMbPerS: 16000,
sizeMb: 4000,
energyConsumptionW: 10,
},
'memory-2': {
- _id: 'memory-2',
+ id: 'memory-2',
name: 'Samsung PC DRAM M393A2K43BB1-CRC',
speedMbPerS: 2400,
sizeMb: 16000,
energyConsumptionW: 10,
},
'memory-3': {
- _id: 'memory-3',
+ id: 'memory-3',
name: 'Crucial MTA18ASF4G72PDZ-3G2E1',
speedMbPerS: 3200,
sizeMb: 32000,
energyConsumptionW: 10,
},
'memory-4': {
- _id: 'memory-4',
+ id: 'memory-4',
name: 'Crucial MTA9ASF2G72PZ-3G2E1',
speedMbPerS: 3200,
sizeMb: 16000,
@@ -86,14 +86,14 @@ export const MEMORY_UNITS = {
export const STORAGE_UNITS = {
'storage-1': {
- _id: 'storage-1',
+ id: 'storage-1',
name: 'Samsung EVO 2016 SATA III',
speedMbPerS: 6000,
sizeMb: 250000,
energyConsumptionW: 10,
},
'storage-2': {
- _id: 'storage-2',
+ id: 'storage-2',
name: 'Western Digital MTA9ASF2G72PZ-3G2E1',
speedMbPerS: 6000,
sizeMb: 4000000,