summaryrefslogtreecommitdiff
path: root/opendc-web/opendc-web-server/src/main/java/org
diff options
context:
space:
mode:
Diffstat (limited to 'opendc-web/opendc-web-server/src/main/java/org')
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Job.java142
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Portfolio.java135
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Project.java224
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/ProjectAuthorization.java163
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Scenario.java189
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Topology.java135
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Trace.java70
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/UserAccounting.java167
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Workload.java61
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/JobService.java138
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/PortfolioService.java148
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/ProjectService.java106
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/ScenarioService.java224
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/TopologyService.java178
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/TraceService.java55
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/UserAccountingService.java136
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/UserService.java58
-rw-r--r--opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/util/runner/QuarkusJobManager.java2
18 files changed, 2330 insertions, 1 deletions
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Job.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Job.java
new file mode 100644
index 00000000..14fd3e2a
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Job.java
@@ -0,0 +1,142 @@
+/*
+ * 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.server.model;
+
+import io.quarkus.hibernate.orm.panache.PanacheEntity;
+import io.quarkus.panache.common.Parameters;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.FetchType;
+import javax.persistence.JoinColumn;
+import javax.persistence.NamedQueries;
+import javax.persistence.NamedQuery;
+import javax.persistence.OneToOne;
+import javax.persistence.Table;
+import org.hibernate.annotations.Type;
+import org.opendc.web.proto.JobState;
+
+/**
+ * A simulation job to be run by the simulator.
+ */
+@Entity
+@Table(name = "jobs")
+@NamedQueries({
+ @NamedQuery(
+ name = "Job.updateOne",
+ query =
+ """
+ UPDATE Job j
+ SET j.state = :newState, j.updatedAt = :updatedAt, j.runtime = :runtime, j.results = :results
+ WHERE j.id = :id AND j.state = :oldState
+ """)
+})
+public class Job extends PanacheEntity {
+ @OneToOne(optional = false, mappedBy = "job", fetch = FetchType.EAGER)
+ @JoinColumn(name = "scenario_id", nullable = false)
+ public Scenario scenario;
+
+ @Column(name = "created_by", nullable = false, updatable = false)
+ public String createdBy;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ public Instant createdAt;
+
+ /**
+ * The number of simulation runs to perform.
+ */
+ @Column(nullable = false, updatable = false)
+ public int repeats;
+
+ /**
+ * The instant at which the job was updated.
+ */
+ @Column(name = "updated_at", nullable = false)
+ public Instant updatedAt = createdAt;
+
+ /**
+ * The state of the job.
+ */
+ @Column(nullable = false)
+ public JobState state = JobState.PENDING;
+
+ /**
+ * The runtime of the job (in seconds).
+ */
+ @Column(nullable = false)
+ public int runtime = 0;
+
+ /**
+ * Experiment results in JSON
+ */
+ @Type(type = "io.hypersistence.utils.hibernate.type.json.JsonType")
+ @Column(columnDefinition = "jsonb")
+ public Map<String, ?> results = null;
+
+ /**
+ * Construct a {@link Job} instance.
+ */
+ public Job(Scenario scenario, String createdBy, Instant createdAt, int repeats) {
+ this.createdBy = createdBy;
+ this.scenario = scenario;
+ this.createdAt = createdAt;
+ this.repeats = repeats;
+ }
+
+ /**
+ * JPA constructor
+ */
+ protected Job() {}
+
+ /**
+ * Find {@link Job}s in the specified {@link JobState}.
+ *
+ * @param state The state of the jobs to find.
+ * @return The list of jobs that are in the specified state.
+ */
+ public static List<Job> findByState(JobState state) {
+ return find("state", state).list();
+ }
+
+ /**
+ * Atomically update this job.
+ *
+ * @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 <code>true</code> when the update succeeded`, <code>false</code> when there was a conflict.
+ */
+ public boolean updateAtomically(JobState newState, Instant time, int runtime, Map<String, ?> results) {
+ long count = update(
+ "#Job.updateOne",
+ Parameters.with("id", id)
+ .and("oldState", state)
+ .and("newState", newState)
+ .and("updatedAt", time)
+ .and("runtime", runtime)
+ .and("results", results));
+ return count > 0;
+ }
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Portfolio.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Portfolio.java
new file mode 100644
index 00000000..4c3af570
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Portfolio.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2023 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION 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.server.model;
+
+import io.quarkus.hibernate.orm.panache.PanacheEntity;
+import io.quarkus.panache.common.Parameters;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import javax.persistence.CascadeType;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.JoinColumn;
+import javax.persistence.ManyToOne;
+import javax.persistence.NamedQueries;
+import javax.persistence.NamedQuery;
+import javax.persistence.OneToMany;
+import javax.persistence.OrderBy;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+import org.hibernate.annotations.Type;
+import org.opendc.web.proto.Targets;
+
+/**
+ * A portfolio is the composition of multiple scenarios.
+ */
+@Entity
+@Table(
+ name = "portfolios",
+ uniqueConstraints = {@UniqueConstraint(columnNames = {"project_id", "number"})},
+ indexes = {@Index(name = "fn_portfolios_number", columnList = "project_id, number")})
+@NamedQueries({
+ @NamedQuery(name = "Portfolio.findByProject", query = "SELECT p FROM Portfolio p WHERE p.project.id = :projectId"),
+ @NamedQuery(
+ name = "Portfolio.findOneByProject",
+ query = "SELECT p FROM Portfolio p WHERE p.project.id = :projectId AND p.number = :number")
+})
+public class Portfolio extends PanacheEntity {
+ /**
+ * The {@link Project} this portfolio belongs to.
+ */
+ @ManyToOne(optional = false)
+ @JoinColumn(name = "project_id", nullable = false)
+ public Project project;
+
+ /**
+ * Unique number of the portfolio for the project.
+ */
+ @Column(nullable = false)
+ public int number;
+
+ /**
+ * The name of this portfolio.
+ */
+ @Column(nullable = false)
+ public String name;
+
+ /**
+ * The portfolio targets (metrics, repetitions).
+ */
+ @Type(type = "io.hypersistence.utils.hibernate.type.json.JsonType")
+ @Column(columnDefinition = "jsonb", nullable = false, updatable = false)
+ public Targets targets;
+
+ /**
+ * The scenarios in this portfolio.
+ */
+ @OneToMany(
+ cascade = {CascadeType.ALL},
+ mappedBy = "portfolio",
+ orphanRemoval = true)
+ @OrderBy("id ASC")
+ public Set<Scenario> scenarios = new HashSet<>();
+
+ /**
+ * Construct a {@link Portfolio} object.
+ */
+ public Portfolio(Project project, int number, String name, Targets targets) {
+ this.project = project;
+ this.number = number;
+ this.name = name;
+ this.targets = targets;
+ }
+
+ /**
+ * JPA constructor
+ */
+ protected Portfolio() {}
+
+ /**
+ * Find all {@link Portfolio}s that belong to the specified project
+ *
+ * @param projectId The unique identifier of the project.
+ * @return The list of portfolios that belong to the specified project.
+ */
+ public static List<Portfolio> findByProject(long projectId) {
+ return find("#Portfolio.findByProject", Parameters.with("projectId", projectId))
+ .list();
+ }
+
+ /**
+ * Find the {@link Portfolio} with the specified <code>number</code> belonging to the specified project.
+ *
+ * @param projectId The unique identifier of the project.
+ * @param number The number of the scenario.
+ * @return The portfolio or <code>null</code> if it does not exist.
+ */
+ public static Portfolio findByProject(long projectId, int number) {
+ return find(
+ "#Portfolio.findOneByProject",
+ Parameters.with("projectId", projectId).and("number", number))
+ .firstResult();
+ }
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Project.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Project.java
new file mode 100644
index 00000000..5836e33f
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Project.java
@@ -0,0 +1,224 @@
+/*
+ * 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.server.model;
+
+import io.quarkus.hibernate.orm.panache.Panache;
+import io.quarkus.hibernate.orm.panache.PanacheEntity;
+import io.quarkus.panache.common.Parameters;
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.Set;
+import javax.persistence.CascadeType;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.NamedQueries;
+import javax.persistence.NamedQuery;
+import javax.persistence.OneToMany;
+import javax.persistence.OrderBy;
+import javax.persistence.Table;
+
+/**
+ * A project in OpenDC encapsulates all the datacenter designs and simulation runs for a set of users.
+ */
+@Entity
+@Table(name = "projects")
+@NamedQueries({
+ @NamedQuery(
+ name = "Project.findByUser",
+ 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
+ """)
+})
+public class Project extends PanacheEntity {
+ /**
+ * The name of the project.
+ */
+ @Column(nullable = false)
+ public String name;
+
+ /**
+ * The instant at which the project was created.
+ */
+ @Column(name = "created_at", nullable = false, updatable = false)
+ public Instant createdAt;
+
+ /**
+ * The instant at which the project was updated.
+ */
+ @Column(name = "updated_at", nullable = false)
+ public Instant updatedAt;
+
+ /**
+ * The portfolios belonging to this project.
+ */
+ @OneToMany(
+ cascade = {CascadeType.ALL},
+ mappedBy = "project",
+ orphanRemoval = true)
+ @OrderBy("id ASC")
+ public Set<Portfolio> portfolios = new HashSet<>();
+
+ /**
+ * The number of portfolios created for this project (including deleted portfolios).
+ */
+ @Column(name = "portfolios_created", nullable = false)
+ public int portfoliosCreated = 0;
+
+ /**
+ * The topologies belonging to this project.
+ */
+ @OneToMany(
+ cascade = {CascadeType.ALL},
+ mappedBy = "project",
+ orphanRemoval = true)
+ @OrderBy("id ASC")
+ public Set<Topology> topologies = new HashSet<>();
+
+ /**
+ * The number of topologies created for this project (including deleted topologies).
+ */
+ @Column(name = "topologies_created", nullable = false)
+ public int topologiesCreated = 0;
+
+ /**
+ * The scenarios belonging to this project.
+ */
+ @OneToMany(mappedBy = "project", orphanRemoval = true)
+ public Set<Scenario> scenarios = new HashSet<>();
+
+ /**
+ * The number of scenarios created for this project (including deleted scenarios).
+ */
+ @Column(name = "scenarios_created", nullable = false)
+ public int scenariosCreated = 0;
+
+ /**
+ * The users authorized to access the project.
+ */
+ @OneToMany(
+ cascade = {CascadeType.ALL},
+ mappedBy = "project",
+ orphanRemoval = true)
+ public Set<ProjectAuthorization> authorizations = new HashSet<>();
+
+ /**
+ * Construct a {@link Project} object.
+ */
+ public Project(String name, Instant createdAt) {
+ this.name = name;
+ this.createdAt = createdAt;
+ this.updatedAt = createdAt;
+ }
+
+ /**
+ * JPA constructor
+ */
+ protected Project() {}
+
+ /**
+ * Allocate the next portfolio number for the specified [project].
+ *
+ * @param time The time at which the new portfolio is created.
+ */
+ public int allocatePortfolio(Instant time) {
+ for (int i = 0; i < 4; i++) {
+ long count = update(
+ "#Project.allocatePortfolio",
+ Parameters.with("id", id).and("oldState", portfoliosCreated).and("now", time));
+ if (count > 0) {
+ return portfoliosCreated + 1;
+ } else {
+ Panache.getEntityManager().refresh(this);
+ }
+ }
+
+ throw new IllegalStateException("Failed to allocate next portfolio");
+ }
+
+ /**
+ * Allocate the next topology number for the specified [project].
+ *
+ * @param time The time at which the new topology is created.
+ */
+ public int allocateTopology(Instant time) {
+ for (int i = 0; i < 4; i++) {
+ long count = update(
+ "#Project.allocateTopology",
+ Parameters.with("id", id).and("oldState", topologiesCreated).and("now", time));
+ if (count > 0) {
+ return topologiesCreated + 1;
+ } else {
+ Panache.getEntityManager().refresh(this);
+ }
+ }
+
+ throw new IllegalStateException("Failed to allocate next topology");
+ }
+
+ /**
+ * Allocate the next scenario number for the specified [project].
+ *
+ * @param time The time at which the new scenario is created.
+ */
+ public int allocateScenario(Instant time) {
+ for (int i = 0; i < 4; i++) {
+ long count = update(
+ "#Project.allocateScenario",
+ Parameters.with("id", id).and("oldState", scenariosCreated).and("now", time));
+ if (count > 0) {
+ return scenariosCreated + 1;
+ } else {
+ Panache.getEntityManager().refresh(this);
+ }
+ }
+
+ throw new IllegalStateException("Failed to allocate next scenario");
+ }
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/ProjectAuthorization.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/ProjectAuthorization.java
new file mode 100644
index 00000000..c10fcc64
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/ProjectAuthorization.java
@@ -0,0 +1,163 @@
+/*
+ * 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.server.model;
+
+import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
+import io.quarkus.panache.common.Parameters;
+import java.io.Serializable;
+import java.util.List;
+import java.util.Objects;
+import javax.persistence.Column;
+import javax.persistence.Embeddable;
+import javax.persistence.EmbeddedId;
+import javax.persistence.Entity;
+import javax.persistence.JoinColumn;
+import javax.persistence.ManyToOne;
+import javax.persistence.MapsId;
+import javax.persistence.NamedQueries;
+import javax.persistence.NamedQuery;
+import javax.persistence.Table;
+import org.opendc.web.proto.user.ProjectRole;
+
+/**
+ * An authorization for some user to participate in a project.
+ */
+@Entity
+@Table(name = "project_authorizations")
+@NamedQueries({
+ @NamedQuery(
+ name = "ProjectAuthorization.findByUser",
+ query =
+ """
+ SELECT a
+ FROM ProjectAuthorization a
+ WHERE a.key.userId = :userId
+ """),
+})
+public class ProjectAuthorization extends PanacheEntityBase {
+ /**
+ * The user identifier of the authorization.
+ */
+ @EmbeddedId
+ public ProjectAuthorization.Key key;
+
+ /**
+ * 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)
+ public Project project;
+
+ /**
+ * The role of the user in the project.
+ */
+ @Column(nullable = false)
+ public ProjectRole role;
+
+ /**
+ * Construct a {@link ProjectAuthorization} object.
+ */
+ public ProjectAuthorization(Project project, String userId, ProjectRole role) {
+ this.key = new ProjectAuthorization.Key(project.id, userId);
+ this.project = project;
+ this.role = role;
+ }
+
+ /**
+ * JPA constructor
+ */
+ protected ProjectAuthorization() {}
+
+ /**
+ * List all projects for the user with the specified <code>userId</code>.
+ *
+ * @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.
+ */
+ public static List<ProjectAuthorization> findByUser(String userId) {
+ return find("#ProjectAuthorization.findByUser", Parameters.with("userId", userId))
+ .list();
+ }
+
+ /**
+ * Find the project with <code>id</code> for the user with the specified <code>userId</code>.
+ *
+ * @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 <code>null</code> if it does not exist or is not accessible
+ * to the user with the specified identifier.
+ */
+ public static ProjectAuthorization findByUser(String userId, long id) {
+ return findById(new ProjectAuthorization.Key(id, userId));
+ }
+
+ /**
+ * Determine whether the authorization allows the user to edit the project.
+ */
+ public boolean canEdit() {
+ return switch (role) {
+ case OWNER, EDITOR -> true;
+ case VIEWER -> false;
+ };
+ }
+
+ /**
+ * Determine whether the authorization allows the user to delete the project.
+ */
+ public boolean canDelete() {
+ return role == ProjectRole.OWNER;
+ }
+
+ /**
+ * Key for representing a {@link ProjectAuthorization} object.
+ */
+ @Embeddable
+ public static class Key implements Serializable {
+ @Column(name = "project_id", nullable = false)
+ public long projectId;
+
+ @Column(name = "user_id", nullable = false)
+ public String userId;
+
+ public Key(long projectId, String userId) {
+ this.projectId = projectId;
+ this.userId = userId;
+ }
+
+ protected Key() {}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Key key = (Key) o;
+ return projectId == key.projectId && userId.equals(key.userId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(projectId, userId);
+ }
+ }
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Scenario.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Scenario.java
new file mode 100644
index 00000000..9381f9be
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Scenario.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright (c) 2023 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION 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.server.model;
+
+import io.quarkus.hibernate.orm.panache.PanacheEntity;
+import io.quarkus.panache.common.Parameters;
+import java.util.List;
+import javax.persistence.CascadeType;
+import javax.persistence.Column;
+import javax.persistence.Embedded;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.JoinColumn;
+import javax.persistence.ManyToOne;
+import javax.persistence.NamedQueries;
+import javax.persistence.NamedQuery;
+import javax.persistence.OneToOne;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+import org.hibernate.annotations.Type;
+import org.opendc.web.proto.OperationalPhenomena;
+
+/**
+ * A single scenario to be explored by the simulator.
+ */
+@Entity
+@Table(
+ name = "scenarios",
+ uniqueConstraints = {@UniqueConstraint(columnNames = {"project_id", "number"})},
+ indexes = {@Index(name = "fn_scenarios_number", columnList = "project_id, number")})
+@NamedQueries({
+ @NamedQuery(name = "Scenario.findByProject", query = "SELECT s FROM Scenario s WHERE s.project.id = :projectId"),
+ @NamedQuery(
+ name = "Scenario.findByPortfolio",
+ 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.findOneByProject",
+ query = "SELECT s FROM Scenario s WHERE s.project.id = :projectId AND s.number = :number")
+})
+public class Scenario extends PanacheEntity {
+ /**
+ * The {@link Project} to which this scenario belongs.
+ */
+ @ManyToOne(optional = false)
+ @JoinColumn(name = "project_id", nullable = false)
+ public Project project;
+
+ /**
+ * The {@link Portfolio} to which this scenario belongs.
+ */
+ @ManyToOne(optional = false)
+ @JoinColumn(name = "portfolio_id", nullable = false)
+ public Portfolio portfolio;
+
+ /**
+ * Unique number of the scenario for the project.
+ */
+ @Column(nullable = false)
+ public int number;
+
+ /**
+ * The name of the scenario.
+ */
+ @Column(nullable = false, updatable = false)
+ public String name;
+
+ /**
+ * Workload details of the scenario.
+ */
+ @Embedded
+ public Workload workload;
+
+ /**
+ * Topology details of the scenario.
+ */
+ @ManyToOne(optional = false)
+ public Topology topology;
+
+ /**
+ * Operational phenomena activated in the scenario.
+ */
+ @Type(type = "io.hypersistence.utils.hibernate.type.json.JsonType")
+ @Column(columnDefinition = "jsonb", nullable = false, updatable = false)
+ public OperationalPhenomena phenomena;
+
+ /**
+ * The name of the VM scheduler used in the scenario.
+ */
+ @Column(name = "scheduler_name", nullable = false, updatable = false)
+ public String schedulerName;
+
+ /**
+ * The {@link Job} associated with the scenario.
+ */
+ @OneToOne(cascade = {CascadeType.ALL})
+ public Job job;
+
+ /**
+ * Construct a {@link Scenario} object.
+ */
+ public Scenario(
+ Project project,
+ Portfolio portfolio,
+ int number,
+ String name,
+ Workload workload,
+ Topology topology,
+ OperationalPhenomena phenomena,
+ String schedulerName) {
+ this.project = project;
+ this.portfolio = portfolio;
+ this.number = number;
+ this.name = name;
+ this.workload = workload;
+ this.topology = topology;
+ this.phenomena = phenomena;
+ this.schedulerName = schedulerName;
+ }
+
+ /**
+ * JPA constructor
+ */
+ protected Scenario() {}
+
+ /**
+ * Find all {@link Scenario}s that belong to the specified project
+ *
+ * @param projectId The unique identifier of the project.
+ * @return The list of scenarios that belong to the specified project.
+ */
+ public static List<Scenario> findByProject(long projectId) {
+ return find("#Scenario.findByProject", Parameters.with("projectId", projectId))
+ .list();
+ }
+
+ /**
+ * Find all {@link Scenario}s that belong to the specified portfolio.
+ *
+ * @param projectId The unique identifier of the project.
+ * @param number The number of the portfolio.
+ * @return The list of scenarios that belong to the specified project and portfolio..
+ */
+ public static List<Scenario> findByPortfolio(long projectId, int number) {
+ return find(
+ "#Scenario.findByPortfolio",
+ Parameters.with("projectId", projectId).and("number", number))
+ .list();
+ }
+
+ /**
+ * Find the {@link Scenario} with the specified <code>number</code> belonging to the specified project.
+ *
+ * @param projectId The unique identifier of the project.
+ * @param number The number of the scenario.
+ * @return The scenario or <code>null</code> if it does not exist.
+ */
+ public static Scenario findByProject(long projectId, int number) {
+ return find(
+ "#Scenario.findOneByProject",
+ Parameters.with("projectId", projectId).and("number", number))
+ .firstResult();
+ }
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Topology.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Topology.java
new file mode 100644
index 00000000..6ec83f78
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Topology.java
@@ -0,0 +1,135 @@
+/*
+ * 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.server.model;
+
+import io.quarkus.hibernate.orm.panache.PanacheEntity;
+import io.quarkus.panache.common.Parameters;
+import java.time.Instant;
+import java.util.List;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Index;
+import javax.persistence.JoinColumn;
+import javax.persistence.ManyToOne;
+import javax.persistence.NamedQueries;
+import javax.persistence.NamedQuery;
+import javax.persistence.Table;
+import javax.persistence.UniqueConstraint;
+import org.hibernate.annotations.Type;
+import org.opendc.web.proto.Room;
+
+/**
+ * A datacenter design in OpenDC.
+ */
+@Entity
+@Table(
+ name = "topologies",
+ uniqueConstraints = {@UniqueConstraint(columnNames = {"project_id", "number"})},
+ indexes = {@Index(name = "fn_topologies_number", columnList = "project_id, number")})
+@NamedQueries({
+ @NamedQuery(name = "Topology.findByProject", query = "SELECT t FROM Topology t WHERE t.project.id = :projectId"),
+ @NamedQuery(
+ name = "Topology.findOneByProject",
+ query = "SELECT t FROM Topology t WHERE t.project.id = :projectId AND t.number = :number")
+})
+public class Topology extends PanacheEntity {
+ /**
+ * The {@link Project} to which the topology belongs.
+ */
+ @ManyToOne(optional = false)
+ @JoinColumn(name = "project_id", nullable = false)
+ public Project project;
+
+ /**
+ * Unique number of the topology for the project.
+ */
+ @Column(nullable = false)
+ public int number;
+
+ /**
+ * The name of the topology.
+ */
+ @Column(nullable = false)
+ public String name;
+
+ /**
+ * The instant at which the topology was created.
+ */
+ @Column(name = "created_at", nullable = false, updatable = false)
+ public Instant createdAt;
+
+ /**
+ * The instant at which the topology was updated.
+ */
+ @Column(name = "updated_at", nullable = false)
+ public Instant updatedAt;
+
+ /**
+ * Datacenter design in JSON
+ */
+ @Type(type = "io.hypersistence.utils.hibernate.type.json.JsonType")
+ @Column(columnDefinition = "jsonb", nullable = false)
+ public List<Room> rooms;
+
+ /**
+ * Construct a {@link Topology} object.
+ */
+ public Topology(Project project, int number, String name, Instant createdAt, List<Room> rooms) {
+ this.project = project;
+ this.number = number;
+ this.name = name;
+ this.createdAt = createdAt;
+ this.updatedAt = createdAt;
+ this.rooms = rooms;
+ }
+
+ /**
+ * JPA constructor
+ */
+ protected Topology() {}
+
+ /**
+ * 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.
+ */
+ public static List<Topology> findByProject(long projectId) {
+ return find("#Topology.findByProject", Parameters.with("projectId", projectId))
+ .list();
+ }
+
+ /**
+ * 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.
+ */
+ public static Topology findByProject(long projectId, int number) {
+ return find(
+ "#Topology.findOneByProject",
+ Parameters.with("projectId", projectId).and("number", number))
+ .firstResult();
+ }
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Trace.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Trace.java
new file mode 100644
index 00000000..f73c8494
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Trace.java
@@ -0,0 +1,70 @@
+/*
+ * 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.server.model;
+
+import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Id;
+
+/**
+ * A workload trace available for simulation.
+ */
+@Entity
+public class Trace extends PanacheEntityBase {
+ /**
+ * The unique identifier of the trace.
+ */
+ @Id
+ public String id;
+
+ /**
+ * The name of the trace.
+ */
+ @Column(nullable = false, updatable = false)
+ public String name;
+
+ /**
+ * The type of trace.
+ */
+ @Column(nullable = false, updatable = false)
+ public String type;
+
+ /**
+ * Construct a {@link Trace}.
+ *
+ * @param id The unique identifier of the trace.
+ * @param name The name of the trace.
+ * @param type The type of trace.
+ */
+ public Trace(String id, String name, String type) {
+ this.id = id;
+ this.name = name;
+ this.type = type;
+ }
+
+ /**
+ * JPA constructor.
+ */
+ protected Trace() {}
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/UserAccounting.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/UserAccounting.java
new file mode 100644
index 00000000..fda4302f
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/UserAccounting.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright (c) 2023 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION 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.server.model;
+
+import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
+import io.quarkus.panache.common.Parameters;
+import java.time.LocalDate;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import javax.persistence.NamedQueries;
+import javax.persistence.NamedQuery;
+import javax.persistence.Table;
+
+/**
+ * Entity to track the number of simulation minutes used by a user.
+ */
+@Entity
+@Table(name = "user_accounting")
+@NamedQueries({
+ @NamedQuery(
+ name = "UserAccounting.consumeBudget",
+ query =
+ """
+ UPDATE UserAccounting a
+ SET a.simulationTime = a.simulationTime + :seconds
+ WHERE a.userId = :userId AND a.periodEnd = :periodEnd
+ """),
+ @NamedQuery(
+ name = "UserAccounting.resetBudget",
+ query =
+ """
+ UPDATE UserAccounting a
+ SET a.periodEnd = :periodEnd, a.simulationTime = :seconds
+ WHERE a.userId = :userId AND a.periodEnd = :oldPeriodEnd
+ """)
+})
+public class UserAccounting extends PanacheEntityBase {
+ /**
+ * User to which this object belongs.
+ */
+ @Id
+ @Column(name = "user_id", nullable = false)
+ public String userId;
+
+ /**
+ * The end of the accounting period.
+ */
+ @Column(name = "period_end", nullable = false)
+ public LocalDate periodEnd;
+
+ /**
+ * The number of simulation seconds to be used per accounting period.
+ */
+ @Column(name = "simulation_time_budget", nullable = false)
+ public int simulationTimeBudget;
+
+ /**
+ * The number of simulation seconds used in this period. This number should reset once the accounting period has
+ * been reached.
+ */
+ @Column(name = "simulation_time", nullable = false)
+ public int simulationTime = 0;
+
+ /**
+ * Construct a new {@link UserAccounting} object.
+ *
+ * @param userId The identifier of the user that this object belongs to.
+ * @param periodEnd The end of the accounting period.
+ * @param simulationTimeBudget The number of simulation seconds available per accounting period.
+ */
+ public UserAccounting(String userId, LocalDate periodEnd, int simulationTimeBudget) {
+ this.userId = userId;
+ this.periodEnd = periodEnd;
+ this.simulationTimeBudget = simulationTimeBudget;
+ }
+
+ /**
+ * JPA constructor.
+ */
+ protected UserAccounting() {}
+
+ /**
+ * Return the {@link UserAccounting} object associated with the specified user id.
+ */
+ public static UserAccounting findByUser(String userId) {
+ return findById(userId);
+ }
+
+ /**
+ * Create a new {@link UserAccounting} object and persist it to the database.
+ *
+ * @param userId The identifier of the user that this object belongs to.
+ * @param periodEnd The end of the accounting period.
+ * @param simulationTimeBudget The number of simulation seconds available per accounting period.
+ * @param simulationTime The initial simulation time that has been consumed.
+ */
+ public static UserAccounting create(
+ String userId, LocalDate periodEnd, int simulationTimeBudget, int simulationTime) {
+ UserAccounting newAccounting = new UserAccounting(userId, periodEnd, simulationTimeBudget);
+ newAccounting.simulationTime = simulationTime;
+ newAccounting.persistAndFlush();
+ return newAccounting;
+ }
+
+ /**
+ * Atomically consume the budget for this {@link UserAccounting} object.
+ *
+ * @param seconds The number of seconds to consume from the user.
+ * @return <code>true</code> when the update succeeded, <code>false</code> when there was a conflict.
+ */
+ public boolean consumeBudget(int seconds) {
+ long count = update(
+ "#UserAccounting.consumeBudget",
+ Parameters.with("userId", userId).and("periodEnd", periodEnd).and("seconds", seconds));
+ return count > 0;
+ }
+
+ /**
+ * Atomically reset the budget for this {@link UserAccounting} object.
+ *
+ * @param periodEnd The new end period for the budget.
+ * @param seconds The number of seconds that have already been consumed.
+ * @return <code>true</code> when the update succeeded`, <code>false</code> when there was a conflict.
+ */
+ public boolean resetBudget(LocalDate periodEnd, int seconds) {
+ long count = update(
+ "#UserAccounting.resetBudget",
+ Parameters.with("userId", userId)
+ .and("oldPeriodEnd", this.periodEnd)
+ .and("periodEnd", periodEnd)
+ .and("seconds", seconds));
+ return count > 0;
+ }
+
+ /**
+ * Determine whether the user has any remaining simulation budget.
+ *
+ * @return <code>true</code> when the user still has budget left, <code>false</code> otherwise.
+ */
+ public boolean hasSimulationBudget() {
+ var today = LocalDate.now();
+
+ // The accounting period must be over or there must be budget remaining.
+ return !today.isBefore(periodEnd) || simulationTimeBudget > simulationTime;
+ }
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Workload.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Workload.java
new file mode 100644
index 00000000..129fb0c5
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/model/Workload.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2023 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION 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.server.model;
+
+import javax.persistence.Column;
+import javax.persistence.Embeddable;
+import javax.persistence.ManyToOne;
+
+/**
+ * Specification of the workload for a {@link Scenario}
+ */
+@Embeddable
+public class Workload {
+ /**
+ * The {@link Trace} that the workload runs.
+ */
+ @ManyToOne(optional = false)
+ public Trace trace;
+
+ /**
+ * The percentage of the trace that should be sampled.
+ */
+ @Column(name = "sampling_fraction", nullable = false, updatable = false)
+ public double samplingFraction;
+
+ /**
+ * Construct a {@link Workload} object.
+ *
+ * @param trace The {@link Trace} to run as workload.
+ * @param samplingFraction The percentage of the workload to sample.
+ */
+ public Workload(Trace trace, double samplingFraction) {
+ this.trace = trace;
+ this.samplingFraction = samplingFraction;
+ }
+
+ /**
+ * JPA constructor.
+ */
+ protected Workload() {}
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/JobService.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/JobService.java
new file mode 100644
index 00000000..eb0982ec
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/JobService.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2023 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION 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.server.service;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import javax.enterprise.context.ApplicationScoped;
+import org.opendc.web.proto.JobState;
+import org.opendc.web.server.model.Job;
+
+/**
+ * Service for managing {@link Job}s.
+ */
+@ApplicationScoped
+public final class JobService {
+ /**
+ * The service for managing the user accounting.
+ */
+ private final UserAccountingService accountingService;
+
+ /**
+ * Construct a {@link JobService} instance.
+ *
+ * @param accountingService The {@link UserAccountingService} instance to use.
+ */
+ public JobService(UserAccountingService accountingService) {
+ this.accountingService = accountingService;
+ }
+
+ /**
+ * Query the pending simulation jobs.
+ */
+ public List<org.opendc.web.proto.runner.Job> listPending() {
+ return Job.findByState(JobState.PENDING).stream()
+ .map(JobService::toRunnerDto)
+ .toList();
+ }
+
+ /**
+ * Find a job by its identifier.
+ */
+ public org.opendc.web.proto.runner.Job findById(long id) {
+ return toRunnerDto(Job.findById(id));
+ }
+
+ /**
+ * Atomically update the state of a {@link Job}.
+ *
+ * @param id The identifier of the job.
+ * @param newState The next state for the job.
+ * @param runtime The runtime of the job (in seconds).
+ * @param results The potential results of the job.
+ */
+ public org.opendc.web.proto.runner.Job updateState(
+ long id, JobState newState, int runtime, Map<String, ?> results) {
+ Job entity = Job.findById(id);
+ if (entity == null) {
+ return null;
+ }
+
+ JobState state = entity.state;
+ if (!isTransitionLegal(state, newState)) {
+ throw new IllegalArgumentException("Invalid transition from %s to %s".formatted(state, newState));
+ }
+
+ Instant now = Instant.now();
+ JobState nextState = newState;
+ int consumedBudget = Math.min(1, runtime - entity.runtime);
+
+ // Check whether the user still has any simulation budget left
+ if (accountingService.consumeSimulationBudget(entity.createdBy, consumedBudget)
+ && nextState == JobState.RUNNING) {
+ nextState = JobState.FAILED; // User has consumed all their budget; cancel the job
+ }
+
+ if (!entity.updateAtomically(nextState, now, runtime, results)) {
+ throw new IllegalStateException("Conflicting update");
+ }
+
+ return toRunnerDto(entity);
+ }
+
+ /**
+ * Determine whether the transition from [this] to [newState] is legal.
+ */
+ public static boolean isTransitionLegal(JobState currentState, JobState newState) {
+ // Note that we always allow transitions from the state
+ return newState == currentState
+ || switch (currentState) {
+ case PENDING -> newState == JobState.CLAIMED;
+ case CLAIMED -> newState == JobState.RUNNING || newState == JobState.FAILED;
+ case RUNNING -> newState == JobState.FINISHED || newState == JobState.FAILED;
+ case FINISHED, FAILED -> false;
+ };
+ }
+
+ /**
+ * Convert a {@link Job} entity into a {@link org.opendc.web.proto.user.Job} DTO.
+ */
+ public static org.opendc.web.proto.user.Job toUserDto(Job job) {
+ return new org.opendc.web.proto.user.Job(job.id, job.state, job.createdAt, job.updatedAt, job.results);
+ }
+
+ /**
+ * Convert a {@link Job} into a runner-facing DTO.
+ */
+ public static org.opendc.web.proto.runner.Job toRunnerDto(Job job) {
+ return new org.opendc.web.proto.runner.Job(
+ job.id,
+ ScenarioService.toRunnerDto(job.scenario),
+ job.state,
+ job.createdAt,
+ job.updatedAt,
+ job.runtime,
+ job.results);
+ }
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/PortfolioService.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/PortfolioService.java
new file mode 100644
index 00000000..94da5195
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/PortfolioService.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright (c) 2023 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION 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.server.service;
+
+import java.time.Instant;
+import java.util.List;
+import javax.enterprise.context.ApplicationScoped;
+import org.opendc.web.server.model.Portfolio;
+import org.opendc.web.server.model.ProjectAuthorization;
+
+/**
+ * Service for managing {@link Portfolio}s.
+ */
+@ApplicationScoped
+public final class PortfolioService {
+ /**
+ * List all {@link Portfolio}s that belong a certain project.
+ */
+ public List<org.opendc.web.proto.user.Portfolio> findByUser(String userId, long projectId) {
+ // User must have access to project
+ ProjectAuthorization auth = ProjectAuthorization.findByUser(userId, projectId);
+
+ if (auth == null) {
+ return List.of();
+ }
+
+ return Portfolio.findByProject(projectId).stream()
+ .map((p) -> toUserDto(p, auth))
+ .toList();
+ }
+
+ /**
+ * Find a {@link Portfolio} with the specified <code>number</code> belonging to <code>projectId</code>.
+ */
+ public org.opendc.web.proto.user.Portfolio findByUser(String userId, long projectId, int number) {
+ // User must have access to project
+ ProjectAuthorization auth = ProjectAuthorization.findByUser(userId, projectId);
+
+ if (auth == null) {
+ return null;
+ }
+
+ Portfolio portfolio = Portfolio.findByProject(projectId, number);
+
+ if (portfolio == null) {
+ return null;
+ }
+
+ return toUserDto(portfolio, auth);
+ }
+
+ /**
+ * Delete the portfolio with the specified <code>number</code> belonging to <code>projectId</code>.
+ */
+ public org.opendc.web.proto.user.Portfolio delete(String userId, long projectId, int number) {
+ // User must have access to project
+ ProjectAuthorization auth = ProjectAuthorization.findByUser(userId, projectId);
+
+ if (auth == null) {
+ return null;
+ } else if (!auth.canEdit()) {
+ throw new IllegalStateException("Not permitted to edit project");
+ }
+
+ Portfolio entity = Portfolio.findByProject(projectId, number);
+ if (entity == null) {
+ return null;
+ }
+
+ entity.delete();
+ return toUserDto(entity, auth);
+ }
+
+ /**
+ * Construct a new {@link Portfolio} with the specified name.
+ */
+ public org.opendc.web.proto.user.Portfolio create(
+ String userId, long projectId, org.opendc.web.proto.user.Portfolio.Create request) {
+ // User must have access to project
+ ProjectAuthorization auth = ProjectAuthorization.findByUser(userId, projectId);
+
+ if (auth == null) {
+ return null;
+ } else if (!auth.canEdit()) {
+ throw new IllegalStateException("Not permitted to edit project");
+ }
+
+ var now = Instant.now();
+ var project = auth.project;
+ int number = project.allocatePortfolio(now);
+
+ Portfolio portfolio = new Portfolio(project, number, request.getName(), request.getTargets());
+
+ project.portfolios.add(portfolio);
+ portfolio.persist();
+
+ return toUserDto(portfolio, auth);
+ }
+
+ /**
+ * Convert a {@link Portfolio} entity into a {@link org.opendc.web.proto.user.Portfolio} DTO.
+ */
+ public static org.opendc.web.proto.user.Portfolio toUserDto(Portfolio portfolio, ProjectAuthorization auth) {
+ return new org.opendc.web.proto.user.Portfolio(
+ portfolio.id,
+ portfolio.number,
+ ProjectService.toUserDto(auth),
+ portfolio.name,
+ portfolio.targets,
+ portfolio.scenarios.stream().map(ScenarioService::toSummaryDto).toList());
+ }
+
+ /**
+ * Convert a {@link Portfolio} entity into a {@link org.opendc.web.proto.user.Portfolio.Summary} DTO.
+ */
+ public static org.opendc.web.proto.user.Portfolio.Summary toSummaryDto(Portfolio portfolio) {
+ return new org.opendc.web.proto.user.Portfolio.Summary(
+ portfolio.id, portfolio.number, portfolio.name, portfolio.targets);
+ }
+
+ /**
+ * Convert a {@link Portfolio} into a runner-facing DTO.
+ */
+ public static org.opendc.web.proto.runner.Portfolio toRunnerDto(Portfolio portfolio) {
+ return new org.opendc.web.proto.runner.Portfolio(
+ portfolio.id, portfolio.number, portfolio.name, portfolio.targets);
+ }
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/ProjectService.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/ProjectService.java
new file mode 100644
index 00000000..aeef664e
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/ProjectService.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright (c) 2023 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION 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.server.service;
+
+import java.time.Instant;
+import java.util.List;
+import javax.enterprise.context.ApplicationScoped;
+import org.opendc.web.proto.user.ProjectRole;
+import org.opendc.web.server.model.Project;
+import org.opendc.web.server.model.ProjectAuthorization;
+
+/**
+ * Service for managing {@link Project}s.
+ */
+@ApplicationScoped
+public final class ProjectService {
+ /**
+ * List all projects for the user with the specified <code>userId</code>.
+ */
+ public List<org.opendc.web.proto.user.Project> findByUser(String userId) {
+ return ProjectAuthorization.findByUser(userId).stream()
+ .map(ProjectService::toUserDto)
+ .toList();
+ }
+
+ /**
+ * Obtain the project with the specified <code>id</code> for the user with the specified <code>userId</code>.
+ */
+ public org.opendc.web.proto.user.Project findByUser(String userId, long id) {
+ ProjectAuthorization auth = ProjectAuthorization.findByUser(userId, id);
+
+ if (auth == null) {
+ return null;
+ }
+
+ return toUserDto(auth);
+ }
+
+ /**
+ * Create a new {@link Project} for the user with the specified <code>userId</code>.
+ */
+ public org.opendc.web.proto.user.Project create(String userId, String name) {
+ Instant now = Instant.now();
+ Project entity = new Project(name, now);
+ entity.persist();
+
+ ProjectAuthorization authorization = new ProjectAuthorization(entity, userId, ProjectRole.OWNER);
+
+ entity.authorizations.add(authorization);
+ authorization.persist();
+
+ return toUserDto(authorization);
+ }
+
+ /**
+ * Delete a project by its identifier.
+ *
+ * @param userId The user that invokes the action.
+ * @param id The identifier of the project.
+ */
+ public org.opendc.web.proto.user.Project delete(String userId, long id) {
+ ProjectAuthorization auth = ProjectAuthorization.findByUser(userId, id);
+
+ if (auth == null) {
+ return null;
+ }
+
+ if (!auth.canDelete()) {
+ throw new IllegalArgumentException("Not allowed to delete project");
+ }
+
+ auth.project.updatedAt = Instant.now();
+ org.opendc.web.proto.user.Project project = toUserDto(auth);
+ auth.project.delete();
+ return project;
+ }
+
+ /**
+ * Convert a {@link ProjectAuthorization} entity into a {@link Project} DTO.
+ */
+ public static org.opendc.web.proto.user.Project toUserDto(ProjectAuthorization auth) {
+ Project project = auth.project;
+ return new org.opendc.web.proto.user.Project(
+ project.id, project.name, project.createdAt, project.updatedAt, auth.role);
+ }
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/ScenarioService.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/ScenarioService.java
new file mode 100644
index 00000000..bf5206af
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/ScenarioService.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright (c) 2023 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION 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.server.service;
+
+import java.time.Instant;
+import java.util.List;
+import javax.enterprise.context.ApplicationScoped;
+import org.opendc.web.proto.JobState;
+import org.opendc.web.server.model.Job;
+import org.opendc.web.server.model.Portfolio;
+import org.opendc.web.server.model.ProjectAuthorization;
+import org.opendc.web.server.model.Scenario;
+import org.opendc.web.server.model.Topology;
+import org.opendc.web.server.model.Trace;
+import org.opendc.web.server.model.Workload;
+
+/**
+ * Service for managing {@link Scenario}s.
+ */
+@ApplicationScoped
+public final class ScenarioService {
+ /**
+ * The service for managing the user accounting.
+ */
+ private final UserAccountingService accountingService;
+
+ /**
+ * Construct a {@link ScenarioService} instance.
+ *
+ * @param accountingService The {@link UserAccountingService} instance to use.
+ */
+ public ScenarioService(UserAccountingService accountingService) {
+ this.accountingService = accountingService;
+ }
+
+ /**
+ * List all {@link Scenario}s that belong a certain portfolio.
+ */
+ public List<org.opendc.web.proto.user.Scenario> findAll(String userId, long projectId, int number) {
+ // User must have access to project
+ ProjectAuthorization auth = ProjectAuthorization.findByUser(userId, projectId);
+
+ if (auth == null) {
+ return List.of();
+ }
+
+ return Scenario.findByPortfolio(projectId, number).stream()
+ .map((s) -> toUserDto(s, auth))
+ .toList();
+ }
+
+ /**
+ * Obtain a {@link Scenario} by identifier.
+ */
+ public org.opendc.web.proto.user.Scenario findOne(String userId, long projectId, int number) {
+ // User must have access to project
+ ProjectAuthorization auth = ProjectAuthorization.findByUser(userId, projectId);
+
+ if (auth == null) {
+ return null;
+ }
+
+ Scenario scenario = Scenario.findByProject(projectId, number);
+
+ if (scenario == null) {
+ return null;
+ }
+
+ return toUserDto(scenario, auth);
+ }
+
+ /**
+ * Delete the specified scenario.
+ */
+ public org.opendc.web.proto.user.Scenario delete(String userId, long projectId, int number) {
+ // User must have access to project
+ ProjectAuthorization auth = ProjectAuthorization.findByUser(userId, projectId);
+
+ if (auth == null) {
+ return null;
+ } else if (!auth.canEdit()) {
+ throw new IllegalStateException("Not permitted to edit project");
+ }
+
+ Scenario entity = Scenario.findByProject(projectId, number);
+ if (entity == null) {
+ return null;
+ }
+
+ entity.delete();
+ return toUserDto(entity, auth);
+ }
+
+ /**
+ * Construct a new {@link Scenario} with the specified data.
+ */
+ public org.opendc.web.proto.user.Scenario create(
+ String userId, long projectId, int portfolioNumber, org.opendc.web.proto.user.Scenario.Create request) {
+ // User must have access to project
+ ProjectAuthorization auth = ProjectAuthorization.findByUser(userId, projectId);
+
+ if (auth == null) {
+ return null;
+ } else if (!auth.canEdit()) {
+ throw new IllegalStateException("Not permitted to edit project");
+ }
+
+ Portfolio portfolio = Portfolio.findByProject(projectId, portfolioNumber);
+
+ if (portfolio == null) {
+ return null;
+ }
+
+ Topology topology = Topology.findByProject(projectId, (int) request.getTopology());
+ if (topology == null) {
+ throw new IllegalArgumentException("Referred topology does not exist");
+ }
+
+ Trace trace = Trace.findById(request.getWorkload().getTrace());
+ if (trace == null) {
+ throw new IllegalArgumentException("Referred trace does not exist");
+ }
+
+ var now = Instant.now();
+ var project = auth.project;
+ int number = project.allocateScenario(now);
+
+ Scenario scenario = new Scenario(
+ project,
+ portfolio,
+ number,
+ request.getName(),
+ new Workload(trace, request.getWorkload().getSamplingFraction()),
+ topology,
+ request.getPhenomena(),
+ request.getSchedulerName());
+ Job job = new Job(scenario, userId, now, portfolio.targets.getRepeats());
+
+ // Fail the job if there is not enough budget for the simulation
+ if (!accountingService.hasSimulationBudget(userId)) {
+ job.state = JobState.FAILED;
+ }
+
+ scenario.job = job;
+ portfolio.scenarios.add(scenario);
+ scenario.persist();
+
+ return toUserDto(scenario, auth);
+ }
+
+ /**
+ * Convert a {@link Scenario} entity into a {@link org.opendc.web.proto.user.Scenario} DTO.
+ */
+ public static org.opendc.web.proto.user.Scenario toUserDto(Scenario scenario, ProjectAuthorization auth) {
+ return new org.opendc.web.proto.user.Scenario(
+ scenario.id,
+ scenario.number,
+ ProjectService.toUserDto(auth),
+ PortfolioService.toSummaryDto(scenario.portfolio),
+ scenario.name,
+ toDto(scenario.workload),
+ TopologyService.toSummaryDto(scenario.topology),
+ scenario.phenomena,
+ scenario.schedulerName,
+ JobService.toUserDto(scenario.job));
+ }
+
+ /**
+ * Convert a {@link Scenario} entity into a {@link org.opendc.web.proto.user.Scenario.Summary} DTO.
+ */
+ public static org.opendc.web.proto.user.Scenario.Summary toSummaryDto(Scenario scenario) {
+ return new org.opendc.web.proto.user.Scenario.Summary(
+ scenario.id,
+ scenario.number,
+ scenario.name,
+ toDto(scenario.workload),
+ TopologyService.toSummaryDto(scenario.topology),
+ scenario.phenomena,
+ scenario.schedulerName,
+ JobService.toUserDto(scenario.job));
+ }
+
+ /**
+ * Convert a {@link Scenario} into a runner-facing DTO.
+ */
+ public static org.opendc.web.proto.runner.Scenario toRunnerDto(Scenario scenario) {
+ return new org.opendc.web.proto.runner.Scenario(
+ scenario.id,
+ scenario.number,
+ PortfolioService.toRunnerDto(scenario.portfolio),
+ scenario.name,
+ toDto(scenario.workload),
+ TopologyService.toRunnerDto(scenario.topology),
+ scenario.phenomena,
+ scenario.schedulerName);
+ }
+
+ /**
+ * Convert a {@link Workload} entity into a DTO.
+ */
+ public static org.opendc.web.proto.Workload toDto(Workload workload) {
+ return new org.opendc.web.proto.Workload(TraceService.toUserDto(workload.trace), workload.samplingFraction);
+ }
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/TopologyService.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/TopologyService.java
new file mode 100644
index 00000000..1961995f
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/TopologyService.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright (c) 2023 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION 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.server.service;
+
+import java.time.Instant;
+import java.util.List;
+import javax.enterprise.context.ApplicationScoped;
+import org.opendc.web.server.model.Project;
+import org.opendc.web.server.model.ProjectAuthorization;
+import org.opendc.web.server.model.Topology;
+
+/**
+ * Service for managing {@link Topology}s.
+ */
+@ApplicationScoped
+public final class TopologyService {
+ /**
+ * List all {@link Topology}s that belong a certain project.
+ */
+ public List<org.opendc.web.proto.user.Topology> findAll(String userId, long projectId) {
+ // User must have access to project
+ ProjectAuthorization auth = ProjectAuthorization.findByUser(userId, projectId);
+
+ if (auth == null) {
+ return List.of();
+ }
+
+ return Topology.findByProject(projectId).stream()
+ .map((t) -> toUserDto(t, auth))
+ .toList();
+ }
+
+ /**
+ * Find the {@link Topology} with the specified <code>number</code> belonging to <code>projectId</code>.
+ */
+ public org.opendc.web.proto.user.Topology findOne(String userId, long projectId, int number) {
+ // User must have access to project
+ ProjectAuthorization auth = ProjectAuthorization.findByUser(userId, projectId);
+
+ if (auth == null) {
+ return null;
+ }
+
+ Topology topology = Topology.findByProject(projectId, number);
+
+ if (topology == null) {
+ return null;
+ }
+
+ return toUserDto(topology, auth);
+ }
+
+ /**
+ * Delete the {@link Topology} with the specified <code>number</code> belonging to <code>projectId</code>
+ */
+ public org.opendc.web.proto.user.Topology delete(String userId, long projectId, int number) {
+ // User must have access to project
+ ProjectAuthorization auth = ProjectAuthorization.findByUser(userId, projectId);
+
+ if (auth == null) {
+ return null;
+ } else if (!auth.canEdit()) {
+ throw new IllegalStateException("Not permitted to edit project");
+ }
+
+ Topology entity = Topology.findByProject(projectId, number);
+
+ if (entity == null) {
+ return null;
+ }
+
+ entity.updatedAt = Instant.now();
+ entity.delete();
+ return toUserDto(entity, auth);
+ }
+
+ /**
+ * Update a {@link Topology} with the specified <code>number</code> belonging to <code>projectId</code>.
+ */
+ public org.opendc.web.proto.user.Topology update(
+ String userId, long projectId, int number, org.opendc.web.proto.user.Topology.Update request) {
+ // User must have access to project
+ ProjectAuthorization auth = ProjectAuthorization.findByUser(userId, projectId);
+
+ if (auth == null) {
+ return null;
+ } else if (!auth.canEdit()) {
+ throw new IllegalStateException("Not permitted to edit project");
+ }
+
+ Topology entity = Topology.findByProject(projectId, number);
+
+ if (entity == null) {
+ return null;
+ }
+
+ entity.updatedAt = Instant.now();
+ entity.rooms = request.getRooms();
+
+ return toUserDto(entity, auth);
+ }
+
+ /**
+ * Construct a new {@link Topology} with the specified name.
+ */
+ public org.opendc.web.proto.user.Topology create(
+ String userId, long projectId, org.opendc.web.proto.user.Topology.Create request) {
+ // User must have access to project
+ ProjectAuthorization auth = ProjectAuthorization.findByUser(userId, projectId);
+
+ if (auth == null) {
+ return null;
+ } else if (!auth.canEdit()) {
+ throw new IllegalStateException("Not permitted to edit project");
+ }
+
+ Instant now = Instant.now();
+ Project project = auth.project;
+ int number = project.allocateTopology(now);
+
+ Topology topology = new Topology(project, number, request.getName(), now, request.getRooms());
+
+ project.topologies.add(topology);
+ topology.persist();
+
+ return toUserDto(topology, auth);
+ }
+
+ /**
+ * Convert a {@link Topology} entity into a {@link org.opendc.web.proto.user.Topology} DTO.
+ */
+ public static org.opendc.web.proto.user.Topology toUserDto(Topology topology, ProjectAuthorization auth) {
+ return new org.opendc.web.proto.user.Topology(
+ topology.id,
+ topology.number,
+ ProjectService.toUserDto(auth),
+ topology.name,
+ topology.rooms,
+ topology.createdAt,
+ topology.updatedAt);
+ }
+
+ /**
+ * Convert a {@link Topology} entity into a {@link org.opendc.web.proto.user.Topology.Summary} DTO.
+ */
+ public static org.opendc.web.proto.user.Topology.Summary toSummaryDto(Topology topology) {
+ return new org.opendc.web.proto.user.Topology.Summary(
+ topology.id, topology.number, topology.name, topology.createdAt, topology.updatedAt);
+ }
+
+ /**
+ * Convert a {@link Topology} into a runner-facing DTO.
+ */
+ public static org.opendc.web.proto.runner.Topology toRunnerDto(Topology topology) {
+ return new org.opendc.web.proto.runner.Topology(
+ topology.id, topology.number, topology.name, topology.rooms, topology.createdAt, topology.updatedAt);
+ }
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/TraceService.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/TraceService.java
new file mode 100644
index 00000000..94b8340b
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/TraceService.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2023 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION 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.server.service;
+
+import java.util.List;
+import javax.enterprise.context.ApplicationScoped;
+import org.opendc.web.server.model.Trace;
+
+/**
+ * Service for managing {@link Trace}s.
+ */
+@ApplicationScoped
+public final class TraceService {
+ /**
+ * Obtain all available workload traces.
+ */
+ public List<org.opendc.web.proto.Trace> findAll() {
+ List<Trace> entities = Trace.listAll();
+ return entities.stream().map(TraceService::toUserDto).toList();
+ }
+
+ /**
+ * Obtain a workload trace by identifier.
+ */
+ public org.opendc.web.proto.Trace findById(String id) {
+ return toUserDto(Trace.findById(id));
+ }
+
+ /**
+ * Convert a {@link Trace] entity into a {@link org.opendc.web.proto.Trace} DTO.
+ */
+ public static org.opendc.web.proto.Trace toUserDto(Trace trace) {
+ return new org.opendc.web.proto.Trace(trace.id, trace.name, trace.type);
+ }
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/UserAccountingService.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/UserAccountingService.java
new file mode 100644
index 00000000..e5003cb4
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/UserAccountingService.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright (c) 2023 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION 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.server.service;
+
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.temporal.TemporalAdjusters;
+import javax.enterprise.context.ApplicationScoped;
+import javax.persistence.EntityExistsException;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.opendc.web.server.model.UserAccounting;
+
+/**
+ * Service to track the simulation budget of users.
+ */
+@ApplicationScoped
+public final class UserAccountingService {
+ /**
+ * The default simulation budget for new users.
+ */
+ private final Duration simulationBudget;
+
+ /**
+ * Construct a {@link UserAccountingService} instance.
+ *
+ * @param simulationBudget The default simulation budget for new users.
+ */
+ public UserAccountingService(
+ @ConfigProperty(name = "opendc.accounting.simulation-budget", defaultValue = "2000m")
+ Duration simulationBudget) {
+ this.simulationBudget = simulationBudget;
+ }
+
+ /**
+ * Return the {@link org.opendc.web.proto.user.UserAccounting} object for the user with the
+ * specified <code>userId</code>. If the object does not exist in the database, a default value is constructed.
+ */
+ public org.opendc.web.proto.user.UserAccounting getAccounting(String userId) {
+ UserAccounting accounting = UserAccounting.findByUser(userId);
+ if (accounting != null) {
+ return new org.opendc.web.proto.user.UserAccounting(
+ accounting.periodEnd, accounting.simulationTime, accounting.simulationTimeBudget);
+ }
+
+ return new org.opendc.web.proto.user.UserAccounting(
+ getNextAccountingPeriod(LocalDate.now()), 0, (int) simulationBudget.toSeconds());
+ }
+
+ /**
+ * Determine whether the user with <code>userId</code> has any remaining simulation budget.
+ *
+ * @param userId The unique identifier of the user.
+ * @return <code>true</code> when the user still has budget left, <code>false</code> otherwise.
+ */
+ public boolean hasSimulationBudget(String userId) {
+ UserAccounting accounting = UserAccounting.findByUser(userId);
+ if (accounting == null) {
+ return true;
+ }
+ return accounting.hasSimulationBudget();
+ }
+
+ /**
+ * Consume <code>seconds</code> from the simulation budget of the user with <code>userId</code>.
+ *
+ * @param userId The unique identifier of the user.
+ * @param seconds The seconds to consume from the simulation budget.
+ * @return <code>true</code> if the user has consumed his full budget or <code>false</code> if there is still budget
+ * remaining.
+ */
+ public boolean consumeSimulationBudget(String userId, int seconds) {
+ LocalDate today = LocalDate.now();
+ LocalDate nextAccountingPeriod = getNextAccountingPeriod(today);
+
+ // We need to be careful to prevent conflicts in case of concurrency
+ // 1. First, we try to create the accounting object if it does not exist yet. This may fail if another instance
+ // creates the object concurrently.
+ // 2. Second, we check if the budget needs to be reset and try this atomically.
+ // 3. Finally, we atomically consume the budget from the object
+ // This is repeated three times in case there is a conflict
+ for (int i = 0; i < 3; i++) {
+ UserAccounting accounting = UserAccounting.findByUser(userId);
+
+ if (accounting == null) {
+ try {
+ UserAccounting newAccounting = UserAccounting.create(
+ userId, nextAccountingPeriod, (int) simulationBudget.toSeconds(), seconds);
+ return !newAccounting.hasSimulationBudget();
+ } catch (EntityExistsException e) {
+ // Conflict due to concurrency; retry
+ }
+ } else {
+ boolean success;
+
+ if (!today.isBefore(accounting.periodEnd)) {
+ success = accounting.resetBudget(nextAccountingPeriod, seconds);
+ } else {
+ success = accounting.consumeBudget(seconds);
+ }
+
+ if (success) {
+ return !accounting.hasSimulationBudget();
+ }
+ }
+ }
+
+ throw new IllegalStateException("Failed to allocate consume budget due to conflict");
+ }
+
+ /**
+ * Helper method to find next accounting period.
+ */
+ private static LocalDate getNextAccountingPeriod(LocalDate today) {
+ return today.with(TemporalAdjusters.firstDayOfNextMonth());
+ }
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/UserService.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/UserService.java
new file mode 100644
index 00000000..b46b799b
--- /dev/null
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/service/UserService.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2023 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION 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.server.service;
+
+import io.quarkus.security.identity.SecurityIdentity;
+import javax.enterprise.context.ApplicationScoped;
+import org.opendc.web.proto.user.User;
+import org.opendc.web.proto.user.UserAccounting;
+
+/**
+ * Service for managing {@link User}s.
+ */
+@ApplicationScoped
+public final class UserService {
+ /**
+ * The service for managing the user accounting.
+ */
+ private final UserAccountingService accountingService;
+
+ /**
+ * Construct a {@link UserService} instance.
+ *
+ * @param accountingService The {@link UserAccountingService} instance to use.
+ */
+ public UserService(UserAccountingService accountingService) {
+ this.accountingService = accountingService;
+ }
+
+ /**
+ * Obtain the {@link User} object for the specified <code>identity</code>.
+ */
+ public User getUser(SecurityIdentity identity) {
+ String userId = identity.getPrincipal().getName();
+ UserAccounting accounting = accountingService.getAccounting(userId);
+
+ return new User(userId, accounting);
+ }
+}
diff --git a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/util/runner/QuarkusJobManager.java b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/util/runner/QuarkusJobManager.java
index ed1f7bf1..84ebd6e4 100644
--- a/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/util/runner/QuarkusJobManager.java
+++ b/opendc-web/opendc-web-server/src/main/java/org/opendc/web/server/util/runner/QuarkusJobManager.java
@@ -47,7 +47,7 @@ public class QuarkusJobManager implements JobManager {
@Nullable
@Override
public Job findNext() {
- var pending = jobService.queryPending();
+ var pending = jobService.listPending();
if (pending.isEmpty()) {
return null;
}